diff --git a/.gitattributes b/.gitattributes index e4a509285..c921772ae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ *.png filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text *.npy filter=lfs diff=lfs merge=lfs -text - diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..f33328dce --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @kushalkolar @clewis7 diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 000000000..bec47fdc5 --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,24 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + lint: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + steps: + - uses: actions/checkout@v4 + - uses: psf/black@stable + with: + src: "./fastplotlib" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fddfae5f4..9adb67f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ on: - ready_for_review jobs: - docs-build: name: Docs runs-on: bigmem + if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false steps: @@ -47,15 +47,13 @@ jobs: make html SPHINXOPTS="-W --keep-going" test-build-full: - name: Test examples, env with notebook and glfw + name: Test Linux, notebook + glfw runs-on: bigmem if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false matrix: include: - - name: Test py39 - pyversion: '3.9' - name: Test py310 pyversion: '3.10' - name: Test py311 @@ -93,8 +91,9 @@ jobs: env: PYGFX_EXPECT_LAVAPIPE: true run: | + WGPU_FORCE_OFFSCREEN=1 pytest -v tests/ pytest -v examples - pytest --nbmake examples/notebooks/ + FASTPLOTLIB_NB_TESTS=1 pytest --nbmake examples/notebooks/ - uses: actions/upload-artifact@v3 if: ${{ failure() }} with: @@ -104,15 +103,13 @@ jobs: examples/notebooks/diffs test-build-desktop: - name: Test examples, env with only glfw + name: Test Linux, only glfw runs-on: bigmem if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false matrix: include: - - name: Test py39 - pyversion: '3.9' - name: Test py310 pyversion: '3.10' - name: Test py311 @@ -150,6 +147,7 @@ jobs: env: PYGFX_EXPECT_LAVAPIPE: true run: | + WGPU_FORCE_OFFSCREEN=1 pytest -v tests/ pytest -v examples - uses: actions/upload-artifact@v3 if: ${{ failure() }} @@ -157,3 +155,87 @@ jobs: name: screenshot-diffs path: | examples/desktop/diffs + +# test-build-full-mac: +# name: Test Mac, notebook + glfw +# runs-on: macos-14 +# if: ${{ !github.event.pull_request.draft }} +# strategy: +# fail-fast: false +# matrix: +# include: +# - name: Test py310 +# pyversion: '3.10' +# - name: Test py311 +# pyversion: '3.11' +# - name: Test py312 +# pyversion: '3.12' +# steps: +# - uses: actions/checkout@v3 +# with: +# lfs: true +# - name: Set up Python +# uses: actions/setup-python@v3 +# with: +# python-version: ${{ matrix.pyversion }} +# - name: Install dev dependencies +# run: | +# python -m pip install --upgrade pip setuptools +# # remove pygfx from install_requires, we install using pygfx@main +# pip install -e ".["tests"]" +# pip install git+https://github.com/pygfx/pygfx.git@main +# - name: Show wgpu backend +# run: +# python -c "from examples.tests.testutils import wgpu_backend; print(wgpu_backend)" +# - name: Test examples +# run: | +# pytest -v examples +# pytest --nbmake examples/notebooks/ +# - uses: actions/upload-artifact@v3 +# if: ${{ failure() }} +# with: +# name: screenshot-diffs +# path: | +# examples/desktop/diffs +# examples/notebooks/diffs +# +# test-build-glfw-mac: +# name: Test Mac, glfw +# runs-on: macos-14 +# if: ${{ !github.event.pull_request.draft }} +# strategy: +# fail-fast: false +# matrix: +# include: +# - name: Test py310 +# pyversion: '3.10' +# - name: Test py311 +# pyversion: '3.11' +# - name: Test py312 +# pyversion: '3.12' +# steps: +# - uses: actions/checkout@v3 +# with: +# lfs: true +# - name: Set up Python +# uses: actions/setup-python@v3 +# with: +# python-version: ${{ matrix.pyversion }} +# - name: Install dev dependencies +# run: | +# python -m pip install --upgrade pip setuptools +# # remove pygfx from install_requires, we install using pygfx@main +# pip install -e ".["tests-desktop"]" +# pip install git+https://github.com/pygfx/pygfx.git@main +# - name: Show wgpu backend +# run: +# python -c "from examples.tests.testutils import wgpu_backend; print(wgpu_backend)" +# - name: Test examples +# run: | +# pytest -v examples +# - uses: actions/upload-artifact@v3 +# if: ${{ failure() }} +# with: +# name: screenshot-diffs +# path: | +# examples/desktop/diffs diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 207d92351..9ebe52b87 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -21,7 +21,14 @@ jobs: runs-on: ubuntu-latest steps: + - name: Install git-lfs + run: | + sudo apt install --no-install-recommends -y git-lfs - uses: actions/checkout@v3 + - name: fetch git lfs files + run: | + git lfs fetch --all + git lfs pull - name: Set up Python uses: actions/setup-python@v3 with: diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index d3cdb919b..baad8b655 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -48,7 +48,7 @@ jobs: run: | # regenerate screenshots REGENERATE_SCREENSHOTS=1 pytest -v examples - REGENERATE_SCREENSHOTS=1 pytest --nbmake examples/notebooks/ + FASTPLOTLIB_NB_TESTS=1 REGENERATE_SCREENSHOTS=1 pytest --nbmake examples/notebooks/ - uses: actions/upload-artifact@v3 if: always() with: diff --git a/.gitignore b/.gitignore index f87eb1c51..c599d5f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,7 @@ dmypy.json # Pycharm .idea/ +# vs code +.vscode/ + +examples/desktop/diffs/*.png diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bf5c69ea..0786596b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,27 +2,28 @@ Contributions are welcome! :smile: -## Instructions +## Installation 1. Fork the repo to your own GitHub account, click the "Fork" button at the top: ![image](https://github.com/kushalkolar/fastplotlib/assets/9403332/82612021-37b2-48dd-b7e4-01a919535c17) -2. Clone the repo and install according to the development instructions. Replace the `YOUR_ACCOUNT` in the repo URL to the fork on your account: +2. Clone the repo and install according to the development instructions. Replace the `YOUR_ACCOUNT` in the repo URL to the fork on your account. We use [git-lfs](https://git-lfs.com) for storing large files, such as ground-truths for tests, so you will need to [install it](https://github.com/git-lfs/git-lfs#installing) before cloning the repo. ```bash git clone https://github.com/YOUR_ACCOUNT/fastplotlib.git cd fastplotlib # install all extras in place -pip install -e ".[notebook,docs,tests] +pip install -e ".[notebook,docs,tests]" ``` +> If you cloned the repo before installing `git-lfs`, you can run `git lfs pull` at any +> time to download the files stored on LFS + 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. The screenshots will be generated on github actions servers, which you can then copy into the screenshots dir. :) +If your contributions modify how visualizations look, see the "Tests in detail" section at the very bottom. ```bash cd fastplotlib @@ -32,14 +33,14 @@ git checkout main # checkout your new branch from main git checkout -b my-new-feature-branch -# make your changes -# run tests -REGENERATE_SCREENSHOTS=1 pytest -v -k examples - -# make some changes, lint with black, and commit +# make some changes, lint with black black . -# add only your changed files, not the entire repo, do not add changes to examples/screenshots +# run tests from the repo root dir +pytest -v examples +FASTPLOTLIB_NB_TESTS=1 pytest --nbmake examples/notebooks/ + +# add your changed files, do not add any changes from screenshot diff dirs git add my_changed_files # commit changes @@ -50,3 +51,192 @@ git push origin my-new-feature-branch ``` 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! + +## How fastplotlib works + +Fastplotlib uses the [`pygfx`](https://github.com/pygfx/pygfx) rendering engine to give users a high-level scientific +plotting library. Some degree of familiarity with [`pygfx`](https://github.com/pygfx/pygfx) or rendering engines may +be useful depending on the type of contribution you're working on. + +There are currently 2 major subpackages within `fastplotlib`, `layouts` and `graphics`. The user-facing public +class within `layouts` is `Figure`. A user is intended to create a `Figure`, and +then add *Graphics* to subplots within that `Figure`. + +### Graphics + +A `Graphic` is something that can be added to a `PlotArea` (described in detail in a later section). All the various +fastplotlib graphics, such as `ImageGraphic`, `ScatterGraphic`, etc. inherit from the `Graphic` base class in +`fastplotlib/graphics/_base.py`. It has a few properties that mostly wrap `pygfx` `WorldObject` properties and transforms. +These might change in the future (ex. `Graphic.position_x` etc.). + +All graphics can be given a string name for the user's convenience. This allows graphics to be easily accessed from +plots, ex: `subplot["some_image"]`. + +All graphics contain a `world_object` property which is just the `pygfx.WorldObject` that this graphic uses. Fastplotlib +keeps a *private* global dictionary of all `WorldObject` instances and users are only given a weakref proxy to this world object. +This is due to garbage collection. This may be quite complicated for beginners, for more details see this PR: https://github.com/fastplotlib/fastplotlib/pull/160 . +If you are curious or have more questions on garbage collection in fastplotlib you're welcome to post an issue :D. + +#### Graphic properties + +Graphic properties are all evented, and internally we called these "graphic features". They are the various +aspects of a graphic that the user can change. +The "graphic features" subpackage can be found at `fastplotlib/graphics/_features`. As we can see this +is a private subpackage and never meant to be accessible to users.. + +##### LineGraphic + +For example let's look at `LineGraphic` in `fastplotlib/graphics/line.py`. Every graphic has a class variable called +`_features` which is a set of all graphic properties that are evented. It has the following evented properties: +`"data", "colors", "cmap", "thickness"` in addition to properties common to all graphics, such as `"name", "offset", "rotation", and "visible"` + +Now look at the constructor for the `LineGraphic` base class `PositionsGraphic`, it first creates an instance of `VertexPositions`. +This is a class that manages vertex positions buffer. It defines the line, and provides additional useful functionality. +For example, every time that the `data` is changed, the new data will be marked for upload to the GPU before the next draw. +In addition, event handlers will be called if any event handlers are registered. + +`VertexColors`behaves similarly, but it can perform additional parsing that can create the colors buffer from different +forms of user input. For example if a user runs: `line_graphic.colors = "blue"`, then `VertexColors.__setitem__()` will +create a buffer that corresponds to what `pygfx.Color` thinks is "blue". Users can also take advantage of fancy indexing, +ex: `line_graphics.colors[bool_array] = "red"` :smile: + +`LineGraphic` also has a `VertexCmap`, this manages the line `VertexColors` instance to parse colormaps, for example: +`line_graphic.cmap = "jet"` or even `line_graphic.cmap[50:] = "viridis"`. + +`LineGraphic` also has a `thickness` property which is pretty simple, and `DeletedFeature` which is useful if you need +callbacks to indicate that the graphic has been deleted (for example, removing references to a graphic from a legend). + +Other graphics have properties that are relevant to them, for example `ImageGraphic` has `cmap`, `vmin`, `vmax`, +properties unique to images. + +#### Selectors + +Selectors are a fairly new subpackage at `fastplotlib/graphics/selectors` which is likely to change significantly +after https://github.com/pygfx/pygfx/pull/665 . This subpackage contains selection tools, such as line selectors +(horizontal or vertical lines that can be moved), linear region selectors, and a primitive polygon drawing selection tool. +All selector tools inherit from `BaseSelector` in `graphics/selectors/_base_selector.py` but this is likely to change +after the aforementioned `Input` class PR in `pygfx` and after https://github.com/fastplotlib/fastplotlib/pull/413 . + +### Layouts + +#### PlotArea + +This is the main base class within layouts. Subplots within a `Figure` and `Dock` areas within a `Subplot`, +inherit from `PlotArea`. + +`PlotArea` has the following key properties that allow it to be a "plot area" that can be used to view graphical objects: + +* scene - instance of `pygfx.Scene` +* canvas - instance of `WgpuCanvas` +* renderer - instance of `pygfx.WgpuRenderer` +* viewport - instance of `pygfx.Viewport` +* camera - instance of `pygfx.PerspectiveCamera`, we always just use `PerspectiveCamera` and just set `camera.fov = 0` for orthographic projections +* controller - instance of `pygfx.Controller` + +Abstract method that must be implemented in subclasses: + +* get_rect - musut return [x, y, width, height] that defines the viewport rect for this `PlotArea` + +Properties specifically used by subplots in a Figure: + +* parent - A parent if relevant, used by individual `Subplots` in `Figure`, and by `Dock` which are "docked" subplots at the edges of a subplot. +* position - if a subplot within a Figure, it is the position of this subplot within the `Figure` + +Other important properties: + +* graphics - a tuple of weakref proxies to all `Graphics` within this `PlotArea`, users are only given weakref proxies to `Graphic` objects, all `Graphic` objects are stored in a private global dict. +* selectors - a tuple of weakref proxies to all selectors within this `PlotArea` +* legend - a tuple of weakref proxies to all legend graphics within this `PlotArea` +* name - plot areas are allowed to have names that the user can use for their convenience + +Important methods: + +* add_graphic - add a `Graphic` to the `PlotArea`, append to the end of the `PlotArea._graphics` list +* insert_graphic - insert a `Graphic` to the `PlotArea`, insert to a specific position of the `PlotArea._graphics` list +* remove_graphic - remove a graphic from the `Scene`, **does not delete it** +* delete_graphic - delete a graphic from the `PlotArea`, performs garbage collection +* clear - deletes all graphics from the `PlotArea` +* center_graphic - center camera w.r.t. a `Graphic` +* center_scene - center camera w.r.t. entire `Scene` +* auto_scale - Auto-scale the camera w.r.t to the `Scene` + +In addition, `PlotArea` supports `__getitem__`, so you can do: `plot_area["graphic_name"]` to retrieve a `Graphic` by +name :smile: + +You can also check if a `PlotArea` has certain graphics, ex: `"some_image_name" in plot_area`, or `graphic_instance in plot_area` + +#### Subplot + +This class inherits from `PlotArea` and `GraphicMethodsMixin`. + +`GraphicMethodsMixin` is a simple class that just has all the `add_` methods. It is autogenerated by a utility script like this: + +```bash +python scripts/generate_add_methods.py +``` + +Each `add_` method basically creates an instance of `Graphic`, adds it to the `Subplot`, and returns a weakref +proxy to the `Graphic`. + +Subplot has one property that is not in `PlotArea`: + +* docks: a `dict` of `PlotAreas` which are located at the "top", "right", "left", and "bottom" edges of a `Subplot`. By default their size is `0`. They are useful for putting things like histogram LUT tools. + +The key method in `Subplot` is an implementation of `get_rect` that returns the viewport rect for this subplot. + +#### Figure + +Now that we have understood `PlotArea` and `Subplot` we need a way for the user to create them! + +A `Figure` contains a grid of subplot and has methods such as `show()` to output the figure. +`Figure.__init__` basically does a lot of parsing of user arguments to determine how to create +the subplots. All subplots within a `Figure` share the same canvas and use different viewports to create the subplots. + +## Tests in detail + +Backend tests are in `tests/`, in addition as a plotting library CI pipeline produces things that +"look visually correct". Each example within the `examples` dir is run and an image of the canvas +is taken and compared with a ground-truth screenshot that we have manually inspected. +Ground-truth image are stored using `git-lfs`. + +The ground-truth images are in: + +``` +examples/desktop/screenshots +examples/notebooks/screenshots +``` + +The tests will produce slightly different imperceptible (to a human) results on different hardware when compared to the +ground-truth. A small RMSE tolerance has been chosen, `0.025` for most examples. If the output image and +ground-truth image are within that tolerance the test will pass. + + +To run tests: + +```bash +# tests basic backend functionality +WGPU_FORCE_OFFSCREEN=1 pytest -v -s tests/ + +# desktop examples +pytest -v examples + +# notebook examples +FASTPLOTLIB_NB_TESTS=1 pytest --nbmake examples/notebooks/ +``` + +If your contribution modifies a ground-truth test screenshot then replace the ground-truth image along with your PR and +also notify us of this in the PR. Likewise, if your contribution requires a new test or new ground-truth then include +this new image in your PR. + +You can create/regenerate ground-truths for the examples like this: + +```bash +# desktop examples +REGENERATE_SCREENSHOTS=1 pytest -v examples/ + +# notebook examples +FASTPLOTLIB_NB_TESTS=1 REGENERATE_SCREENSHOTS=1 pytest --nbmake examples/notebooks/image_widget_test.ipynb +``` + +**Please only commit ground-truth images that correspond to your PR** since this will generate ground-truth images for +the entire test suite. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 000000000..27acb1c45 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,120 @@ +# fastplotlib governance + +The governance of fastplotlib applies to all fastplotlib related activities. This includes the fastplotlib GitHub organization, all repositories under the fastplotlib GitHub organization, as well as any events or workshops organized by members of the fastplotlib Leadership Team. + +The purpose of this document is to formalize the governance process used by the fastplotlib project, to clarify how decisions are made and how the various elements of our community interact. + +## Mission + +The mission of `fastplotlib` is to leverage new graphics APIs and modern GPU hardware to create fast and interactive scientific visualizations using an expressive and elegant API. + +`fastplotlib` aims to provide a library that allows for the following: +- Rapid prototyping and algorithm development +- Realtime analysis and visualization +- Efficient rendering of thousands of objects +- Compatibility with lazy-loading and lazy-compute objects +- Shipping dependent packages as a distributable (ex: PyInstaller) + +Ultimately, `fastplotlib` is, and will always be, a free and open-source project that belongs to the community. Our end goal is to aid in the advancement of science, and as a result, we will guide the project in a direction that best serves our community in achieving this purpose. + +## Leadership Team + +### Maintainers + +The maintainers are the core developers of fastplotlib and together have a complete understanding of the codebase. They are also known as code-owners. At any given time, there must be a minimum of two maintainers. + +The current maintainers are: + +1. [Kushal Kolar](https://github.com/kushalkolar) +1. [Caitlin Lewis](https://github.com/clewis7) + +Responsibilities: + +* Carry out the `fastplotlib` mission. +* Work towards completion of the roadmap. +* Timely responses to issues and pull requests. +* Code review. +* Attend a yearly Roadmap meeting. +* Be available for conflict resolution. + +### Advisory Committee + +The Advisory Committee holds a significant interest in fastplotlib as determined solely by the **Maintainers**. + +1. Amol Pasarkar +1. Eric Thomson +1. Andrea Giovannucci +1. John Pearson + +Responsibilities: + +* Help carry out the `fastplotlib` mission. +* Provide strategic guidance. +* Attend a yearly Roadmap meeting. +* Be available for conflict resolution. + +### Neutral moderator + +No voting power, has no stake in the fastplotlib project. + +* Reagan Bullins + +Responsibilities: + +* Facilitate conflict resolution without voting power. + +## Adding a member to the advisory committee +1. Only individuals, not organizations, may be added to the leadership team. A candidate individual must be nominated by a current member of the leadership team. +2. A candidate must: + * Be committed to the fastplotlib mission. + * Have demonstrated contibutions to `fastplotlib` through one of: + * Significant contributions to the codebase. + * Significant application of fastplotlib in a dependent package. + * Significant technical guidance or feedback on the development of `fastplotlib`. + +## Adding a maintainer + +Candidate maintainers must have demonstrated prolonged and significant contributions to the codebase over a long period of time. A candidate can be nominated by any current maintainer. The candidate may then be added as a maintainer through a unanimous vote within the current maintainers. + +## Decision making + +Decisions about the future of the project are made through discussion with all members of the community. All non-sensitive project management discussion takes place on the issue tracker. Occasionally, sensitive discussions may occur on a private core developer medium. + +Decisions should be made in accordance with the mission and code of conduct of the `fastplotlib` project. + +We use a “consensus seeking” process for making decisions. The Leadership Team tries to find a resolution that has no open objections among Leadership Team members. Leadership Team members are expected to distinguish between fundamental objections to a proposal and minor perceived flaws that they can live with, and not hold up the decision-making process for the latter. If no option can be found without objections, the decision is escalated to the maintainers who have ultimate authority. + +Decisions are made according to the following rules: + +Minor documentation changes, such as typo fixes, or addition / correction of a sentence, require approval by a maintainer and no disagreement or requested changes by other maintainers on the issue or pull request page via lazy consensus. Pull-request authors are expected to give “reasonable time” to others to give their opinion on the pull request if they’re not confident others would agree. + +Code changes and major documentation changes require agreement by one maintainer and no disagreement or requested changes by other maintainers on the issue or pull-request page (lazy consensus). For all changes of this type, maintainers are expected to give “reasonable time” after approval and before merging for others to weigh in on the pull request in its final state. + +Changes to the API principles require a dedicated issue on our issue tracker and follow the decision-making process outlined above. + +Changes to this governance model or our mission, vision, and values require a dedicated issue on our issue tracker and follow the decision-making process outlined above. + +If an objection is raised on a lazy consensus, the proposer can appeal to the Leadership Team and the change can be approved or rejected by escalating to the maintainers. + +## Conflict Resolution + +Anyone (absolutely anyone, not just the leadership team members) who feels that the code of conduct or governance document has been breached may invoke a vote by contacting the neutral moderator. + +### Process + +1. Contact the neutral moderator with a description of the conflict, max of 250 words. +2. Neutral moderator must schedule a vote within 15 days. If that is not possible then within the next 45 days. +3. The individual who has invoked the conflict vote can choose to present their case, or they may choose to let the neutral moderator represent them. + * Every individual involved in the conflict is given a maximum of 15 minutes to be represented. This time limit may be expanded at the discretion of the neutral moderator if a justifiable reason is provided. +4. The maintainers vote on one of the actions from “Enforcement Guidelines”: https://www.contributor-covenant.org/version/2/1/code_of_conduct/. It is advised that the first offense leads to action (1) “Correction”. Repeated or serious offenses from the same individual/organization may lead to escalating levels of actions. Very bad behavior, as determined by the leadership team, can justify a first offense resulting in (3) “Temporary Ban” or (4) “Permanent Ban”. +5. The advisory committee members may advise on the actions, but the ultimate decision is voted on by the maintainers. + +## Transparency + +Governance decisions, meeting minutes, and voting outcomes are publicly documented and accessible. We aim for transparency to allow the broader community to understand and trust the governance process. + +## Changes to this governance document + +### Until February 28, 2025 + +During early stages of fastplotlib development, changes to the governance document may be made directly through unanimous approval by the original maintainers, Kushal Kolar & Caitlin Lewis. They (Kushal & Caitlin) may also add new members to the advisory committee through unanimous approval. diff --git a/MANIFEST.in b/MANIFEST.in index 121ea2fd0..b8debd28d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ recursive-include fastplotlib/utils/colormaps/ * include fastplotlib/VERSION +recursive-include fastplotlib/assets/ * + diff --git a/README.md b/README.md index eb64f3cc3..9f3f9b236 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ [![CI](https://github.com/kushalkolar/fastplotlib/actions/workflows/ci.yml/badge.svg)](https://github.com/kushalkolar/fastplotlib/actions/workflows/ci.yml) [![PyPI version](https://badge.fury.io/py/fastplotlib.svg)](https://badge.fury.io/py/fastplotlib) [![Documentation Status](https://readthedocs.org/projects/fastplotlib/badge/?version=latest)](https://fastplotlib.readthedocs.io/en/latest/?badge=latest) -[![Gitter](https://badges.gitter.im/fastplotlib/community.svg)](https://gitter.im/fastplotlib/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [**Installation**](https://github.com/kushalkolar/fastplotlib#installation) | [**GPU Drivers**](https://github.com/kushalkolar/fastplotlib#graphics-drivers) | @@ -15,7 +14,7 @@ [**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) 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. +Next-gen 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! `fastplotlib` is an expressive plotting library that enables rapid prototyping for large scale explorative scientific visualization. ![scipy-fpl](https://github.com/fastplotlib/fastplotlib/assets/9403332/b981a54c-05f9-443f-a8e4-52cd01cd802a) @@ -23,8 +22,8 @@ A fast plotting library built using the [`pygfx`](https://github.com/pygfx/pygfx [![fpl_thumbnail](http://i3.ytimg.com/vi/Q-UJpAqljsU/hqdefault.jpg)](https://www.youtube.com/watch?v=Q-UJpAqljsU) -Notebooks from talk: https://github.com/fastplotlib/fastplotlib-scipy2023 - +Note that the API is currently evolving quickly. We recommend using the latest notebooks from the repo but the general +concepts are similar to those from the API shown in the video. # Supported frameworks @@ -36,41 +35,37 @@ Notebooks from talk: https://github.com/fastplotlib/fastplotlib-scipy2023 :heavy_check_mark: `wxPython` **Notes:**\ -:heavy_check_mark: Non-blocking Qt output is supported in ipython and notebooks by using [`%gui qt`](https://ipython.readthedocs.io/en/stable/interactive/magics.html#magic-gui) before creating plots. This hook only supports pyqt6 at the moment.\ +:heavy_check_mark: Non-blocking Qt/PySide output is supported in ipython and notebooks by using [`%gui qt`](https://ipython.readthedocs.io/en/stable/interactive/magics.html#magic-gui). This **must** be called *before* importing `fastplotlib`! :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 yet, see https://github.com/vispy/jupyter_rfb/pull/77 +:disappointed: [`jupyter_rfb`](https://github.com/vispy/jupyter_rfb) does not work in collab, see https://github.com/vispy/jupyter_rfb/pull/77 > **Note** > -> `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 +> `fastplotlib` is currently in the **late alpha stage**, 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/main/examples - -If someone wants to integrate `pyodide` with `pygfx` we would be able to have live interactive examples! :smiley: +The examples are interactive if you run them locally on your computer. If someone wants to integrate `pyodide` with `pygfx` we would be able to have live interactive examples on the website! -Questions, issues, ideas? Post an [issue](https://github.com/fastplotlib/fastplotlib/issues) or post on the [discussion forum](https://github.com/fastplotlib/fastplotlib/discussions)! +Questions, issues, ideas? You are welcome to post an [issue](https://github.com/fastplotlib/fastplotlib/issues) or post on the [discussion forum](https://github.com/fastplotlib/fastplotlib/discussions)! :smiley: # Installation -Install using `pip`. - ### Minimal, use with your own `Qt` or `glfw` applications ```bash pip install fastplotlib ``` -**This does not give you `Qt` or `glfw`, you will have to install one of them yourself depending on your preference**. +**This does not give you `PyQt`/`PySide` or `glfw`, you will have to install your preferred GUI framework separately**. ### Notebook ```bash pip install "fastplotlib[notebook]" ``` -**Optional: install `simplejpeg` for much faster notebook visualization, you will need C compilers and [libjpeg-turbo](https://libjpeg-turbo.org/) to install it:** +**Strongly recommended: install `simplejpeg` for much faster notebook visualization, this requires you to first install [libjpeg-turbo](https://libjpeg-turbo.org/)** ```bash pip install simplejpeg @@ -78,71 +73,70 @@ pip install simplejpeg > **Note** > -> `fastplotlib` and `pygfx` are fast evolving projects, the version available through pip might be outdated, you will need to follow the "For developers" instructions below if you want the latest features. You can find the release history on pypi here: https://pypi.org/project/fastplotlib/#history +> `fastplotlib` and `pygfx` are fast evolving projects, the version available through pip might be outdated, you will need to follow the "For developers" instructions below if you want the latest features. You can find the release history here: https://github.com/fastplotlib/fastplotlib/releases ### For developers + +Make sure you have [git-lfs](https://github.com/git-lfs/git-lfs#installing) installed. + ```bash -git clone https://github.com/kushalkolar/fastplotlib.git +git clone https://github.com/fastplotlib/fastplotlib.git cd fastplotlib # install all extras in place pip install -e ".[notebook,docs,tests]" + +# install latest pygfx +pip install git+https://github.com/pygfx/pygfx.git@main ``` +Se [Contributing](https://github.com/fastplotlib/fastplotlib?tab=readme-ov-file#heart-contributing) for more details on development + # Examples -> **Note** -> -> `fastplotlib` and `pygfx` are fast evolving, you may require the latest `pygfx` and `fastplotlib` from github to use the examples in the main branch. +Examples gallery: https://fastplotlib.readthedocs.io/en/latest/_gallery/index.html -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 😄 +> **Note:** `fastplotlib` and `pygfx` are fast evolving, you will probably require the latest `pygfx` and `fastplotlib` from github to use the examples in the main branch. -### Desktop examples using `glfw` or `Qt` +`fastplotlib` code is identical across notebook (`jupyter`), and desktop use with `Qt`/`PySide` or `glfw`. -GLFW examples are here. GLFW is a "minimal" desktop framework. +Even if you do not intend to use notebooks with `fastplotlib`, the `quickstart.ipynb` tutorial notebook is the best way to get familiar with the API: https://github.com/fastplotlib/fastplotlib/tree/main/examples/notebooks/quickstart.ipynb -https://github.com/fastplotlib/fastplotlib/tree/main/examples/desktop +The specifics for running `fastplotlib` in different GUI frameworks are: +- Running in `glfw` requires a `fastplotlib.run()` call (which is really just a `wgpu` `run()` call) +- With `Qt` you can encapsulate it within a `QApplication`, see `examples/qt` +- Notebooks plots have ipywidget-based toolbars and widgets. There are plans to move toward an identical in-canvas toolbar with UI elements across all supported frameworks 😄 -Qt examples are here: +### Embedding in a `Qt` app -https://github.com/fastplotlib/fastplotlib/tree/main/examples/qt +See these for examples on embedding within a Qt app. Note that you can also use `fastplotlib` with qt interactively using `%gui qt` in jupyter or ipython. -Some of the examples require imageio: -``` -pip install imageio -``` +https://github.com/fastplotlib/fastplotlib/tree/main/examples/qt ### Notebook examples -Notebook examples are here: +Notebook examples are here, these include examples on selector tools. https://github.com/fastplotlib/fastplotlib/tree/main/examples/notebooks -**Start with `simple.ipynb`.** - -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 +Our SciPy 2023 talk walks through numerous demos: https://github.com/fastplotlib/fastplotlib#scipy-talk ## Graphics drivers You will need a relatively modern GPU (newer integrated GPUs in CPUs are usually fine). Generally if your GPU is from 2017 or later it should be fine. -For more information see: https://wgpu-py.readthedocs.io/en/stable/start.html#platform-requirements +For more detailed information, such as use on cloud computing infrastructure, see: https://wgpu-py.readthedocs.io/en/stable/start.html#platform-requirements + +Some more information on GPUs is here: https://fastplotlib.readthedocs.io/en/latest/user_guide/gpu.html ### Windows: Vulkan drivers should be installed by default on Windows 11, but you will need to install your GPU manufacturer's driver package (Nvidia or AMD). If you have an integrated GPU within your CPU, you might still need to install a driver package too, check your CPU manufacturer's info. -We also recommend installing C compilers so that you can install `simplejpeg` which improves remote frame buffer performance in notebooks. - ### Linux: +You will generally need a linux distro that is from ~2020 or newer (ex. Ubuntu 18.04 won't work), this is due to the `glibc` requirements of the `wgpu-native` binary. + Debian based distros: ```bash @@ -162,10 +156,10 @@ sudo apt install llvm-dev libturbojpeg* libgl1-mesa-dev libgl1-mesa-glx libglapi ``` ### Mac OSX: -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! +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/main/CONTRIBUTING.md -You can also take a look at our [**Roadmap for 2024**](https://github.com/kushalkolar/fastplotlib/issues/55) and [**Issues**](https://github.com/kushalkolar/fastplotlib/issues) for ideas on how to contribute! +You can also take a look at our [**Roadmap for 2025**](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/api/gpu.rst b/docs/source/api/gpu.rst new file mode 100644 index 000000000..6f94aff23 --- /dev/null +++ b/docs/source/api/gpu.rst @@ -0,0 +1,6 @@ +fastplotlib.utils.gpu +********************* + +.. currentmodule:: fastplotlib.utils.gpu +.. automodule:: fastplotlib + :members: diff --git a/docs/source/api/graphic_features/CmapFeature.rst b/docs/source/api/graphic_features/CmapFeature.rst deleted file mode 100644 index 7cc2f681f..000000000 --- a/docs/source/api/graphic_features/CmapFeature.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _api.CmapFeature: - -CmapFeature -*********** - -=========== -CmapFeature -=========== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: CmapFeature_api - - CmapFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: CmapFeature_api - - CmapFeature.buffer - CmapFeature.name - CmapFeature.values - -Methods -~~~~~~~ -.. autosummary:: - :toctree: CmapFeature_api - - CmapFeature.add_event_handler - CmapFeature.block_events - CmapFeature.clear_event_handlers - CmapFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/ColorFeature.rst b/docs/source/api/graphic_features/ColorFeature.rst deleted file mode 100644 index 3ed84cd70..000000000 --- a/docs/source/api/graphic_features/ColorFeature.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _api.ColorFeature: - -ColorFeature -************ - -============ -ColorFeature -============ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ColorFeature_api - - ColorFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ColorFeature_api - - ColorFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ColorFeature_api - - ColorFeature.add_event_handler - ColorFeature.block_events - ColorFeature.clear_event_handlers - ColorFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/Deleted.rst b/docs/source/api/graphic_features/Deleted.rst new file mode 100644 index 000000000..09131c4a7 --- /dev/null +++ b/docs/source/api/graphic_features/Deleted.rst @@ -0,0 +1,35 @@ +.. _api.Deleted: + +Deleted +******* + +======= +Deleted +======= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Deleted_api + + Deleted + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Deleted_api + + Deleted.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Deleted_api + + Deleted.add_event_handler + Deleted.block_events + Deleted.clear_event_handlers + Deleted.remove_event_handler + Deleted.set_value + diff --git a/docs/source/api/graphic_features/FeatureEvent.rst b/docs/source/api/graphic_features/FeatureEvent.rst deleted file mode 100644 index f22ee3ef4..000000000 --- a/docs/source/api/graphic_features/FeatureEvent.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. _api.FeatureEvent: - -FeatureEvent -************ - -============ -FeatureEvent -============ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: FeatureEvent_api - - FeatureEvent - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: FeatureEvent_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: FeatureEvent_api - - diff --git a/docs/source/api/graphic_features/FontSize.rst b/docs/source/api/graphic_features/FontSize.rst new file mode 100644 index 000000000..4b8df9826 --- /dev/null +++ b/docs/source/api/graphic_features/FontSize.rst @@ -0,0 +1,35 @@ +.. _api.FontSize: + +FontSize +******** + +======== +FontSize +======== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: FontSize_api + + FontSize + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: FontSize_api + + FontSize.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: FontSize_api + + FontSize.add_event_handler + FontSize.block_events + FontSize.clear_event_handlers + FontSize.remove_event_handler + FontSize.set_value + diff --git a/docs/source/api/graphic_features/GraphicFeature.rst b/docs/source/api/graphic_features/GraphicFeature.rst deleted file mode 100644 index 7abc3e6b2..000000000 --- a/docs/source/api/graphic_features/GraphicFeature.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.GraphicFeature: - -GraphicFeature -************** - -============== -GraphicFeature -============== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeature_api - - GraphicFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeature_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: GraphicFeature_api - - GraphicFeature.add_event_handler - GraphicFeature.block_events - GraphicFeature.clear_event_handlers - GraphicFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/GraphicFeatureIndexable.rst b/docs/source/api/graphic_features/GraphicFeatureIndexable.rst deleted file mode 100644 index 7bd1383bc..000000000 --- a/docs/source/api/graphic_features/GraphicFeatureIndexable.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _api.GraphicFeatureIndexable: - -GraphicFeatureIndexable -*********************** - -======================= -GraphicFeatureIndexable -======================= -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeatureIndexable_api - - GraphicFeatureIndexable - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeatureIndexable_api - - GraphicFeatureIndexable.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: GraphicFeatureIndexable_api - - GraphicFeatureIndexable.add_event_handler - GraphicFeatureIndexable.block_events - GraphicFeatureIndexable.clear_event_handlers - GraphicFeatureIndexable.remove_event_handler - diff --git a/docs/source/api/graphic_features/HeatmapCmapFeature.rst b/docs/source/api/graphic_features/HeatmapCmapFeature.rst deleted file mode 100644 index bac43c9b9..000000000 --- a/docs/source/api/graphic_features/HeatmapCmapFeature.rst +++ /dev/null @@ -1,37 +0,0 @@ -.. _api.HeatmapCmapFeature: - -HeatmapCmapFeature -****************** - -================== -HeatmapCmapFeature -================== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapCmapFeature_api - - HeatmapCmapFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapCmapFeature_api - - HeatmapCmapFeature.name - HeatmapCmapFeature.vmax - HeatmapCmapFeature.vmin - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HeatmapCmapFeature_api - - HeatmapCmapFeature.add_event_handler - HeatmapCmapFeature.block_events - HeatmapCmapFeature.clear_event_handlers - HeatmapCmapFeature.remove_event_handler - HeatmapCmapFeature.reset_vmin_vmax - diff --git a/docs/source/api/graphic_features/HeatmapDataFeature.rst b/docs/source/api/graphic_features/HeatmapDataFeature.rst deleted file mode 100644 index 029f0e199..000000000 --- a/docs/source/api/graphic_features/HeatmapDataFeature.rst +++ /dev/null @@ -1,35 +0,0 @@ -.. _api.HeatmapDataFeature: - -HeatmapDataFeature -****************** - -================== -HeatmapDataFeature -================== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapDataFeature_api - - HeatmapDataFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapDataFeature_api - - HeatmapDataFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HeatmapDataFeature_api - - HeatmapDataFeature.add_event_handler - HeatmapDataFeature.block_events - HeatmapDataFeature.clear_event_handlers - HeatmapDataFeature.remove_event_handler - HeatmapDataFeature.update_gpu - diff --git a/docs/source/api/graphic_features/ImageCmap.rst b/docs/source/api/graphic_features/ImageCmap.rst new file mode 100644 index 000000000..23d16a4a2 --- /dev/null +++ b/docs/source/api/graphic_features/ImageCmap.rst @@ -0,0 +1,35 @@ +.. _api.ImageCmap: + +ImageCmap +********* + +========= +ImageCmap +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmap_api + + ImageCmap + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmap_api + + ImageCmap.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageCmap_api + + ImageCmap.add_event_handler + ImageCmap.block_events + ImageCmap.clear_event_handlers + ImageCmap.remove_event_handler + ImageCmap.set_value + diff --git a/docs/source/api/graphic_features/ImageCmapFeature.rst b/docs/source/api/graphic_features/ImageCmapFeature.rst deleted file mode 100644 index ae65744c7..000000000 --- a/docs/source/api/graphic_features/ImageCmapFeature.rst +++ /dev/null @@ -1,37 +0,0 @@ -.. _api.ImageCmapFeature: - -ImageCmapFeature -**************** - -================ -ImageCmapFeature -================ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageCmapFeature_api - - ImageCmapFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageCmapFeature_api - - ImageCmapFeature.name - ImageCmapFeature.vmax - ImageCmapFeature.vmin - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageCmapFeature_api - - ImageCmapFeature.add_event_handler - ImageCmapFeature.block_events - ImageCmapFeature.clear_event_handlers - ImageCmapFeature.remove_event_handler - ImageCmapFeature.reset_vmin_vmax - diff --git a/docs/source/api/graphic_features/ImageCmapInterpolation.rst b/docs/source/api/graphic_features/ImageCmapInterpolation.rst new file mode 100644 index 000000000..7e04ec788 --- /dev/null +++ b/docs/source/api/graphic_features/ImageCmapInterpolation.rst @@ -0,0 +1,35 @@ +.. _api.ImageCmapInterpolation: + +ImageCmapInterpolation +********************** + +====================== +ImageCmapInterpolation +====================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmapInterpolation_api + + ImageCmapInterpolation + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmapInterpolation_api + + ImageCmapInterpolation.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageCmapInterpolation_api + + ImageCmapInterpolation.add_event_handler + ImageCmapInterpolation.block_events + ImageCmapInterpolation.clear_event_handlers + ImageCmapInterpolation.remove_event_handler + ImageCmapInterpolation.set_value + diff --git a/docs/source/api/graphic_features/ImageDataFeature.rst b/docs/source/api/graphic_features/ImageDataFeature.rst deleted file mode 100644 index 35fe74cf7..000000000 --- a/docs/source/api/graphic_features/ImageDataFeature.rst +++ /dev/null @@ -1,35 +0,0 @@ -.. _api.ImageDataFeature: - -ImageDataFeature -**************** - -================ -ImageDataFeature -================ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageDataFeature_api - - ImageDataFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageDataFeature_api - - ImageDataFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageDataFeature_api - - ImageDataFeature.add_event_handler - ImageDataFeature.block_events - ImageDataFeature.clear_event_handlers - ImageDataFeature.remove_event_handler - ImageDataFeature.update_gpu - diff --git a/docs/source/api/graphic_features/ImageInterpolation.rst b/docs/source/api/graphic_features/ImageInterpolation.rst new file mode 100644 index 000000000..866e76333 --- /dev/null +++ b/docs/source/api/graphic_features/ImageInterpolation.rst @@ -0,0 +1,35 @@ +.. _api.ImageInterpolation: + +ImageInterpolation +****************** + +================== +ImageInterpolation +================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageInterpolation_api + + ImageInterpolation + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageInterpolation_api + + ImageInterpolation.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageInterpolation_api + + ImageInterpolation.add_event_handler + ImageInterpolation.block_events + ImageInterpolation.clear_event_handlers + ImageInterpolation.remove_event_handler + ImageInterpolation.set_value + diff --git a/docs/source/api/graphic_features/ImageVmax.rst b/docs/source/api/graphic_features/ImageVmax.rst new file mode 100644 index 000000000..b7dfe7e2d --- /dev/null +++ b/docs/source/api/graphic_features/ImageVmax.rst @@ -0,0 +1,35 @@ +.. _api.ImageVmax: + +ImageVmax +********* + +========= +ImageVmax +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmax_api + + ImageVmax + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmax_api + + ImageVmax.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVmax_api + + ImageVmax.add_event_handler + ImageVmax.block_events + ImageVmax.clear_event_handlers + ImageVmax.remove_event_handler + ImageVmax.set_value + diff --git a/docs/source/api/graphic_features/ImageVmin.rst b/docs/source/api/graphic_features/ImageVmin.rst new file mode 100644 index 000000000..0d4634894 --- /dev/null +++ b/docs/source/api/graphic_features/ImageVmin.rst @@ -0,0 +1,35 @@ +.. _api.ImageVmin: + +ImageVmin +********* + +========= +ImageVmin +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmin_api + + ImageVmin + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmin_api + + ImageVmin.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVmin_api + + ImageVmin.add_event_handler + ImageVmin.block_events + ImageVmin.clear_event_handlers + ImageVmin.remove_event_handler + ImageVmin.set_value + diff --git a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst index a15825530..b8958c86b 100644 --- a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst +++ b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst @@ -21,6 +21,7 @@ Properties :toctree: LinearRegionSelectionFeature_api LinearRegionSelectionFeature.axis + LinearRegionSelectionFeature.value Methods ~~~~~~~ @@ -31,4 +32,5 @@ Methods LinearRegionSelectionFeature.block_events LinearRegionSelectionFeature.clear_event_handlers LinearRegionSelectionFeature.remove_event_handler + LinearRegionSelectionFeature.set_value diff --git a/docs/source/api/graphic_features/LinearSelectionFeature.rst b/docs/source/api/graphic_features/LinearSelectionFeature.rst index aeb1ca66b..ad7b8645a 100644 --- a/docs/source/api/graphic_features/LinearSelectionFeature.rst +++ b/docs/source/api/graphic_features/LinearSelectionFeature.rst @@ -20,6 +20,7 @@ Properties .. autosummary:: :toctree: LinearSelectionFeature_api + LinearSelectionFeature.value Methods ~~~~~~~ @@ -30,4 +31,5 @@ Methods LinearSelectionFeature.block_events LinearSelectionFeature.clear_event_handlers LinearSelectionFeature.remove_event_handler + LinearSelectionFeature.set_value diff --git a/docs/source/api/graphic_features/Name.rst b/docs/source/api/graphic_features/Name.rst new file mode 100644 index 000000000..288fcfc22 --- /dev/null +++ b/docs/source/api/graphic_features/Name.rst @@ -0,0 +1,35 @@ +.. _api.Name: + +Name +**** + +==== +Name +==== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Name_api + + Name + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Name_api + + Name.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Name_api + + Name.add_event_handler + Name.block_events + Name.clear_event_handlers + Name.remove_event_handler + Name.set_value + diff --git a/docs/source/api/graphic_features/Offset.rst b/docs/source/api/graphic_features/Offset.rst new file mode 100644 index 000000000..683aaf763 --- /dev/null +++ b/docs/source/api/graphic_features/Offset.rst @@ -0,0 +1,35 @@ +.. _api.Offset: + +Offset +****** + +====== +Offset +====== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Offset_api + + Offset + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Offset_api + + Offset.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Offset_api + + Offset.add_event_handler + Offset.block_events + Offset.clear_event_handlers + Offset.remove_event_handler + Offset.set_value + diff --git a/docs/source/api/graphic_features/PointsDataFeature.rst b/docs/source/api/graphic_features/PointsDataFeature.rst deleted file mode 100644 index 078b1c535..000000000 --- a/docs/source/api/graphic_features/PointsDataFeature.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _api.PointsDataFeature: - -PointsDataFeature -***************** - -================= -PointsDataFeature -================= -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PointsDataFeature_api - - PointsDataFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PointsDataFeature_api - - PointsDataFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PointsDataFeature_api - - PointsDataFeature.add_event_handler - PointsDataFeature.block_events - PointsDataFeature.clear_event_handlers - PointsDataFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst index 7915cb09d..3dcc4eeb2 100644 --- a/docs/source/api/graphic_features/PointsSizesFeature.rst +++ b/docs/source/api/graphic_features/PointsSizesFeature.rst @@ -21,6 +21,8 @@ Properties :toctree: PointsSizesFeature_api PointsSizesFeature.buffer + PointsSizesFeature.shared + PointsSizesFeature.value Methods ~~~~~~~ @@ -31,4 +33,5 @@ Methods PointsSizesFeature.block_events PointsSizesFeature.clear_event_handlers PointsSizesFeature.remove_event_handler + PointsSizesFeature.set_value diff --git a/docs/source/api/graphic_features/PresentFeature.rst b/docs/source/api/graphic_features/PresentFeature.rst deleted file mode 100644 index 1ddbf1ec4..000000000 --- a/docs/source/api/graphic_features/PresentFeature.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.PresentFeature: - -PresentFeature -************** - -============== -PresentFeature -============== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PresentFeature_api - - PresentFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PresentFeature_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PresentFeature_api - - PresentFeature.add_event_handler - PresentFeature.block_events - PresentFeature.clear_event_handlers - PresentFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/Rotation.rst b/docs/source/api/graphic_features/Rotation.rst new file mode 100644 index 000000000..f8963b0fd --- /dev/null +++ b/docs/source/api/graphic_features/Rotation.rst @@ -0,0 +1,35 @@ +.. _api.Rotation: + +Rotation +******** + +======== +Rotation +======== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Rotation_api + + Rotation + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Rotation_api + + Rotation.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Rotation_api + + Rotation.add_event_handler + Rotation.block_events + Rotation.clear_event_handlers + Rotation.remove_event_handler + Rotation.set_value + diff --git a/docs/source/api/graphic_features/TextData.rst b/docs/source/api/graphic_features/TextData.rst new file mode 100644 index 000000000..1c27b6e48 --- /dev/null +++ b/docs/source/api/graphic_features/TextData.rst @@ -0,0 +1,35 @@ +.. _api.TextData: + +TextData +******** + +======== +TextData +======== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextData_api + + TextData + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextData_api + + TextData.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextData_api + + TextData.add_event_handler + TextData.block_events + TextData.clear_event_handlers + TextData.remove_event_handler + TextData.set_value + diff --git a/docs/source/api/graphic_features/TextFaceColor.rst b/docs/source/api/graphic_features/TextFaceColor.rst new file mode 100644 index 000000000..5dae54192 --- /dev/null +++ b/docs/source/api/graphic_features/TextFaceColor.rst @@ -0,0 +1,35 @@ +.. _api.TextFaceColor: + +TextFaceColor +************* + +============= +TextFaceColor +============= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextFaceColor_api + + TextFaceColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextFaceColor_api + + TextFaceColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextFaceColor_api + + TextFaceColor.add_event_handler + TextFaceColor.block_events + TextFaceColor.clear_event_handlers + TextFaceColor.remove_event_handler + TextFaceColor.set_value + diff --git a/docs/source/api/graphic_features/TextOutlineColor.rst b/docs/source/api/graphic_features/TextOutlineColor.rst new file mode 100644 index 000000000..f7831b0df --- /dev/null +++ b/docs/source/api/graphic_features/TextOutlineColor.rst @@ -0,0 +1,35 @@ +.. _api.TextOutlineColor: + +TextOutlineColor +**************** + +================ +TextOutlineColor +================ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineColor_api + + TextOutlineColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineColor_api + + TextOutlineColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextOutlineColor_api + + TextOutlineColor.add_event_handler + TextOutlineColor.block_events + TextOutlineColor.clear_event_handlers + TextOutlineColor.remove_event_handler + TextOutlineColor.set_value + diff --git a/docs/source/api/graphic_features/TextOutlineThickness.rst b/docs/source/api/graphic_features/TextOutlineThickness.rst new file mode 100644 index 000000000..75d485781 --- /dev/null +++ b/docs/source/api/graphic_features/TextOutlineThickness.rst @@ -0,0 +1,35 @@ +.. _api.TextOutlineThickness: + +TextOutlineThickness +******************** + +==================== +TextOutlineThickness +==================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineThickness_api + + TextOutlineThickness + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineThickness_api + + TextOutlineThickness.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextOutlineThickness_api + + TextOutlineThickness.add_event_handler + TextOutlineThickness.block_events + TextOutlineThickness.clear_event_handlers + TextOutlineThickness.remove_event_handler + TextOutlineThickness.set_value + diff --git a/docs/source/api/graphic_features/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst new file mode 100644 index 000000000..79707c453 --- /dev/null +++ b/docs/source/api/graphic_features/TextureArray.rst @@ -0,0 +1,39 @@ +.. _api.TextureArray: + +TextureArray +************ + +============ +TextureArray +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextureArray_api + + TextureArray + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextureArray_api + + TextureArray.buffer + TextureArray.col_indices + TextureArray.row_indices + TextureArray.shared + TextureArray.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextureArray_api + + TextureArray.add_event_handler + TextureArray.block_events + TextureArray.clear_event_handlers + TextureArray.remove_event_handler + TextureArray.set_value + diff --git a/docs/source/api/graphic_features/Thickness.rst b/docs/source/api/graphic_features/Thickness.rst new file mode 100644 index 000000000..061f96fe8 --- /dev/null +++ b/docs/source/api/graphic_features/Thickness.rst @@ -0,0 +1,35 @@ +.. _api.Thickness: + +Thickness +********* + +========= +Thickness +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Thickness_api + + Thickness + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Thickness_api + + Thickness.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Thickness_api + + Thickness.add_event_handler + Thickness.block_events + Thickness.clear_event_handlers + Thickness.remove_event_handler + Thickness.set_value + diff --git a/docs/source/api/graphic_features/ThicknessFeature.rst b/docs/source/api/graphic_features/ThicknessFeature.rst deleted file mode 100644 index 80219a2cd..000000000 --- a/docs/source/api/graphic_features/ThicknessFeature.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.ThicknessFeature: - -ThicknessFeature -**************** - -================ -ThicknessFeature -================ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ThicknessFeature_api - - ThicknessFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ThicknessFeature_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ThicknessFeature_api - - ThicknessFeature.add_event_handler - ThicknessFeature.block_events - ThicknessFeature.clear_event_handlers - ThicknessFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/UniformColor.rst b/docs/source/api/graphic_features/UniformColor.rst new file mode 100644 index 000000000..7370589b7 --- /dev/null +++ b/docs/source/api/graphic_features/UniformColor.rst @@ -0,0 +1,35 @@ +.. _api.UniformColor: + +UniformColor +************ + +============ +UniformColor +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformColor_api + + UniformColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformColor_api + + UniformColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformColor_api + + UniformColor.add_event_handler + UniformColor.block_events + UniformColor.clear_event_handlers + UniformColor.remove_event_handler + UniformColor.set_value + diff --git a/docs/source/api/graphic_features/UniformSize.rst b/docs/source/api/graphic_features/UniformSize.rst new file mode 100644 index 000000000..e342d6a70 --- /dev/null +++ b/docs/source/api/graphic_features/UniformSize.rst @@ -0,0 +1,35 @@ +.. _api.UniformSize: + +UniformSize +*********** + +=========== +UniformSize +=========== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformSize_api + + UniformSize + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformSize_api + + UniformSize.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformSize_api + + UniformSize.add_event_handler + UniformSize.block_events + UniformSize.clear_event_handlers + UniformSize.remove_event_handler + UniformSize.set_value + diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst new file mode 100644 index 000000000..a3311d6e6 --- /dev/null +++ b/docs/source/api/graphic_features/VertexCmap.rst @@ -0,0 +1,40 @@ +.. _api.VertexCmap: + +VertexCmap +********** + +========== +VertexCmap +========== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmap_api + + VertexCmap + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmap_api + + VertexCmap.alpha + VertexCmap.buffer + VertexCmap.name + VertexCmap.shared + VertexCmap.transform + VertexCmap.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexCmap_api + + VertexCmap.add_event_handler + VertexCmap.block_events + VertexCmap.clear_event_handlers + VertexCmap.remove_event_handler + VertexCmap.set_value + diff --git a/docs/source/api/graphic_features/VertexColors.rst b/docs/source/api/graphic_features/VertexColors.rst new file mode 100644 index 000000000..3c2089a78 --- /dev/null +++ b/docs/source/api/graphic_features/VertexColors.rst @@ -0,0 +1,37 @@ +.. _api.VertexColors: + +VertexColors +************ + +============ +VertexColors +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexColors_api + + VertexColors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexColors_api + + VertexColors.buffer + VertexColors.shared + VertexColors.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexColors_api + + VertexColors.add_event_handler + VertexColors.block_events + VertexColors.clear_event_handlers + VertexColors.remove_event_handler + VertexColors.set_value + diff --git a/docs/source/api/graphic_features/VertexPositions.rst b/docs/source/api/graphic_features/VertexPositions.rst new file mode 100644 index 000000000..9669ab6d5 --- /dev/null +++ b/docs/source/api/graphic_features/VertexPositions.rst @@ -0,0 +1,37 @@ +.. _api.VertexPositions: + +VertexPositions +*************** + +=============== +VertexPositions +=============== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPositions_api + + VertexPositions + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPositions_api + + VertexPositions.buffer + VertexPositions.shared + VertexPositions.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexPositions_api + + VertexPositions.add_event_handler + VertexPositions.block_events + VertexPositions.clear_event_handlers + VertexPositions.remove_event_handler + VertexPositions.set_value + diff --git a/docs/source/api/graphic_features/Visible.rst b/docs/source/api/graphic_features/Visible.rst new file mode 100644 index 000000000..957b4433a --- /dev/null +++ b/docs/source/api/graphic_features/Visible.rst @@ -0,0 +1,35 @@ +.. _api.Visible: + +Visible +******* + +======= +Visible +======= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Visible_api + + Visible + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Visible_api + + Visible.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Visible_api + + Visible.add_event_handler + Visible.block_events + Visible.clear_event_handlers + Visible.remove_event_handler + Visible.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 1c4b33392..87504ea8a 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -4,19 +4,28 @@ Graphic Features .. toctree:: :maxdepth: 1 - ColorFeature - CmapFeature - ImageCmapFeature - HeatmapCmapFeature - PointsDataFeature + VertexColors + UniformColor + UniformSize + Thickness + VertexPositions PointsSizesFeature - ImageDataFeature - HeatmapDataFeature - PresentFeature - ThicknessFeature - GraphicFeature - GraphicFeatureIndexable - FeatureEvent - to_gpu_supported_dtype + VertexCmap + TextureArray + ImageCmap + ImageVmin + ImageVmax + ImageInterpolation + ImageCmapInterpolation + TextData + FontSize + TextFaceColor + TextOutlineColor + TextOutlineThickness LinearSelectionFeature LinearRegionSelectionFeature + Name + Offset + Rotation + Visible + Deleted diff --git a/docs/source/api/graphic_features/to_gpu_supported_dtype.rst b/docs/source/api/graphic_features/to_gpu_supported_dtype.rst deleted file mode 100644 index 984a76157..000000000 --- a/docs/source/api/graphic_features/to_gpu_supported_dtype.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. _api.to_gpu_supported_dtype: - -to_gpu_supported_dtype -********************** - -====================== -to_gpu_supported_dtype -====================== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: to_gpu_supported_dtype_api - - to_gpu_supported_dtype - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: to_gpu_supported_dtype_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: to_gpu_supported_dtype_api - - diff --git a/docs/source/api/graphics/HeatmapGraphic.rst b/docs/source/api/graphics/HeatmapGraphic.rst deleted file mode 100644 index 3bd2f2baa..000000000 --- a/docs/source/api/graphics/HeatmapGraphic.rst +++ /dev/null @@ -1,41 +0,0 @@ -.. _api.HeatmapGraphic: - -HeatmapGraphic -************** - -============== -HeatmapGraphic -============== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapGraphic_api - - HeatmapGraphic - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapGraphic_api - - HeatmapGraphic.children - HeatmapGraphic.position - HeatmapGraphic.position_x - HeatmapGraphic.position_y - HeatmapGraphic.position_z - HeatmapGraphic.visible - HeatmapGraphic.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HeatmapGraphic_api - - 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 871462701..a0ae8a5ed 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -20,12 +20,20 @@ Properties .. autosummary:: :toctree: ImageGraphic_api - ImageGraphic.children - ImageGraphic.position - ImageGraphic.position_x - ImageGraphic.position_y - ImageGraphic.position_z + ImageGraphic.block_events + ImageGraphic.cmap + ImageGraphic.cmap_interpolation + ImageGraphic.data + ImageGraphic.deleted + ImageGraphic.event_handlers + ImageGraphic.interpolation + ImageGraphic.name + ImageGraphic.offset + ImageGraphic.rotation + ImageGraphic.supported_events ImageGraphic.visible + ImageGraphic.vmax + ImageGraphic.vmin ImageGraphic.world_object Methods @@ -33,9 +41,13 @@ Methods .. autosummary:: :toctree: ImageGraphic_api + ImageGraphic.add_event_handler ImageGraphic.add_linear_region_selector ImageGraphic.add_linear_selector - ImageGraphic.link - ImageGraphic.reset_feature - ImageGraphic.set_feature + ImageGraphic.clear_event_handlers + ImageGraphic.remove_event_handler + ImageGraphic.reset_vmin_vmax + ImageGraphic.rotate + ImageGraphic.share_property + ImageGraphic.unshare_property diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index 3f67feed9..c000b7334 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -20,15 +20,24 @@ Properties .. autosummary:: :toctree: LineCollection_api - LineCollection.children + LineCollection.block_events LineCollection.cmap - LineCollection.cmap_values + LineCollection.colors + LineCollection.data + LineCollection.deleted + LineCollection.event_handlers LineCollection.graphics - LineCollection.position - LineCollection.position_x - LineCollection.position_y - LineCollection.position_z + LineCollection.metadatas + LineCollection.name + LineCollection.names + LineCollection.offset + LineCollection.offsets + LineCollection.rotation + LineCollection.rotations + LineCollection.supported_events + LineCollection.thickness LineCollection.visible + LineCollection.visibles LineCollection.world_object Methods @@ -36,11 +45,14 @@ Methods .. autosummary:: :toctree: LineCollection_api + LineCollection.add_event_handler LineCollection.add_graphic LineCollection.add_linear_region_selector LineCollection.add_linear_selector - LineCollection.link + LineCollection.clear_event_handlers + LineCollection.remove_event_handler LineCollection.remove_graphic - LineCollection.reset_feature - LineCollection.set_feature + LineCollection.rotate + LineCollection.share_property + LineCollection.unshare_property diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 4aae4bbee..d260c3214 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -20,11 +20,17 @@ Properties .. autosummary:: :toctree: LineGraphic_api - LineGraphic.children - LineGraphic.position - LineGraphic.position_x - LineGraphic.position_y - LineGraphic.position_z + LineGraphic.block_events + LineGraphic.cmap + LineGraphic.colors + LineGraphic.data + LineGraphic.deleted + LineGraphic.event_handlers + LineGraphic.name + LineGraphic.offset + LineGraphic.rotation + LineGraphic.supported_events + LineGraphic.thickness LineGraphic.visible LineGraphic.world_object @@ -33,9 +39,12 @@ Methods .. autosummary:: :toctree: LineGraphic_api + LineGraphic.add_event_handler LineGraphic.add_linear_region_selector LineGraphic.add_linear_selector - LineGraphic.link - LineGraphic.reset_feature - LineGraphic.set_feature + LineGraphic.clear_event_handlers + LineGraphic.remove_event_handler + LineGraphic.rotate + LineGraphic.share_property + LineGraphic.unshare_property diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index 36ae6808e..18b35932d 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -20,15 +20,24 @@ Properties .. autosummary:: :toctree: LineStack_api - LineStack.children + LineStack.block_events LineStack.cmap - LineStack.cmap_values + LineStack.colors + LineStack.data + LineStack.deleted + LineStack.event_handlers LineStack.graphics - LineStack.position - LineStack.position_x - LineStack.position_y - LineStack.position_z + LineStack.metadatas + LineStack.name + LineStack.names + LineStack.offset + LineStack.offsets + LineStack.rotation + LineStack.rotations + LineStack.supported_events + LineStack.thickness LineStack.visible + LineStack.visibles LineStack.world_object Methods @@ -36,11 +45,14 @@ Methods .. autosummary:: :toctree: LineStack_api + LineStack.add_event_handler LineStack.add_graphic LineStack.add_linear_region_selector LineStack.add_linear_selector - LineStack.link + LineStack.clear_event_handlers + LineStack.remove_event_handler LineStack.remove_graphic - LineStack.reset_feature - LineStack.set_feature + LineStack.rotate + LineStack.share_property + LineStack.unshare_property diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 3c4bf3909..8f2b17fd6 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -20,11 +20,17 @@ Properties .. autosummary:: :toctree: ScatterGraphic_api - ScatterGraphic.children - ScatterGraphic.position - ScatterGraphic.position_x - ScatterGraphic.position_y - ScatterGraphic.position_z + ScatterGraphic.block_events + ScatterGraphic.cmap + ScatterGraphic.colors + ScatterGraphic.data + ScatterGraphic.deleted + ScatterGraphic.event_handlers + ScatterGraphic.name + ScatterGraphic.offset + ScatterGraphic.rotation + ScatterGraphic.sizes + ScatterGraphic.supported_events ScatterGraphic.visible ScatterGraphic.world_object @@ -33,4 +39,10 @@ Methods .. autosummary:: :toctree: ScatterGraphic_api + ScatterGraphic.add_event_handler + ScatterGraphic.clear_event_handlers + ScatterGraphic.remove_event_handler + ScatterGraphic.rotate + ScatterGraphic.share_property + ScatterGraphic.unshare_property diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index 6290dcc2e..a3cd9bbb9 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -20,16 +20,18 @@ Properties .. autosummary:: :toctree: TextGraphic_api - TextGraphic.children + TextGraphic.block_events + TextGraphic.deleted + TextGraphic.event_handlers TextGraphic.face_color + TextGraphic.font_size + TextGraphic.name + TextGraphic.offset TextGraphic.outline_color - TextGraphic.outline_size - TextGraphic.position - TextGraphic.position_x - TextGraphic.position_y - TextGraphic.position_z + TextGraphic.outline_thickness + TextGraphic.rotation + TextGraphic.supported_events TextGraphic.text - TextGraphic.text_size TextGraphic.visible TextGraphic.world_object @@ -38,4 +40,10 @@ Methods .. autosummary:: :toctree: TextGraphic_api + TextGraphic.add_event_handler + TextGraphic.clear_event_handlers + TextGraphic.remove_event_handler + TextGraphic.rotate + TextGraphic.share_property + TextGraphic.unshare_property diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index 611ee5833..b64ac53c0 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -4,10 +4,9 @@ Graphics .. toctree:: :maxdepth: 1 + LineGraphic ImageGraphic ScatterGraphic - LineGraphic - HeatmapGraphic + TextGraphic LineCollection LineStack - TextGraphic diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst new file mode 100644 index 000000000..817284e18 --- /dev/null +++ b/docs/source/api/layouts/figure.rst @@ -0,0 +1,45 @@ +.. _api.Figure: + +Figure +****** + +====== +Figure +====== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Figure_api + + Figure + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Figure_api + + Figure.cameras + Figure.canvas + Figure.controllers + Figure.names + Figure.output + Figure.renderer + Figure.shape + Figure.toolbar + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Figure_api + + Figure.add_animations + Figure.clear + Figure.close + Figure.export + Figure.remove_animation + Figure.render + Figure.show + Figure.start_render + diff --git a/docs/source/api/layouts/gridplot.rst b/docs/source/api/layouts/gridplot.rst deleted file mode 100644 index b5b03bfa4..000000000 --- a/docs/source/api/layouts/gridplot.rst +++ /dev/null @@ -1,42 +0,0 @@ -.. _api.GridPlot: - -GridPlot -******** - -======== -GridPlot -======== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: GridPlot_api - - GridPlot - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: GridPlot_api - - GridPlot.canvas - GridPlot.renderer - GridPlot.toolbar - GridPlot.widget - -Methods -~~~~~~~ -.. autosummary:: - :toctree: GridPlot_api - - GridPlot.add_animations - GridPlot.clear - GridPlot.close - GridPlot.record_start - GridPlot.record_stop - GridPlot.remove_animation - GridPlot.render - GridPlot.show - GridPlot.start_render - diff --git a/docs/source/api/layouts/plot.rst b/docs/source/api/layouts/plot.rst deleted file mode 100644 index bd38720b4..000000000 --- a/docs/source/api/layouts/plot.rst +++ /dev/null @@ -1,73 +0,0 @@ -.. _api.Plot: - -Plot -**** - -==== -Plot -==== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Plot_api - - Plot - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Plot_api - - Plot.camera - Plot.canvas - Plot.controller - Plot.docks - Plot.graphics - Plot.name - Plot.parent - Plot.position - Plot.renderer - Plot.scene - Plot.selectors - Plot.toolbar - Plot.viewport - Plot.widget - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Plot_api - - Plot.add_animations - Plot.add_graphic - Plot.add_heatmap - Plot.add_image - Plot.add_line - Plot.add_line_collection - Plot.add_line_stack - Plot.add_scatter - Plot.add_text - Plot.auto_scale - Plot.center_graphic - Plot.center_scene - Plot.center_title - Plot.clear - Plot.close - Plot.delete_graphic - Plot.get_rect - Plot.insert_graphic - Plot.map_screen_to_world - Plot.record_start - Plot.record_stop - Plot.remove_animation - Plot.remove_graphic - Plot.render - Plot.set_axes_visibility - Plot.set_grid_visibility - Plot.set_title - Plot.set_viewport_rect - Plot.show - Plot.start_render - diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index c61c46e05..61f5da307 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -25,7 +25,9 @@ Properties Subplot.controller Subplot.docks Subplot.graphics + Subplot.legends Subplot.name + Subplot.objects Subplot.parent Subplot.position Subplot.renderer @@ -40,7 +42,6 @@ Methods Subplot.add_animations Subplot.add_graphic - Subplot.add_heatmap Subplot.add_image Subplot.add_line Subplot.add_line_collection @@ -54,6 +55,7 @@ Methods Subplot.clear Subplot.delete_graphic Subplot.get_rect + Subplot.get_refcounts Subplot.insert_graphic Subplot.map_screen_to_world Subplot.remove_animation diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index ce0d8d9b6..c9140bc7d 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -20,12 +20,17 @@ Properties .. autosummary:: :toctree: LinearRegionSelector_api - LinearRegionSelector.children + LinearRegionSelector.axis + LinearRegionSelector.block_events + LinearRegionSelector.deleted + LinearRegionSelector.event_handlers LinearRegionSelector.limits - LinearRegionSelector.position - LinearRegionSelector.position_x - LinearRegionSelector.position_y - LinearRegionSelector.position_z + LinearRegionSelector.name + LinearRegionSelector.offset + LinearRegionSelector.parent + LinearRegionSelector.rotation + LinearRegionSelector.selection + LinearRegionSelector.supported_events LinearRegionSelector.visible LinearRegionSelector.world_object @@ -34,9 +39,15 @@ Methods .. autosummary:: :toctree: LinearRegionSelector_api + LinearRegionSelector.add_event_handler LinearRegionSelector.add_ipywidget_handler + LinearRegionSelector.clear_event_handlers LinearRegionSelector.get_selected_data LinearRegionSelector.get_selected_index LinearRegionSelector.get_selected_indices LinearRegionSelector.make_ipywidget_slider + LinearRegionSelector.remove_event_handler + LinearRegionSelector.rotate + LinearRegionSelector.share_property + LinearRegionSelector.unshare_property diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index 4056bcc46..fa21f8f15 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -20,12 +20,17 @@ Properties .. autosummary:: :toctree: LinearSelector_api - LinearSelector.children + LinearSelector.axis + LinearSelector.block_events + LinearSelector.deleted + LinearSelector.event_handlers LinearSelector.limits - LinearSelector.position - LinearSelector.position_x - LinearSelector.position_y - LinearSelector.position_z + LinearSelector.name + LinearSelector.offset + LinearSelector.parent + LinearSelector.rotation + LinearSelector.selection + LinearSelector.supported_events LinearSelector.visible LinearSelector.world_object @@ -34,9 +39,15 @@ Methods .. autosummary:: :toctree: LinearSelector_api + LinearSelector.add_event_handler LinearSelector.add_ipywidget_handler + LinearSelector.clear_event_handlers LinearSelector.get_selected_data LinearSelector.get_selected_index LinearSelector.get_selected_indices LinearSelector.make_ipywidget_slider + LinearSelector.remove_event_handler + LinearSelector.rotate + LinearSelector.share_property + LinearSelector.unshare_property diff --git a/docs/source/api/selectors/PolygonSelector.rst b/docs/source/api/selectors/PolygonSelector.rst deleted file mode 100644 index aaa434dbf..000000000 --- a/docs/source/api/selectors/PolygonSelector.rst +++ /dev/null @@ -1,40 +0,0 @@ -.. _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/Synchronizer.rst b/docs/source/api/selectors/Synchronizer.rst deleted file mode 100644 index 2b28fe351..000000000 --- a/docs/source/api/selectors/Synchronizer.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.Synchronizer: - -Synchronizer -************ - -============ -Synchronizer -============ -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Synchronizer_api - - Synchronizer - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Synchronizer_api - - Synchronizer.selectors - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Synchronizer_api - - Synchronizer.add - Synchronizer.clear - Synchronizer.remove - diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst index 01c040728..ffa4054db 100644 --- a/docs/source/api/selectors/index.rst +++ b/docs/source/api/selectors/index.rst @@ -6,5 +6,3 @@ Selectors LinearSelector LinearRegionSelector - PolygonSelector - Synchronizer diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst index 08bce8d7a..3ca384968 100644 --- a/docs/source/api/widgets/ImageWidget.rst +++ b/docs/source/api/widgets/ImageWidget.rst @@ -23,9 +23,11 @@ Properties ImageWidget.cmap ImageWidget.current_index ImageWidget.data - ImageWidget.dims_order - ImageWidget.gridplot + ImageWidget.figure + ImageWidget.frame_apply ImageWidget.managed_graphics + ImageWidget.n_img_dims + ImageWidget.n_scrollable_dims ImageWidget.ndim ImageWidget.slider_dims ImageWidget.sliders diff --git a/docs/source/conf.py b/docs/source/conf.py index 7b33a309e..4d94ec7e7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,14 +2,31 @@ # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os + +# need to force offscreen rendering before importing fpl +# otherwise fpl tries to select glfw canvas +os.environ["WGPU_FORCE_OFFSCREEN"] = "1" + import fastplotlib +from pygfx.utils.gallery_scraper import find_examples_for_gallery +from pathlib import Path +import sys +from sphinx_gallery.sorting import ExplicitOrder +import imageio.v3 as iio + +ROOT_DIR = Path(__file__).parents[1].parents[0] # repo root +EXAMPLES_DIR = Path.joinpath(ROOT_DIR, "examples", "desktop") + +sys.path.insert(0, str(ROOT_DIR)) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'fastplotlib' -copyright = '2023, Kushal Kolar, Caitlin Lewis' -author = 'Kushal Kolar, Caitlin Lewis' +project = "fastplotlib" +copyright = "2023, Kushal Kolar, Caitlin Lewis" +author = "Kushal Kolar, Caitlin Lewis" release = fastplotlib.__version__ # -- General configuration --------------------------------------------------- @@ -23,26 +40,57 @@ "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx_design", - "nbsphinx", + "sphinx_gallery.gen_gallery" ] +sphinx_gallery_conf = { + "gallery_dirs": "_gallery", + "backreferences_dir": "_gallery/backreferences", + "doc_module": ("fastplotlib",), + "image_scrapers": ("pygfx",), + "remove_config_comments": True, + "subsection_order": ExplicitOrder( + [ + "../../examples/desktop/image", + "../../examples/desktop/gridplot", + "../../examples/desktop/line", + "../../examples/desktop/line_collection", + "../../examples/desktop/scatter", + "../../examples/desktop/heatmap", + "../../examples/desktop/misc" + ] + ), + "ignore_pattern": r'__init__\.py', + "nested_sections": False, + "thumbnail_size": (250, 250) +} + +extra_conf = find_examples_for_gallery(EXAMPLES_DIR) +sphinx_gallery_conf.update(extra_conf) + +# download imageio examples for the gallery +iio.imread("imageio:clock.png") +iio.imread("imageio:astronaut.png") +iio.imread("imageio:coffee.png") +iio.imread("imageio:hubble_deep_field.png") + autosummary_generate = True -templates_path = ['_templates'] +templates_path = ["_templates"] exclude_patterns = [] -napoleon_custom_sections = ['Features'] +napoleon_custom_sections = ["Features"] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "furo" -html_static_path = ['_static'] +html_static_path = ["_static"] html_logo = "_static/logo.png" html_title = f"v{release}" -autodoc_member_order = 'groupwise' +autodoc_member_order = "groupwise" autoclass_content = "both" add_module_names = False @@ -50,13 +98,14 @@ autodoc_typehints_description_target = "documented_params" intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), - 'numpy': ('https://numpy.org/doc/stable/', None), - 'pygfx': ('https://pygfx.readthedocs.io/en/latest', None) + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "pygfx": ("https://pygfx.com/latest", None), + "wgpu": ("https://wgpu-py.readthedocs.io/en/latest", None), } html_theme_options = { - "source_repository": "https://github.com/kushalkolar/fastplotlib", + "source_repository": "https://github.com/fastplotlib/fastplotlib", "source_branch": "main", "source_directory": "docs/", } diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 05e8b0f1c..0150836ec 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -26,7 +26,7 @@ GRAPHICS_DIR, GRAPHIC_FEATURES_DIR, SELECTORS_DIR, - WIDGETS_DIR + WIDGETS_DIR, ] for source_dir in doc_sources: @@ -66,13 +66,9 @@ def generate_class( ): name = cls.__name__ methods, properties = get_public_members(cls) - methods = [ - f"{name}.{m}" for m in methods - ] + methods = [f"{name}.{m}" for m in methods] - properties = [ - f"{name}.{p}" for p in properties - ] + properties = [f"{name}.{p}" for p in properties] underline = "=" * len(name) @@ -146,35 +142,24 @@ def generate_page( def main(): generate_page( - page_name="Plot", - classes=[fastplotlib.Plot], + page_name="Figure", + classes=[fastplotlib.Figure], modules=["fastplotlib"], - source_path=LAYOUTS_DIR.joinpath("plot.rst") - ) - - generate_page( - page_name="GridPlot", - classes=[fastplotlib.GridPlot], - modules=["fastplotlib"], - source_path=LAYOUTS_DIR.joinpath("gridplot.rst") + source_path=LAYOUTS_DIR.joinpath("figure.rst"), ) generate_page( page_name="Subplot", classes=[Subplot], modules=["fastplotlib.layouts._subplot"], - source_path=LAYOUTS_DIR.joinpath("subplot.rst") + source_path=LAYOUTS_DIR.joinpath("subplot.rst"), ) # the rest of this is a mess and can be refactored later - graphic_classes = [ - getattr(graphics, g) for g in graphics.__all__ - ] + graphic_classes = [getattr(graphics, g) for g in graphics.__all__] - graphic_class_names = [ - g.__name__ for g in graphic_classes - ] + graphic_class_names = [g.__name__ for g in graphic_classes] graphic_class_names_str = "\n ".join([""] + graphic_class_names) @@ -194,17 +179,13 @@ def main(): page_name=graphic_cls.__name__, classes=[graphic_cls], modules=["fastplotlib"], - source_path=GRAPHICS_DIR.joinpath(f"{graphic_cls.__name__}.rst") + source_path=GRAPHICS_DIR.joinpath(f"{graphic_cls.__name__}.rst"), ) ############################################################################## - feature_classes = [ - getattr(_features, f) for f in _features.__all__ - ] + feature_classes = [getattr(_features, f) for f in _features.__all__] - feature_class_names = [ - f.__name__ for f in feature_classes - ] + feature_class_names = [f.__name__ for f in feature_classes] feature_class_names_str = "\n ".join([""] + feature_class_names) @@ -223,17 +204,13 @@ def main(): page_name=feature_cls.__name__, classes=[feature_cls], modules=["fastplotlib.graphics._features"], - source_path=GRAPHIC_FEATURES_DIR.joinpath(f"{feature_cls.__name__}.rst") + source_path=GRAPHIC_FEATURES_DIR.joinpath(f"{feature_cls.__name__}.rst"), ) ############################################################################## - selector_classes = [ - getattr(selectors, s) for s in selectors.__all__ - ] + selector_classes = [getattr(selectors, s) for s in selectors.__all__] - selector_class_names = [ - s.__name__ for s in selector_classes - ] + selector_class_names = [s.__name__ for s in selector_classes] selector_class_names_str = "\n ".join([""] + selector_class_names) @@ -252,17 +229,13 @@ def main(): page_name=selector_cls.__name__, classes=[selector_cls], modules=["fastplotlib"], - source_path=SELECTORS_DIR.joinpath(f"{selector_cls.__name__}.rst") + source_path=SELECTORS_DIR.joinpath(f"{selector_cls.__name__}.rst"), ) ############################################################################## - widget_classes = [ - getattr(widgets, w) for w in widgets.__all__ - ] + widget_classes = [getattr(widgets, w) for w in widgets.__all__] - widget_class_names = [ - w.__name__ for w in widget_classes - ] + widget_class_names = [w.__name__ for w in widget_classes] widget_class_names_str = "\n ".join([""] + widget_class_names) @@ -281,7 +254,7 @@ def main(): page_name=widget_cls.__name__, classes=[widget_cls], modules=["fastplotlib"], - source_path=WIDGETS_DIR.joinpath(f"{widget_cls.__name__}.rst") + source_path=WIDGETS_DIR.joinpath(f"{widget_cls.__name__}.rst"), ) ############################################################################## @@ -290,6 +263,12 @@ def main(): with open(API_DIR.joinpath("utils.rst"), "w") as f: f.write(utils_str) + # gpu selection + fpl_functions = generate_functions_module(fastplotlib, "fastplotlib.utils.gpu") + + with open(API_DIR.joinpath("gpu.rst"), "w") as f: + f.write(fpl_functions) + if __name__ == "__main__": main() diff --git a/docs/source/index.rst b/docs/source/index.rst index 7e1d3865a..6385c2aee 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,29 +1,30 @@ -.. fastplotlib documentation master file, created by - sphinx-quickstart on Wed Dec 28 12:46:56 2022. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - Welcome to fastplotlib's documentation! ======================================= .. toctree:: - :caption: Quick Start - :maxdepth: 2 - - quickstart + :caption: User Guide + :maxdepth: 2 + + GPU Info .. toctree:: :maxdepth: 1 :caption: API - Plot - Gridplot + Figure Subplot Graphics Graphic Features Selectors Widgets Utils + GPU + +.. toctree:: + :caption: Gallery + :maxdepth: 1 + + Gallery <_gallery/index> Summary ======= @@ -31,7 +32,6 @@ Summary A fast plotting library built using the `pygfx `_ render engine utilizing `Vulkan `_, `DX12 `_, or `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. `fastplotlib` will run on any framework that ``pygfx`` runs on, this includes ``glfw``, ``Qt`` and ``jupyter lab`` - Installation ============ @@ -39,12 +39,23 @@ For installation please see the instructions on GitHub: https://github.com/kushalkolar/fastplotlib#installation +FAQ +=== + +1. Axes, axis, ticks, labels, legends + +A: They are on the `roadmap `_ and expected by summer 2024 :) + +2. Why the parrot logo? + +A: The logo is a `swift parrot `_, they are the fastest species of parrot and they are colorful like fastplotlib visualizations :D + Contributing ============ Contributions are welcome! See the contributing guide on GitHub: https://github.com/kushalkolar/fastplotlib/blob/master/CONTRIBUTING.md. -Also take a look at the `Roadmap 2023 `_ for future plans or ways in which you could contribute. +Also take a look at the `Roadmap 2025 `_ for future plans or ways in which you could contribute. Indices and tables ================== diff --git a/docs/source/user_guide/gpu.rst b/docs/source/user_guide/gpu.rst new file mode 100644 index 000000000..1bbc0c030 --- /dev/null +++ b/docs/source/user_guide/gpu.rst @@ -0,0 +1,314 @@ +GPU Info and selection +********************** + +FAQ +=== + +1. Do I need a GPU? + +Technically no, you can perform limited software rendering on linux using lavapipe (see drivers link below). However +``fastplotlib`` is intentionally built for realtime rendering using the latest GPU technologies, so we strongly +recommend that you use a GPU. + +2. My kernel keeps crashing. + +This can happen under the following circumstances: + +- You have ran out of GPU VRAM. +- Driver issues (see next section). + +If you aren't able to solve it please post an issue on GitHub. :) + +3. Nothing renders or rendering is weird, or I see graphical artifacts. + +- Probably driver issues (see next section). + +Drivers +======= + +See the README: https://github.com/fastplotlib/fastplotlib?tab=readme-ov-file#graphics-drivers + +If you notice weird graphic artifacts, things not rendering, or other glitches try updating to the latest stable +drivers. + +GPU Info +======== + +View available adapters +----------------------- + +You can get a summary of all adapters that are available to ``WGPU`` like this:: + + import fastplotlib as fpl + + adapters = fpl.enumerate_adapters() + + for a in adapters: + print(a.summary) + +For example, on a Thinkpad AMD laptop with a dedicated nvidia GPU this returns:: + + AMD Radeon Graphics (RADV REMBRANDT) (IntegratedGPU) on Vulkan + NVIDIA T1200 Laptop GPU (DiscreteGPU) on Vulkan + llvmpipe (LLVM 15.0.6, 256 bits) (CPU) on Vulkan + AMD Radeon Graphics (rembrandt, LLVM 15.0.6, DRM 3.52, 6.4.0-0.deb12.2-amd64) (Unknown) on OpenGL + +In jupyter all the available adapters are also listed when ``fastplotlib`` is imported. + +You can get more detailed info on each adapter like this:: + + import pprint + for a in fpl.enumerate_adapters(): + pprint.pprint(a.request_adapter_info()) + +General description of the fields: + * vendor: GPU manufacturer + * device: specific GPU model + * description: GPU driver version + * adapter_type: indicates whether this is a discrete GPU, integrated GPU, or software rendering adapter (CPU) + * backend_type: one of "Vulkan", "Metal", or "D3D12" + +For more information on the fields see: https://gpuweb.github.io/gpuweb/#gpuadapterinfo + +Adapter currently in use +------------------------ + +If you want to know the adapter that a figure is using you can check the adapter on the renderer:: + + # for example if we make a plot + fig = fpl.Figure() + fig[0, 0].add_image(np.random.rand(100, 100)) + fig.show() + + # GPU that is currently in use by the renderer + print(fig.renderer.device.adapter.summary) + + +Diagnostic info +--------------- + +After creating a figure you can view WGPU diagnostic info like this:: + + fpl.print_wgpu_report() + + +Example output:: + + ██ system: + + platform: Linux-5.10.0-21-amd64-x86_64-with-glibc2.31 + python_implementation: CPython + python: 3.11.3 + + ██ versions: + + wgpu: 0.15.1 + cffi: 1.15.1 + jupyter_rfb: 0.4.2 + numpy: 1.26.4 + pygfx: 0.2.0 + pylinalg: 0.4.1 + fastplotlib: 0.1.0.a16 + + ██ wgpu_native_info: + + expected_version: 0.19.3.1 + lib_version: 0.19.3.1 + lib_path: ./resources/libwgpu_native-release.so + + ██ object_counts: + + count resource_mem + + Adapter: 1 + BindGroup: 3 + BindGroupLayout: 3 + Buffer: 6 696 + CanvasContext: 1 + CommandBuffer: 0 + CommandEncoder: 0 + ComputePassEncoder: 0 + ComputePipeline: 0 + Device: 1 + PipelineLayout: 0 + QuerySet: 0 + Queue: 1 + RenderBundle: 0 + RenderBundleEncoder: 0 + RenderPassEncoder: 0 + RenderPipeline: 3 + Sampler: 2 + ShaderModule: 3 + Texture: 6 9.60M + TextureView: 6 + + total: 36 9.60M + + ██ wgpu_native_counts: + + count mem backend a k r e el_size + + Adapter: 1 1.98K vulkan: 1 1 3 0 1.98K + BindGroup: 3 1.10K vulkan: 3 3 0 0 368 + BindGroupLayout: 3 960 vulkan: 5 3 2 0 320 + Buffer: 6 1.77K vulkan: 7 6 1 0 296 + CanvasContext: 0 0 0 0 0 0 160 + CommandBuffer: 1 1.25K vulkan: 0 0 0 1 1.25K + ComputePipeline: 0 0 vulkan: 0 0 0 0 288 + Device: 1 11.8K vulkan: 1 1 0 0 11.8K + PipelineLayout: 0 0 vulkan: 3 0 3 0 200 + QuerySet: 0 0 vulkan: 0 0 0 0 80 + Queue: 1 184 vulkan: 1 1 0 0 184 + RenderBundle: 0 0 vulkan: 0 0 0 0 848 + RenderPipeline: 3 1.68K vulkan: 3 3 0 0 560 + Sampler: 2 160 vulkan: 2 2 0 0 80 + ShaderModule: 3 2.40K vulkan: 3 3 0 0 800 + Texture: 6 4.94K vulkan: 7 6 1 0 824 + TextureView: 6 1.48K vulkan: 6 6 1 0 248 + + total: 36 29.7K + + * The a, k, r, e are allocated, kept, released, and error, respectively. + * Reported memory does not include buffer/texture data. + + ██ pygfx_adapter_info: + + vendor: radv + architecture: + device: AMD RADV POLARIS10 (ACO) + description: Mesa 20.3.5 (ACO) + vendor_id: 4.09K + device_id: 26.5K + adapter_type: DiscreteGPU + backend_type: Vulkan + + ██ pygfx_features: + + adapter device + + bgra8unorm-storage: - - + depth32float-stencil8: ✓ - + depth-clip-control: ✓ - + float32-filterable: ✓ ✓ + indirect-first-instance: ✓ - + rg11b10ufloat-renderable: ✓ - + shader-f16: - - + texture-compression-astc: - - + texture-compression-bc: ✓ - + texture-compression-etc2: - - + timestamp-query: ✓ - + MultiDrawIndirect: ✓ - + MultiDrawIndirectCount: ✓ - + PushConstants: ✓ - + TextureAdapterSpecificFormatFeatures: ✓ - + VertexWritableStorage: ✓ - + + ██ pygfx_limits: + + adapter device + + max_bind_groups: 8 8 + max_bind_groups_plus_vertex_buffers: 0 0 + max_bindings_per_bind_group: 1.00K 1.00K + max_buffer_size: 2.14G 2.14G + max_color_attachment_bytes_per_sample: 0 0 + max_color_attachments: 0 0 + max_compute_invocations_per_workgroup: 1.02K 1.02K + max_compute_workgroup_size_x: 1.02K 1.02K + max_compute_workgroup_size_y: 1.02K 1.02K + max_compute_workgroup_size_z: 1.02K 1.02K + max_compute_workgroup_storage_size: 32.7K 32.7K + max_compute_workgroups_per_dimension: 65.5K 65.5K + max_dynamic_storage_buffers_per_pipeline_layout: 8 8 + max_dynamic_uniform_buffers_per_pipeline_layout: 16 16 + max_inter_stage_shader_components: 128 128 + max_inter_stage_shader_variables: 0 0 + max_sampled_textures_per_shader_stage: 8.38M 8.38M + max_samplers_per_shader_stage: 8.38M 8.38M + max_storage_buffer_binding_size: 2.14G 2.14G + max_storage_buffers_per_shader_stage: 8.38M 8.38M + max_storage_textures_per_shader_stage: 8.38M 8.38M + max_texture_array_layers: 2.04K 2.04K + max_texture_dimension1d: 16.3K 16.3K + max_texture_dimension2d: 16.3K 16.3K + max_texture_dimension3d: 2.04K 2.04K + max_uniform_buffer_binding_size: 2.14G 2.14G + max_uniform_buffers_per_shader_stage: 8.38M 8.38M + max_vertex_attributes: 32 32 + max_vertex_buffer_array_stride: 2.04K 2.04K + max_vertex_buffers: 16 16 + min_storage_buffer_offset_alignment: 32 32 + min_uniform_buffer_offset_alignment: 32 32 + + ██ pygfx_caches: + + count hits misses + + full_quad_objects: 1 0 2 + mipmap_pipelines: 0 0 0 + layouts: 1 0 3 + bindings: 1 0 1 + shader_modules: 2 0 2 + pipelines: 2 0 2 + shadow_pipelines: 0 0 0 + + ██ pygfx_resources: + + Texture: 8 + Buffer: 23 + + +Select GPU (adapter) +==================== + +You can select an adapter by passing one of the ``wgpu.GPUAdapter`` instances returned by ``fpl.enumerate_adapters()`` +to ``fpl.select_adapter()``:: + + # get info or summary of all adapters to pick an adapter + import pprint + for a in fpl.enumerate_adapters(): + pprint.pprint(a.request_adapter_info()) + + # example, pick adapter at index 2 + chosen_gpu = fpl.enumerate_adapters()[2] + fpl.select_adapter(chosen_gpu) + +**You must select an adapter before creating a** ``Figure`` **, otherwise the default adapter will be selected. Once a** +``Figure`` **is created the adapter cannot be changed.** + +Note that using this function reduces the portability of your code, because +it's highly specific for your current machine/environment. + +The order of the adapters returned by ``wgpu.gpu.enumerate_adapters()`` is +such that Vulkan adapters go first, then Metal, then D3D12, then OpenGL. +Within each category, the order as provided by the particular backend is +maintained. Note that the same device may be present via multiple backends +(e.g. vulkan/opengl). + +We cannot make guarantees about whether the order of the adapters matches +the order as reported by e.g. ``nvidia-smi``. We have found that on a Linux +multi-gpu cluster, the order does match, but we cannot promise that this is +always the case. If you want to make sure, do some testing by allocating big +buffers and checking memory usage using ``nvidia-smi`` + +Example to allocate and check GPU mem usage:: + + import subprocess + + import wgpu + import torch + + def allocate_gpu_mem_with_wgpu(idx): + a = wgpu.gpu.enumerate_adapters()[idx] + d = a.request_device() + b = d.create_buffer(size=10*2**20, usage=wgpu.BufferUsage.COPY_DST) + return b + + def allocate_gpu_mem_with_torch(idx): + d = torch.device(f"cuda:{idx}") + return torch.ones([2000, 10], dtype=torch.float32, device=d) + + def show_mem_usage(): + print(subprocess.run(["nvidia-smi"])) + +See https://github.com/pygfx/wgpu-py/issues/482 for more details. diff --git a/examples/README.rst b/examples/README.rst new file mode 100644 index 000000000..138ec748b --- /dev/null +++ b/examples/README.rst @@ -0,0 +1,2 @@ +Examples that use fastplotlib +============================= diff --git a/examples/desktop/gridplot/__init__.py b/examples/desktop/README.rst similarity index 100% rename from examples/desktop/gridplot/__init__.py rename to examples/desktop/README.rst diff --git a/examples/desktop/gridplot/README.rst b/examples/desktop/gridplot/README.rst new file mode 100644 index 000000000..486e708e7 --- /dev/null +++ b/examples/desktop/gridplot/README.rst @@ -0,0 +1,2 @@ +GridPlot Examples +================= diff --git a/examples/desktop/gridplot/gridplot.py b/examples/desktop/gridplot/gridplot.py index 3acf6a8ba..044adae80 100644 --- a/examples/desktop/gridplot/gridplot.py +++ b/examples/desktop/gridplot/gridplot.py @@ -1,36 +1,37 @@ """ GridPlot Simple -============ +=============== + Example showing simple 2x2 GridPlot with Standard images from imageio. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.GridPlot(shape=(2, 2)) -# to force a specific framework such as glfw: -# plot = fpl.GridPlot(canvas="glfw") +figure = fpl.Figure(shape=(2, 2)) im = iio.imread("imageio:clock.png") im2 = iio.imread("imageio:astronaut.png") im3 = iio.imread("imageio:coffee.png") im4 = iio.imread("imageio:hubble_deep_field.png") -plot[0, 0].add_image(data=im) -plot[0, 1].add_image(data=im2) -plot[1, 0].add_image(data=im3) -plot[1, 1].add_image(data=im4) +figure[0, 0].add_image(data=im) +figure[0, 1].add_image(data=im2) +figure[1, 0].add_image(data=im3) +figure[1, 1].add_image(data=im4) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) -for subplot in plot: +for subplot in figure: subplot.auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/gridplot/gridplot_non_square.py b/examples/desktop/gridplot/gridplot_non_square.py index fe43a3c04..c8a68cc85 100644 --- a/examples/desktop/gridplot/gridplot_non_square.py +++ b/examples/desktop/gridplot/gridplot_non_square.py @@ -1,34 +1,35 @@ """ -GridPlot Simple -============ +GridPlot Non-Square Example +=========================== + Example showing simple 2x2 GridPlot with Standard images from imageio. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.GridPlot(shape=(2, 2), controller_ids="sync") -# to force a specific framework such as glfw: -# plot = fpl.GridPlot(canvas="glfw") +figure = fpl.Figure(shape=(2, 2), controller_ids="sync") im = iio.imread("imageio:clock.png") im2 = iio.imread("imageio:astronaut.png") im3 = iio.imread("imageio:coffee.png") -plot[0, 0].add_image(data=im) -plot[0, 1].add_image(data=im2) -plot[1, 0].add_image(data=im3) +figure[0, 0].add_image(data=im) +figure[0, 1].add_image(data=im2) +figure[1, 0].add_image(data=im3) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) -for subplot in plot: +for subplot in figure: subplot.auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/gridplot/multigraphic_gridplot.py b/examples/desktop/gridplot/multigraphic_gridplot.py new file mode 100644 index 000000000..edb0aaafd --- /dev/null +++ b/examples/desktop/gridplot/multigraphic_gridplot.py @@ -0,0 +1,116 @@ +""" +Multi-Graphic GridPlot +====================== + +Example showing a Figure with multiple subplots and multiple graphic types. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +import imageio.v3 as iio +from itertools import product + +# define figure +figure = fpl.Figure(shape=(2, 2), names=[["image-overlay", "circles"], ["line-stack", "scatter"]]) + +img = iio.imread("imageio:coffee.png") + +# add image to subplot +figure["image-overlay"].add_image(data=img) + +# generate overlay + +# empty array for overlay, shape is [nrows, ncols, RGBA] +overlay = np.zeros(shape=(*img.shape[:2], 4), dtype=np.float32) + +# set the blue values of some pixels with an alpha > 1 +overlay[img[:, :, -1] > 200] = np.array([0.0, 0.0, 1.0, 0.6]).astype(np.float32) + +# add overlay to image +figure["image-overlay"].add_image(data=overlay) + +# generate some circles +def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points) + xs = radius * np.sin(theta) + ys = radius * np.cos(theta) + + return np.column_stack([xs, ys]) + center + + +spatial_dims = (50, 50) + +# this makes 16 circles, so we can create 16 cmap values, so it will use these values to set the +# color of the line based by using the cmap as a LUT with the corresponding cmap_transform +circles = list() +for center in product(range(0, spatial_dims[0], 15), range(0, spatial_dims[1], 15)): + circles.append(make_circle(center, 5, n_points=75)) + +# things like class labels, cluster labels, etc. +cmap_transform = [ + 0, 1, 1, 2, + 0, 0, 1, 1, + 2, 2, 8, 3, + 1, 9, 1, 5 +] + +# add an image to overlay the circles on +img2 = iio.imread("imageio:coins.png")[10::5, 5::5] + +figure["circles"].add_image(data=img2, cmap="gray") + +# add the circles to the figure +figure["circles"].add_line_collection( + circles, + cmap="tab10", + cmap_transform=cmap_transform, + thickness=3, + alpha=0.5, + name="circles-graphic" +) + +# move the circles graphic so that it is centered over the image +figure["circles"]["circles-graphic"].offset = np.array([7, 7, 2]) + +# generate some sine data +# linspace, create 100 evenly spaced x values from -10 to 10 +xs = np.linspace(-10, 10, 100) +# sine wave +ys = np.sin(xs) +sine = np.dstack([xs, ys])[0] + +# make 10 identical waves +sine_waves = 10 * [sine] + +# add the line stack to the figure +figure["line-stack"].add_line_stack(data=sine_waves, cmap="Wistia", separation=1) + +figure["line-stack"].auto_scale(maintain_aspect=True) + +# generate some scatter data +# create a gaussian cloud of 500 points +n_points = 500 + +mean = [0, 0] # mean of the Gaussian distribution +covariance = [[1, 0], [0, 1]] # covariance matrix + +gaussian_cloud = np.random.multivariate_normal(mean, covariance, n_points) +gaussian_cloud2 = np.random.multivariate_normal(mean, covariance, n_points) + +# add the scatter graphics to the figure +figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet") +figure["scatter"].add_scatter(data=gaussian_cloud2, colors="r", sizes=2) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() + diff --git a/examples/desktop/heatmap/README.rst b/examples/desktop/heatmap/README.rst new file mode 100644 index 000000000..64294f46f --- /dev/null +++ b/examples/desktop/heatmap/README.rst @@ -0,0 +1,2 @@ +Heatmap Examples +================ diff --git a/examples/desktop/heatmap/heatmap.py b/examples/desktop/heatmap/heatmap.py index 45c340cbd..08b284749 100644 --- a/examples/desktop/heatmap/heatmap.py +++ b/examples/desktop/heatmap/heatmap.py @@ -5,33 +5,30 @@ """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 10_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(20_000)]) # plot the image data -heatmap_graphic = plot.add_heatmap(data=data, name="heatmap") +img = figure[0, 0].add_image(data=data, name="heatmap") -plot.show() +figure.show() -plot.canvas.set_logical_size(1500, 1500) +figure.canvas.set_logical_size(700, 560) -plot.auto_scale() +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/heatmap/heatmap_cmap.py b/examples/desktop/heatmap/heatmap_cmap.py index afc67f5b8..f51981bed 100644 --- a/examples/desktop/heatmap/heatmap_cmap.py +++ b/examples/desktop/heatmap/heatmap_cmap.py @@ -4,36 +4,34 @@ Change the cmap of a heatmap """ -# test_example = true + +# test_example = false +# sphinx_gallery_pygfx_docs = 'hidden' import fastplotlib as fpl import numpy as np -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 10_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(20_000)]) # plot the image data -heatmap_graphic = plot.add_heatmap(data=data, name="heatmap") +img = figure[0, 0].add_image(data=data, name="heatmap") -plot.show() +figure.show() -plot.canvas.set_logical_size(1500, 1500) +figure.canvas.set_logical_size(700, 560) -plot.auto_scale() +figure[0, 0].auto_scale() -heatmap_graphic.cmap = "viridis" +img.cmap = "viridis" +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/heatmap/heatmap_data.py b/examples/desktop/heatmap/heatmap_data.py index 78e819ab8..9334ea4d7 100644 --- a/examples/desktop/heatmap/heatmap_data.py +++ b/examples/desktop/heatmap/heatmap_data.py @@ -4,38 +4,35 @@ Change the data of a heatmap """ -# test_example = true +# test_example = false +# sphinx_gallery_pygfx_docs = 'hidden' import fastplotlib as fpl import numpy as np -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 9_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(9_000)]) # plot the image data -heatmap_graphic = plot.add_heatmap(data=data, name="heatmap") +img = figure[0, 0].add_image(data=data, name="heatmap") -plot.show() +figure.show() -plot.canvas.set_logical_size(1500, 1500) +figure.canvas.set_logical_size(700, 560) -plot.auto_scale() - -heatmap_graphic.data[:5_000] = sine -heatmap_graphic.data[5_000:] = cosine +figure[0, 0].auto_scale() +cosine = np.cos(np.sqrt(xs)[:3000]) +# change first 2,000 rows and 3,000 columns +img.data[:2_000, :3_000] = np.vstack([cosine * i * 4 for i in range(2_000)]) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/heatmap/heatmap_square.py b/examples/desktop/heatmap/heatmap_square.py new file mode 100644 index 000000000..51e71695a --- /dev/null +++ b/examples/desktop/heatmap/heatmap_square.py @@ -0,0 +1,34 @@ +""" +Square Heatmap +============== +square heatmap test +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure() + +xs = np.linspace(0, 1_000, 20_000, dtype=np.float32) + +sine = np.sin(np.sqrt(xs)) + +data = np.vstack([sine * i for i in range(20_000)]) + +# plot the image data +img = figure[0, 0].add_image(data=data, name="heatmap") + +del data # data no longer needed after given to graphic +figure.show() + +figure.canvas.set_logical_size(1500, 1500) + +figure[0, 0].auto_scale() + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/heatmap/heatmap_vmin_vmax.py b/examples/desktop/heatmap/heatmap_vmin_vmax.py index 7aae1d6d3..45c960fd8 100644 --- a/examples/desktop/heatmap/heatmap_vmin_vmax.py +++ b/examples/desktop/heatmap/heatmap_vmin_vmax.py @@ -4,37 +4,34 @@ Change the vmin vmax of a heatmap """ -# test_example = true +# test_example = false +# sphinx_gallery_pygfx_docs = 'hidden' import fastplotlib as fpl import numpy as np -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 10_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(20_000)]) # plot the image data -heatmap_graphic = plot.add_heatmap(data=data, name="heatmap") +img = figure[0, 0].add_image(data=data, name="heatmap") -plot.show() +figure.show() -plot.canvas.set_logical_size(1500, 1500) +figure.canvas.set_logical_size(700, 560) -plot.auto_scale() +figure[0, 0].auto_scale() -heatmap_graphic.cmap.vmin = -0.5 -heatmap_graphic.cmap.vmax = 0.5 +img.vmin = -5_000 +img.vmax = 10_000 +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/heatmap/heatmap_wide.py b/examples/desktop/heatmap/heatmap_wide.py new file mode 100644 index 000000000..dccf531e2 --- /dev/null +++ b/examples/desktop/heatmap/heatmap_wide.py @@ -0,0 +1,33 @@ +""" +Wide Heatmap +============ +Wide example +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure() + +xs = np.linspace(0, 1_000, 20_000, dtype=np.float32) + +sine = np.sin(np.sqrt(xs)) + +data = np.vstack([sine * i for i in range(10_000)]) + +# plot the image data +img = figure[0, 0].add_image(data=data, name="heatmap") + +figure.show() + +figure.canvas.set_logical_size(1500, 1500) + +figure[0, 0].auto_scale() + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/image/README.rst b/examples/desktop/image/README.rst new file mode 100644 index 000000000..028e85ec5 --- /dev/null +++ b/examples/desktop/image/README.rst @@ -0,0 +1,2 @@ +Image Examples +============== diff --git a/examples/desktop/image/image_cmap.py b/examples/desktop/image/image_cmap.py index 9a9f0d497..c70af7346 100644 --- a/examples/desktop/image/image_cmap.py +++ b/examples/desktop/image/image_cmap.py @@ -1,31 +1,33 @@ """ -Simple Plot -============ +Image Colormap +============== + Example showing simple plot creation and subsequent cmap change with Standard image from imageio. """ + # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") - im = iio.imread("imageio:camera.png") +figure = fpl.Figure() + # plot the image data -image_graphic = plot.add_image(data=im, name="random-image") +image_graphic = figure[0, 0].add_image(data=im, name="random-image") -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) -plot.auto_scale() +figure[0, 0].auto_scale() image_graphic.cmap = "viridis" +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/image/image_rgb.py b/examples/desktop/image/image_rgb.py index f73077acf..951142fd7 100644 --- a/examples/desktop/image/image_rgb.py +++ b/examples/desktop/image/image_rgb.py @@ -1,30 +1,31 @@ """ -Simple Plot -============ +RGB Image +========= + Example showing the simple plot creation with 512 x 512 2D RGB image. """ + # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") - im = iio.imread("imageio:astronaut.png") -# plot the image data -image_graphic = plot.add_image(data=im, name="iio astronaut") +figure = fpl.Figure() -plot.show() +# plot the image data +image_graphic = figure[0, 0].add_image(data=im, name="iio astronaut") -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/image/image_rgbvminvmax.py b/examples/desktop/image/image_rgbvminvmax.py index 4891c5614..25d3904e8 100644 --- a/examples/desktop/image/image_rgbvminvmax.py +++ b/examples/desktop/image/image_rgbvminvmax.py @@ -1,33 +1,34 @@ """ -Simple Plot -============ +RGB Image Vmin/Vmax +=================== + Example showing the simple plot followed by changing the vmin/vmax with 512 x 512 2D RGB image. """ + # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") - im = iio.imread("imageio:astronaut.png") -# plot the image data -image_graphic = plot.add_image(data=im, name="iio astronaut") +figure = fpl.Figure() -plot.show() +# plot the image data +image_graphic = figure[0, 0].add_image(data=im, name="iio astronaut") -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +figure[0, 0].auto_scale() +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/image/image_simple.py b/examples/desktop/image/image_simple.py index 2d273ad68..dab5188a1 100644 --- a/examples/desktop/image/image_simple.py +++ b/examples/desktop/image/image_simple.py @@ -1,30 +1,31 @@ """ -Simple Plot +Simple Image ============ + Example showing the simple plot creation with Standard imageio image. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() data = iio.imread("imageio:camera.png") # plot the image data -image_graphic = plot.add_image(data=data, name="iio camera") +image_graphic = figure[0, 0].add_image(data=data, name="iio camera") -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) -plot.auto_scale() +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/image/image_small.py b/examples/desktop/image/image_small.py new file mode 100644 index 000000000..95c263a28 --- /dev/null +++ b/examples/desktop/image/image_small.py @@ -0,0 +1,33 @@ +""" +Small Image +=========== + +Test image to verify dims +""" + +import numpy as np + +# test_example = true +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl + +figure = fpl.Figure() + +data = np.array( + [[0, 1, 2], + [3, 4, 5]] +) +image_graphic = figure[0, 0].add_image(data) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0, 0].auto_scale() + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/image/image_vminvmax.py b/examples/desktop/image/image_vminvmax.py index ae5d102fa..d9e49b18e 100644 --- a/examples/desktop/image/image_vminvmax.py +++ b/examples/desktop/image/image_vminvmax.py @@ -1,33 +1,34 @@ """ -Simple Plot -============ +Image Vmin/Vmax +=============== + Example showing the simple plot creation followed by changing the vmin/vmax with Standard imageio image. """ + # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import imageio.v3 as iio - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() data = iio.imread("imageio:astronaut.png") # plot the image data -image_graphic = plot.add_image(data=data, name="iio astronaut") - -plot.show() +image_graphic = figure[0, 0].add_image(data=data, name="iio astronaut") -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +figure[0, 0].auto_scale() +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/image/image_widget.py b/examples/desktop/image/image_widget.py new file mode 100644 index 000000000..de1d27de1 --- /dev/null +++ b/examples/desktop/image/image_widget.py @@ -0,0 +1,22 @@ +""" +Image widget +============ + +Example showing the image widget in action. +When run in a notebook, or with the Qt GUI backend, sliders are also shown. +""" + +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import imageio.v3 as iio # not a fastplotlib dependency, only used for examples + +a = iio.imread("imageio:camera.png") +iw = fpl.ImageWidget(data=a, cmap="viridis") +iw.show() + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/line/README.rst b/examples/desktop/line/README.rst new file mode 100644 index 000000000..b9970c543 --- /dev/null +++ b/examples/desktop/line/README.rst @@ -0,0 +1,2 @@ +Line Examples +============= diff --git a/examples/desktop/line/__init__.py b/examples/desktop/line/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/desktop/line/line.py b/examples/desktop/line/line.py index 8cab1954f..cd661da1e 100644 --- a/examples/desktop/line/line.py +++ b/examples/desktop/line/line.py @@ -1,18 +1,17 @@ """ -Line Plot -============ +Simple Line Plot +================ + Example showing cosine, sine, sinc lines. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() xs = np.linspace(-10, 10, 100) # sine wave @@ -28,22 +27,23 @@ ys = np.sinc(xs) * 3 + 8 sinc = np.dstack([xs, ys])[0] -sine_graphic = plot.add_line(data=sine, thickness=5, colors="magenta") +sine_graphic = figure[0, 0].add_line(data=sine, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine_graphic = plot.add_line(data=cosine, thickness=12, cmap="autumn") +cosine_graphic = figure[0, 0].add_line(data=cosine, thickness=12, cmap="autumn") # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = plot.add_line(data=sinc, thickness=5, colors=colors) - -plot.show() +sinc_graphic = figure[0, 0].add_line(data=sinc, thickness=5, colors=colors) -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line/line_cmap.py b/examples/desktop/line/line_cmap.py index b196132ed..5ffea6fef 100644 --- a/examples/desktop/line/line_cmap.py +++ b/examples/desktop/line/line_cmap.py @@ -1,18 +1,17 @@ """ -Line Plot -============ +Line Plot Colormap +================== + Example showing cosine, sine, sinc lines. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() xs = np.linspace(-10, 10, 100) # sine wave @@ -23,27 +22,29 @@ ys = np.cos(xs) - 5 cosine = np.dstack([xs, ys])[0] -# cmap_values from an array, so the colors on the sine line will be based on the sine y-values -sine_graphic = plot.add_line( +# cmap_transform from an array, so the colors on the sine line will be based on the sine y-values +sine_graphic = figure[0, 0].add_line( data=sine, thickness=10, cmap="plasma", - cmap_values=sine[:, 1] + cmap_transform=sine[:, 1] ) # qualitative colormaps, useful for cluster labels or other types of categorical labels -cmap_values = [0] * 25 + [5] * 10 + [1] * 35 + [2] * 30 -cosine_graphic = plot.add_line( +labels = [0] * 25 + [5] * 10 + [1] * 35 + [2] * 30 +cosine_graphic = figure[0, 0].add_line( data=cosine, thickness=10, cmap="tab10", - cmap_values=cmap_values + cmap_transform=labels ) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line/line_colorslice.py b/examples/desktop/line/line_colorslice.py index f2aca8125..3d18d74b7 100644 --- a/examples/desktop/line/line_colorslice.py +++ b/examples/desktop/line/line_colorslice.py @@ -1,53 +1,71 @@ """ -Line Plot -============ +Line Plot Color Slicing +======================= + Example showing color slicing with cosine, sine, sinc lines. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] +sine = np.column_stack([xs, ys]) # cosine wave -ys = np.cos(xs) + 5 -cosine = np.dstack([xs, ys])[0] +ys = np.cos(xs) +cosine = np.column_stack([xs, ys]) # sinc function a = 0.5 -ys = np.sinc(xs) * 3 + 8 -sinc = np.dstack([xs, ys])[0] +ys = np.sinc(xs) * 3 +sinc = np.column_stack([xs, ys]) -sine_graphic = plot.add_line(data=sine, thickness=5, colors="magenta") +sine_graphic = figure[0, 0].add_line( + data=sine, + thickness=5, + colors="magenta" +) # you can also use colormaps for lines! -cosine_graphic = plot.add_line(data=cosine, thickness=12, cmap="autumn") +cosine_graphic = figure[0, 0].add_line( + data=cosine, + thickness=12, + cmap="autumn", + offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data +) # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = plot.add_line(data=sinc, thickness=5, colors=colors) - -plot.show() +sinc_graphic = figure[0, 0].add_line( + data=sinc, + thickness=5, + colors=colors, + offset=(0, 6, 0) +) + +zeros = np.zeros(xs.size) +zeros_data = np.column_stack([xs, zeros]) +zeros_graphic = figure[0, 0].add_line( + data=zeros_data, + thickness=8, + colors="w", + offset=(0, 10, 0) +) + +figure.show() # indexing of colors cosine_graphic.colors[:15] = "magenta" cosine_graphic.colors[90:] = "red" cosine_graphic.colors[60] = "w" -# indexing to assign colormaps to entire lines or segments -sinc_graphic.cmap[10:50] = "gray" -sine_graphic.cmap = "seismic" - # more complex indexing, set the blue value directly from an array cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65) @@ -55,14 +73,21 @@ key = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 67, 19]) sinc_graphic.colors[key] = "Red" -key2 = np.array([True, False, True, False, True, True, True, True]) -cosine_graphic.colors[key2] = "Green" +# boolean fancy indexing +zeros_graphic.colors[xs < -5] = "green" -plot.canvas.set_logical_size(800, 800) +# assign colormap to an entire line +sine_graphic.cmap = "seismic" +# or to segments of a line +zeros_graphic.cmap[50:75] = "jet" +zeros_graphic.cmap[75:] = "viridis" -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line/line_dataslice.py b/examples/desktop/line/line_dataslice.py index ea87ba552..eac765c68 100644 --- a/examples/desktop/line/line_dataslice.py +++ b/examples/desktop/line/line_dataslice.py @@ -1,18 +1,17 @@ """ -Line Plot -============ +Line Plot Data Slicing +====================== + Example showing data slicing with cosine, sine, sinc lines. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() xs = np.linspace(-10, 10, 100) # sine wave @@ -28,30 +27,31 @@ ys = np.sinc(xs) * 3 + 8 sinc = np.dstack([xs, ys])[0] -sine_graphic = plot.add_line(data=sine, thickness=5, colors="magenta") +sine_graphic = figure[0, 0].add_line(data=sine, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine_graphic = plot.add_line(data=cosine, thickness=12, cmap="autumn") +cosine_graphic = figure[0, 0].add_line(data=cosine, thickness=12, cmap="autumn") # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = plot.add_line(data=sinc, thickness=5, colors=colors) +sinc_graphic = figure[0, 0].add_line(data=sinc, thickness=5, colors=colors) -plot.show() +figure.show() cosine_graphic.data[10:50:5, :2] = sine[10:50:5] cosine_graphic.data[90:, 1] = 7 cosine_graphic.data[0] = np.array([[-10, 0, 0]]) -# additional fancy indexing using numpy -key2 = np.array([True, False, True, False, True, True, True, True]) -sinc_graphic.data[key2] = np.array([[5, 1, 2]]) - -plot.canvas.set_logical_size(800, 800) +# additional fancy indexing with boolean array +bool_key = [True, True, True, False, False] * 20 +sinc_graphic.data[bool_key, 1] = 7 # y vals to 1 -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line/line_present_scaling.py b/examples/desktop/line/line_present_scaling.py deleted file mode 100644 index 327186c16..000000000 --- a/examples/desktop/line/line_present_scaling.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Line Plot -============ -Example showing present and scaling feature for lines. -""" - -# test_example = true - -import fastplotlib as fpl -import numpy as np - - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") - -xs = np.linspace(-10, 10, 100) -# sine wave -ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] - -# cosine wave -ys = np.cos(xs) + 5 -cosine = np.dstack([xs, ys])[0] - -# sinc function -a = 0.5 -ys = np.sinc(xs) * 3 + 8 -sinc = np.dstack([xs, ys])[0] - -sine_graphic = plot.add_line(data=sine, thickness=5, colors="magenta") - -# you can also use colormaps for lines! -cosine_graphic = plot.add_line(data=cosine, thickness=12, cmap="autumn") - -# or a list of colors for each datapoint -colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = plot.add_line(data=sinc, thickness=5, colors=colors) - -plot.show() - -sinc_graphic.present = False - -plot.canvas.set_logical_size(800, 800) - -plot.auto_scale() - - -if __name__ == "__main__": - print(__doc__) - fpl.run() diff --git a/examples/desktop/line_collection/README.rst b/examples/desktop/line_collection/README.rst new file mode 100644 index 000000000..3dbe05f7f --- /dev/null +++ b/examples/desktop/line_collection/README.rst @@ -0,0 +1,2 @@ +LineCollection Examples +======================= diff --git a/examples/desktop/line_collection/__init__.py b/examples/desktop/line_collection/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/desktop/line_collection/line_collection.py b/examples/desktop/line_collection/line_collection.py index 071da2e2e..44b765319 100644 --- a/examples/desktop/line_collection/line_collection.py +++ b/examples/desktop/line_collection/line_collection.py @@ -1,10 +1,12 @@ """ -Line Plot -============ +Line Collection Simple +====================== + Example showing how to plot line collections """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' from itertools import product import numpy as np @@ -27,16 +29,16 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: pos_xy = np.vstack(circles) -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -plot.add_line_collection(circles, cmap="jet", thickness=5) +figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line_collection/line_collection_cmap_values.py b/examples/desktop/line_collection/line_collection_cmap_values.py index 3623c20c3..e94a161ad 100644 --- a/examples/desktop/line_collection/line_collection_cmap_values.py +++ b/examples/desktop/line_collection/line_collection_cmap_values.py @@ -1,16 +1,17 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line collections quantitative cmap +================================== + +Example showing a line collection with a quantitative cmap """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' from itertools import product import numpy as np import fastplotlib as fpl - def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: theta = np.linspace(0, 2 * np.pi, n_points) xs = radius * np.sin(theta) @@ -33,21 +34,18 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: # highest values, lowest values, mid-high values, mid values cmap_values = [10] * 4 + [0] * 4 + [7] * 4 + [5] * 4 -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -plot.add_line_collection( - circles, - cmap="bwr", - cmap_values=cmap_values, - thickness=10 +figure[0, 0].add_line_collection( + circles, cmap="bwr", cmap_transform=cmap_values, thickness=10 ) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py b/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py index f56d2ca02..5f9ea0000 100644 --- a/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py +++ b/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py @@ -1,16 +1,17 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line Collection Qualitative Colormap +==================================== + +Example showing a line collection with a qualitative cmap """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' from itertools import product import numpy as np import fastplotlib as fpl - def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: theta = np.linspace(0, 2 * np.pi, n_points) xs = radius * np.sin(theta) @@ -39,21 +40,21 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: 1, 1, 1, 5 ] -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -plot.add_line_collection( +figure[0, 0].add_line_collection( circles, cmap="tab10", - cmap_values=cmap_values, + cmap_transform=cmap_values, thickness=10 ) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line_collection/line_collection_colors.py b/examples/desktop/line_collection/line_collection_colors.py index d74f65d82..bf3e818cd 100644 --- a/examples/desktop/line_collection/line_collection_colors.py +++ b/examples/desktop/line_collection/line_collection_colors.py @@ -1,10 +1,12 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line Collection Colors +====================== + +Example showing one way ot setting colors for individual lines in a collection """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' from itertools import product import numpy as np @@ -31,16 +33,16 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: # this will produce 16 circles so we will define 16 colors colors = ["blue"] * 4 + ["red"] * 4 + ["yellow"] * 4 + ["w"] * 4 -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -plot.add_line_collection(circles, colors=colors, thickness=10) +figure[0, 0].add_line_collection(circles, colors=colors, thickness=10) -plot.show() +figure.show() -plot.canvas.set_logical_size(800, 800) +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line_collection/line_collection_slicing.py b/examples/desktop/line_collection/line_collection_slicing.py new file mode 100644 index 000000000..a7525f7ba --- /dev/null +++ b/examples/desktop/line_collection/line_collection_slicing.py @@ -0,0 +1,70 @@ +""" +Line collection slicing +======================= + +Example showing how to slice a line collection +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + + +xs = np.linspace(0, np.pi * 10, 100) +# sine wave +ys = np.sin(xs) + +data = np.column_stack([xs, ys]) +multi_data = np.stack([data] * 15) + + +figure = fpl.Figure() + +lines = figure[0, 0].add_line_stack( + multi_data, + thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], + separation=1, + metadatas=list(range(15)), # some metadata + names=list("abcdefghijklmno"), # unique name for each line +) + +print("slice a collection to return a collection indexer") +print(lines[1:5]) # lines 1, 2, 3, 4 + +print("collections supports fancy indexing!") +print(lines[::3]) + +print("fancy index using properties of individual lines!") +print(lines[lines.thickness < 3]) +print(lines[lines.metadatas > 10]) + +# set line properties, such as data +# set y-values of lines 3, 4, 5 +lines[3:6].data[:, 1] = np.cos(xs) +# set these same lines to a different color +lines[3:6].colors = "cyan" + +# setting properties using fancy indexing +# set cmap along the line collection +lines[-3:].cmap = "plasma" + +# set cmap of along a single line +lines[7].cmap = "jet" + +# fancy indexing using line properties! +lines[lines.thickness > 8].colors = "r" +lines[lines.names == "a"].colors = "b" + +# fancy index at the level of lines and individual line properties! +lines[::2].colors[::5] = "magenta" # set every 5th point of every other line to magenta +lines[3:6].colors[50:, -1] = 0.6 # set half the points alpha to 0.6 + +figure.show(maintain_aspect=False) + +figure.canvas.set_logical_size(700, 580) + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/line_collection/line_stack.py b/examples/desktop/line_collection/line_stack.py index 5a94caee7..e7f7125e1 100644 --- a/examples/desktop/line_collection/line_stack.py +++ b/examples/desktop/line_collection/line_stack.py @@ -1,33 +1,39 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line Stack +========== + +Example showing how to plot a stack of lines """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np import fastplotlib as fpl -xs = np.linspace(0, 100, 1000) +xs = np.linspace(0, np.pi * 10, 100) # sine wave -ys = np.sin(xs) * 20 +ys = np.sin(xs) -# make 25 lines -data = np.vstack([ys] * 25) +data = np.column_stack([xs, ys]) +multi_data = np.stack([data] * 10) -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -# line stack takes all the same arguments as line collection and behaves similarly -plot.add_line_stack(data, cmap="jet") +line_stack = figure[0, 0].add_line_stack( + multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] + cmap="jet", # applied along n_lines + thickness=5, + separation=1, # spacing between lines along the separation axis, default separation along "y" axis +) -plot.show(maintain_aspect=False) +figure.show(maintain_aspect=False) -plot.canvas.set_logical_size(900, 600) +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/line_collection/line_stack_3d.py b/examples/desktop/line_collection/line_stack_3d.py new file mode 100644 index 000000000..314a97ff2 --- /dev/null +++ b/examples/desktop/line_collection/line_stack_3d.py @@ -0,0 +1,106 @@ +""" +Line stack 3D +============= + +Example showing a 3D stack of lines with animations +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import numpy as np +import fastplotlib as fpl + + +xs = np.linspace(0, np.pi * 10, 100) +# spiral +ys = np.sin(xs) +zs = np.cos(xs) + +data = np.column_stack([xs, ys, zs]) +multi_data = np.stack([data] * 10) + +# create figure to plot lines and use an orbit controller in 3D +figure = fpl.Figure(cameras="3d", controller_types="orbit") + +line_stack = figure[0, 0].add_line_stack( + multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] + cmap="jet", # applied along n_lines + thickness=3, + separation=1, # spacing between lines along the separation axis, default separation along "y" axis + name="lines", +) + + +x_increment = 0.1 + + +def animate_data(subplot): + """animate with different rates of spinning the spirals""" + global xs # x vals + global x_increment # increment + + # calculate the new data + # new a different spinning rate for each spiral + # top ones will spin faster than the bottom ones + new_xs = [xs + (factor * x_increment) for factor in np.linspace(0.5, 1.5, 10)] + y = [np.sin(x) for x in new_xs] + z = [np.cos(x) for x in new_xs] + + # iterate through collection and set data of each line + for i, line in enumerate(subplot["lines"]): + # set y and z values + line.data[:, 1:] = np.column_stack([y[i], z[i]]) + + x_increment += 0.1 + + +colors_iteration = 0 + + +def animate_colors(subplot): + """animate the colors""" + global colors_iteration + + # change the colors only on every 50th render cycle + # otherwise it just looks like flickering because it's too fast :) + if colors_iteration % 50 != 0: + colors_iteration += 1 + return + + # use cmap_transform to shift the cmap + cmap_transform = np.roll(np.arange(10), shift=int(colors_iteration / 50)) + + # set cmap with the transform + subplot["lines"].cmap = "jet", cmap_transform + + colors_iteration += 1 + + +figure[0, 0].add_animations(animate_data, animate_colors) + +# just a pre-saved camera state +camera_state = { + "position": np.array([-18.0, 9.0, 8.0]), + "rotation": np.array([0.00401791, -0.5951809, 0.00297593, 0.80357619]), + "scale": np.array([1.0, 1.0, 1.0]), + "reference_up": np.array([0.0, 1.0, 0.0]), + "fov": 50.0, + "width": 32, + "height": 20, + "zoom": 1, + "maintain_aspect": True, + "depth_range": None, +} + +figure.show(maintain_aspect=False) + +figure[0, 0].camera.set_state(camera_state) + +figure.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/misc/README.rst b/examples/desktop/misc/README.rst new file mode 100644 index 000000000..cc51fd686 --- /dev/null +++ b/examples/desktop/misc/README.rst @@ -0,0 +1,2 @@ +Other Examples +============== diff --git a/examples/desktop/misc/cycle_animation.py b/examples/desktop/misc/cycle_animation.py new file mode 100644 index 000000000..bb402a1f7 --- /dev/null +++ b/examples/desktop/misc/cycle_animation.py @@ -0,0 +1,62 @@ +""" +Scatter Animation Colors +======================== + +Example showing animation with a scatter plot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import fastplotlib as fpl +import numpy as np + +# create a random distribution of 10,000 xyz coordinates +n_points = 10_000 + +# dimensions always have to be [n_points, xyz] +dims = (n_points, 3) + +clouds_offset = 15 + +# create some random clouds +normal = np.random.normal(size=dims, scale=5) +# stack the data into a single array +cloud = np.vstack( + [ + normal - clouds_offset, + normal, + normal + clouds_offset, + ] +) + +# color each of them separately +colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points + +# create plot +figure = fpl.Figure() +subplot_scatter = figure[0, 0] +# use an alpha value since this will be a lot of points +scatter_graphic = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) + + +i = 0.05 +def cycle_colors(subplot): + global i + # cycle the red values + scatter_graphic.colors[n_points * 2:, 0] = np.abs(np.sin(i)) + scatter_graphic.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4))) + scatter_graphic.colors[n_points * 2:, 2] = np.abs(np.cos(i)) + i += 0.05 + +subplot_scatter.add_animations(cycle_colors) + +figure.show() + +subplot_scatter.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() \ No newline at end of file diff --git a/examples/desktop/misc/em_wave_animation.py b/examples/desktop/misc/em_wave_animation.py new file mode 100644 index 000000000..50ab27ed6 --- /dev/null +++ b/examples/desktop/misc/em_wave_animation.py @@ -0,0 +1,105 @@ +""" +Electromagnetic Wave Animation +============================== + +Example showing animation of an electromagnetic wave. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 400) +) + +start, stop = 0, 4 * np.pi + +# let's define the x, y and z axes for each with direction of wave propogation along the z-axis +# electric field in the xz plane travelling along +zs = np.linspace(start, stop, 200) +e_ys = np.zeros(200) +e_xs = np.sin(zs) +electric = np.column_stack([e_xs, e_ys, zs]) + +# magnetic field in the yz plane +zs = np.linspace(start, stop, 200) +m_ys = np.sin(zs) +m_xs = np.zeros(200) +magnetic = np.column_stack([m_xs, m_ys, zs]) + +# add the lines +figure[0, 0].add_line(electric, colors="blue", thickness=2, name="e") +figure[0, 0].add_line(magnetic, colors="red", thickness=2, name="m") + +# draw vector line at every 10th position +electric_vectors = [np.array([[0, 0, z], [x, 0, z]]) for (x, z) in zip(e_xs[::10], zs[::10])] +magnetic_vectors = [np.array([[0, 0, z], [0, y, z]]) for (y, z) in zip(m_ys[::10], zs[::10])] + +# add as a line collection +figure[0, 0].add_line_collection(electric_vectors, colors="blue", thickness=1.5, name="e-vec") +figure[0, 0].add_line_collection(magnetic_vectors, colors="red", thickness=1.5, name="m-vec") +# note that the z_offset in `add_line_collection` is not data-related +# it is the z-offset for where to place the *graphic*, by default with Orthographic cameras (i.e. 2D views) +# it will increment by 1 for each line in the collection, we want to disable this so set z_position=0 + +# axes are a WIP, just draw a white line along z for now +z_axis = np.array([[0, 0, 0], [0, 0, stop]]) +figure[0, 0].add_line(z_axis, colors="w", thickness=1) + +# just a pre-saved camera state +state = { + 'position': np.array([-8.0 , 6.0, -2.0]), + 'rotation': np.array([0.09, 0.9 , 0.2, -0.5]), + 'scale': np.array([1., 1., 1.]), + 'reference_up': np.array([0., 1., 0.]), + 'fov': 50.0, + 'width': 12, + 'height': 12, + 'zoom': 1.35, + 'maintain_aspect': True, + 'depth_range': None +} + + +figure[0, 0].camera.set_state(state) + +figure.show() + +figure[0, 0].camera.zoom = 1.5 + +increment = np.pi * 4 / 100 + +figure.canvas.set_logical_size(700, 560) + +# moves the wave one step along the z-axis +def tick(subplot): + global increment, start, stop, zs + new_zs = np.linspace(start, stop, 200) + new_data = np.sin(new_zs) + + # just change the x-axis vals for the electric field + subplot["e"].data[:, 0] = new_data + # and y-axis vals for magnetic field + subplot["m"].data[:, 1] = new_data + + # update the vector lines + for i, (value, z) in enumerate(zip(new_data[::10], zs[::10])): + subplot["e-vec"].graphics[i].data = np.array([[0, 0, z], [value, 0, z]]) + subplot["m-vec"].graphics[i].data = np.array([[0, 0, z], [0, value, z]]) + + start += increment + stop += increment + + +figure[0, 0].add_animations(tick) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() \ No newline at end of file diff --git a/examples/desktop/misc/image_animation.py b/examples/desktop/misc/image_animation.py new file mode 100644 index 000000000..df84f3c5a --- /dev/null +++ b/examples/desktop/misc/image_animation.py @@ -0,0 +1,38 @@ +""" +Simple Image Update +=================== + +Example showing updating a single plot with new random 512x512 data. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import fastplotlib as fpl +import numpy as np + +data = np.random.rand(512, 512) + +figure = fpl.Figure() + +# plot the image data +image_graphic = figure[0, 0].add_image(data=data, name="random-image") + + +# a function to update the image_graphic +# a figure-level animation function will optionally take the figure as an argument +def update_data(figure_instance): + new_data = np.random.rand(512, 512) + figure_instance[0, 0]["random-image"].data = new_data + +figure.add_animations(update_data) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/misc/line3d_animation.py b/examples/desktop/misc/line3d_animation.py new file mode 100644 index 000000000..27d22c78a --- /dev/null +++ b/examples/desktop/misc/line3d_animation.py @@ -0,0 +1,59 @@ +""" +Simple 3D Line Animation +======================== + +Example showing animation with 3D lines. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 5s' + +import numpy as np +import fastplotlib as fpl + +# create data in the shape of a spiral +phi = np.linspace(0, 30, 200) + +xs = phi * np.cos(phi) +ys = phi * np.sin(phi) +zs = phi + +# make data 3d, with shape [, 3] +spiral = np.dstack([xs, ys, zs])[0] + +figure = fpl.Figure(cameras="3d") + +line_graphic = figure[0,0].add_line(data=spiral, thickness=3, cmap='jet') + +marker = figure[0,0].add_scatter(data=spiral[0], sizes=10, name="marker") + +marker_index = 0 + + +# a function to move the ball along the spiral +def move_marker(): + global marker_index + + marker_index += 1 + + if marker_index == spiral.shape[0]: + marker_index = 0 + + for subplot in figure: + subplot["marker"].data = spiral[marker_index] + + +# add `move_marker` to the animations +figure.add_animations(move_marker) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0,0].auto_scale(maintain_aspect=False) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/misc/line_animation.py b/examples/desktop/misc/line_animation.py new file mode 100644 index 000000000..50faad5c7 --- /dev/null +++ b/examples/desktop/misc/line_animation.py @@ -0,0 +1,53 @@ +""" +Simple Line Animation +===================== + +Example showing animation with lines. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import fastplotlib as fpl +import numpy as np + +# generate some data +start, stop = 0, 2 * np.pi +increment = (2 * np.pi) / 50 + +# make a simple sine wave +xs = np.linspace(start, stop, 100) +ys = np.sin(xs) + +figure = fpl.Figure() + +# plot the image data +sine = figure[0, 0].add_line(ys, name="sine", colors="r") + + +# increment along the x-axis on each render loop :D +def update_line(subplot): + global increment, start, stop + xs = np.linspace(start + increment, stop + increment, 100) + ys = np.sin(xs) + + start += increment + stop += increment + + # change only the y-axis values of the line + subplot["sine"].data[:, 1] = ys + + +figure[0, 0].add_animations(update_line) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0,0].auto_scale(maintain_aspect=False) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() \ No newline at end of file diff --git a/examples/desktop/misc/multiplot_animation.py b/examples/desktop/misc/multiplot_animation.py new file mode 100644 index 000000000..a712ce9ef --- /dev/null +++ b/examples/desktop/misc/multiplot_animation.py @@ -0,0 +1,49 @@ +""" +Multi-Subplot Image Update +========================== + +Example showing updating a single plot with new random 512x512 data. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import fastplotlib as fpl +import numpy as np + +# Figure of shape 2 x 3 with all controllers synced +figure = fpl.Figure(shape=(2, 3), controller_ids="sync") + +# Make a random image graphic for each subplot +for subplot in figure: + # create image data + data = np.random.rand(512, 512) + # add an image to the subplot + subplot.add_image(data, name="rand-img") + +figure[0,1]["rand-img"].cmap = "viridis" +figure[1,0]["rand-img"].cmap = "Wistia" +figure[0,2]["rand-img"].cmap = "gray" +figure[1,1]["rand-img"].cmap = "spring" + +# Define a function to update the image graphics with new data +# add_animations will pass the gridplot to the animation function +def update_data(f): + for subplot in f: + new_data = np.random.rand(512, 512) + # index the image graphic by name and set the data + subplot["rand-img"].data = new_data + +# add the animation function +figure.add_animations(update_data) + +# show the gridplot +figure.show() + +figure.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() \ No newline at end of file diff --git a/examples/desktop/misc/scatter_animation.py b/examples/desktop/misc/scatter_animation.py new file mode 100644 index 000000000..aa1495dd9 --- /dev/null +++ b/examples/desktop/misc/scatter_animation.py @@ -0,0 +1,59 @@ +""" +Scatter Animation Data +====================== + +Example showing animation with a scatter plot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + +import fastplotlib as fpl +import numpy as np + +# create a random distribution of 10,000 xyz coordinates +n_points = 10_000 + +# dimensions always have to be [n_points, xyz] +dims = (n_points, 3) + +clouds_offset = 15 + +# create some random clouds +normal = np.random.normal(size=dims, scale=5) +# stack the data into a single array +cloud = np.vstack( + [ + normal - clouds_offset, + normal, + normal + clouds_offset, + ] +) + +# color each of them separately +colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points + +# create plot +figure = fpl.Figure() +subplot_scatter = figure[0, 0] +# use an alpha value since this will be a lot of points +scatter_graphic = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) + + +def update_points(subplot): + # move every point by a small amount + deltas = np.random.normal(size=scatter_graphic.data.value.shape, loc=0, scale=0.15) + scatter_graphic.data = scatter_graphic.data.value + deltas + + +subplot_scatter.add_animations(update_points) + +figure.show() + +subplot_scatter.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() \ No newline at end of file diff --git a/examples/desktop/misc/simple_event.py b/examples/desktop/misc/simple_event.py new file mode 100644 index 000000000..b6d408862 --- /dev/null +++ b/examples/desktop/misc/simple_event.py @@ -0,0 +1,56 @@ +""" +Simple Event +============ + +Example showing how to add a simple callback event. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import imageio.v3 as iio + +data = iio.imread("imageio:camera.png") + +# Create a figure +figure = fpl.Figure() + +# plot sine wave, use a single color +image_graphic = figure[0,0].add_image(data=data) + +# show the plot +figure.show() + + +# define callback function to print the event data +def callback_func(event_data): + print(event_data.info) + + +# Will print event data when the color changes +image_graphic.add_event_handler(callback_func, "cmap") + +image_graphic.cmap = "viridis" + + +# adding a click event, we can also use decorators to add event handlers +@image_graphic.add_event_handler("click") +def click_event(event_data): + # get the click location in screen coordinates + xy = (event_data.x, event_data.y) + + # map the screen coordinates to world coordinates + xy = figure[0,0].map_screen_to_world(xy)[:-1] + + # print the click location + print(xy) + + +figure.canvas.set_logical_size(700, 560) + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/scatter/README.rst b/examples/desktop/scatter/README.rst new file mode 100644 index 000000000..278170fb4 --- /dev/null +++ b/examples/desktop/scatter/README.rst @@ -0,0 +1,2 @@ +Scatter Examples +================ diff --git a/examples/desktop/scatter/__init__.py b/examples/desktop/scatter/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/desktop/scatter/scatter.py b/examples/desktop/scatter/scatter.py index 778f37deb..05dd7a99b 100644 --- a/examples/desktop/scatter/scatter.py +++ b/examples/desktop/scatter/scatter.py @@ -1,34 +1,54 @@ """ Scatter Plot ============ + Example showing scatter plot. """ -# test_example = true +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -from pathlib import Path -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() + +# create a random distribution of 10,000 xyz coordinates +n_points = 5_000 + +# dimensions always have to be [n_points, xyz] +dims = (n_points, 3) + +clouds_offset = 15 -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) +# create some random clouds +normal = np.random.normal(size=dims, scale=5) +# stack the data into a single array +cloud = np.vstack( + [ + normal - clouds_offset, + normal, + normal + clouds_offset, + ] +) -n_points = 50 +# color each of them separately colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points -scatter_graphic = plot.add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) +# create plot +figure = fpl.Figure() -plot.show() +# use an alpha value since this will be a lot of points +figure[0,0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/scatter/scatter_cmap.py b/examples/desktop/scatter/scatter_cmap.py index edc55a4b1..0adf72509 100644 --- a/examples/desktop/scatter/scatter_cmap.py +++ b/examples/desktop/scatter/scatter_cmap.py @@ -1,47 +1,53 @@ """ -Scatter Plot -============ +Scatter Colormap +================ + Example showing cmap change for scatter plot. """ -# test_example = true +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -from pathlib import Path -from sklearn.cluster import AgglomerativeClustering - - -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) +figure = fpl.Figure() +# create a random distribution of 10,000 xyz coordinates +n_points = 5_000 -agg = AgglomerativeClustering(n_clusters=3) +# dimensions always have to be [n_points, xyz] +dims = (n_points, 3) -agg.fit_predict(data) +clouds_offset = 15 - -scatter_graphic = plot.add_scatter( - data=data[:, :-1], - sizes=15, - alpha=0.7, - cmap="Set1", - cmap_values=agg.labels_ +# create some random clouds +normal = np.random.normal(size=dims, scale=5) +# stack the data into a single array +cloud = np.vstack( + [ + normal - clouds_offset, + normal, + normal + clouds_offset, + ] ) -plot.show() +# color each of them separately +colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points + +# use an alpha value since this will be a lot of points +figure[0,0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure[0,0].graphics[0].cmap = "viridis" -scatter_graphic.cmap = "tab10" +figure.canvas.set_logical_size(700, 560) +figure[0, 0].auto_scale() +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/scatter/scatter_cmap_iris.py b/examples/desktop/scatter/scatter_cmap_iris.py new file mode 100644 index 000000000..700f5c136 --- /dev/null +++ b/examples/desktop/scatter/scatter_cmap_iris.py @@ -0,0 +1,42 @@ +""" +Iris Scatter Colormap +===================== + +Example showing cmap change for scatter plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +from sklearn.cluster import AgglomerativeClustering +from sklearn import datasets + + +figure = fpl.Figure() + +data = datasets.load_iris()["data"] + +agg = AgglomerativeClustering(n_clusters=3) +agg.fit_predict(data) + +scatter_graphic = figure[0, 0].add_scatter( + data=data[:, :-1], # use only xy data + sizes=15, + alpha=0.7, + cmap="Set1", + cmap_transform=agg.labels_ # use the labels as a transform to map colors from the colormap +) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0, 0].auto_scale() + +scatter_graphic.cmap = "tab10" + + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/scatter/scatter_colorslice.py b/examples/desktop/scatter/scatter_colorslice.py index d752cacbd..3d3a3fa26 100644 --- a/examples/desktop/scatter/scatter_colorslice.py +++ b/examples/desktop/scatter/scatter_colorslice.py @@ -1,39 +1,60 @@ """ -Scatter Plot -============ +Scatter Plot Color Slicing +========================== + Example showing color slice for scatter plot. """ -# test_example = true +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -from pathlib import Path +figure = fpl.Figure() + +# create a random distribution of 10,000 xyz coordinates +n_points = 5_000 + +# dimensions always have to be [n_points, xyz] +dims = (n_points, 3) -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +clouds_offset = 30 -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) +# create some random clouds +normal = np.random.normal(size=dims, scale=5) +# stack the data into a single array +cloud = np.vstack( + [ + normal - clouds_offset, + normal, + normal + clouds_offset, + ] +) -n_points = 50 +# color each of them separately colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points -scatter_graphic = plot.add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) +# create plot +figure = fpl.Figure() -plot.show() +# use an alpha value since this will be a lot of points +figure[0,0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) -plot.canvas.set_logical_size(800, 800) +figure.show() -plot.auto_scale() +figure.canvas.set_logical_size(700, 560) + +scatter_graphic = figure[0, 0].graphics[0] + +figure[0, 0].auto_scale() scatter_graphic.colors[0:75] = "red" scatter_graphic.colors[75:150] = "white" scatter_graphic.colors[::2] = "blue" - +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/scatter/scatter_colorslice_iris.py b/examples/desktop/scatter/scatter_colorslice_iris.py new file mode 100644 index 000000000..a1e6d5318 --- /dev/null +++ b/examples/desktop/scatter/scatter_colorslice_iris.py @@ -0,0 +1,42 @@ +""" +Iris Scatter Plot Color Slicing +=============================== + +Example showing color slice for scatter plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +from sklearn import datasets + + +figure = fpl.Figure() + +data = datasets.load_iris()["data"] + +n_points = 50 +colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points + +scatter_graphic = figure[0, 0].add_scatter( + data=data[:, :-1], + sizes=6, + alpha=0.7, + colors=colors # use colors from the list of strings +) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0, 0].auto_scale() + +scatter_graphic.colors[0:75] = "red" +scatter_graphic.colors[75:150] = "white" +scatter_graphic.colors[::2] = "blue" + + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/scatter/scatter_dataslice.py b/examples/desktop/scatter/scatter_dataslice.py index 22c495bff..af2fffebd 100644 --- a/examples/desktop/scatter/scatter_dataslice.py +++ b/examples/desktop/scatter/scatter_dataslice.py @@ -1,42 +1,46 @@ """ -Scatter Plot -============ +Scatter Plot Data Slicing +========================= + Example showing data slice for scatter plot. """ -# test_example = true +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -from pathlib import Path -plot = fpl.Plot() -# to force a specific framework such as glfw: -# plot = fpl.Plot(canvas="glfw") +figure = fpl.Figure() -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) +# create a gaussian cloud of 5_000 points +n_points = 1_000 -n_points = 50 -colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points +mean = [0, 0] # mean of the Gaussian distribution +covariance = [[1, 0], [0, 1]] # covariance matrix -scatter_graphic = plot.add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) +gaussian_cloud = np.random.multivariate_normal(mean, covariance, n_points) +gaussian_cloud2 = np.random.multivariate_normal(mean, covariance, n_points) -plot.show() +# create plot +figure = fpl.Figure() -plot.canvas.set_logical_size(800, 800) +# use an alpha value since this will be a lot of points +scatter1 = figure[0,0].add_scatter(data=gaussian_cloud, sizes=3) +scatter2 = figure[0,0].add_scatter(data=gaussian_cloud2, colors="r", sizes=3) -plot.auto_scale() +figure.show() -scatter_graphic.data[0] = np.array([[5, 3, 1.5]]) -scatter_graphic.data[1] = np.array([[4.3, 3.2, 1.3]]) -scatter_graphic.data[2] = np.array([[5.2, 2.7, 1.7]]) +figure.canvas.set_logical_size(700, 560) -scatter_graphic.data[10:15] = scatter_graphic.data[0:5] + np.array([1, 1, 1]) -scatter_graphic.data[50:100:2] = scatter_graphic.data[100:150:2] + np.array([1,1,0]) +figure[0, 0].auto_scale() +scatter1.data[:500] = np.array([0 , 0, 0]) +scatter2.data[500:] = np.array([0 , 0, 0]) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/scatter/scatter_dataslice_iris.py b/examples/desktop/scatter/scatter_dataslice_iris.py new file mode 100644 index 000000000..0d47c6efd --- /dev/null +++ b/examples/desktop/scatter/scatter_dataslice_iris.py @@ -0,0 +1,41 @@ +""" +Iris Scatter Plot Data Slicing +============================== + +Example showing data slice for scatter plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +from sklearn import datasets + + +figure = fpl.Figure() + +data = datasets.load_iris()["data"] + +n_points = 50 +colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points + +scatter_graphic = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0, 0].auto_scale() + +scatter_graphic.data[0] = np.array([[5, 3, 1.5]]) +scatter_graphic.data[1] = np.array([[4.3, 3.2, 1.3]]) +scatter_graphic.data[2] = np.array([[5.2, 2.7, 1.7]]) + +scatter_graphic.data[10:15] = scatter_graphic.data[0:5] + np.array([1, 1, 1]) +scatter_graphic.data[50:100:2] = scatter_graphic.data[100:150:2] + np.array([1, 1, 0]) + + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/scatter/scatter_iris.py b/examples/desktop/scatter/scatter_iris.py new file mode 100644 index 000000000..c16a4b135 --- /dev/null +++ b/examples/desktop/scatter/scatter_iris.py @@ -0,0 +1,38 @@ +""" +Iris Scatter Plot +================= + +Example showing scatter plot using sklearn iris dataset. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import numpy as np +from pathlib import Path +import sys + +figure = fpl.Figure() + +current_file = Path(sys.argv[0]).resolve() + +data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") +data = np.load(data_path) + +n_points = 50 +colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points + +scatter_graphic = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) + +figure.show() + +figure.canvas.set_logical_size(700, 560) + +figure[0, 0].auto_scale() + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.run() \ No newline at end of file diff --git a/examples/desktop/scatter/scatter_present.py b/examples/desktop/scatter/scatter_present.py deleted file mode 100644 index ad4be837f..000000000 --- a/examples/desktop/scatter/scatter_present.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Scatter Plot -============ -Example showing present feature for scatter plot. -""" - -# test_example = true - -import fastplotlib as fpl -import numpy as np -from pathlib import Path - - -plot = fpl.Plot() - -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) - -n_points = 50 -colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points - -scatter_graphic = plot.add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) - -colors = ["red"] * n_points + ["white"] * n_points + ["blue"] * n_points -scatter_graphic2 = plot.add_scatter(data=data[:, 1:], sizes=6, alpha=0.7, colors=colors) - -plot.show() - -plot.canvas.set_logical_size(800, 800) - -plot.auto_scale() - -scatter_graphic.present = False - - -if __name__ == "__main__": - print(__doc__) - fpl.run() diff --git a/examples/desktop/scatter/scatter_size.py b/examples/desktop/scatter/scatter_size.py index 2ad995584..bd4e2db2b 100644 --- a/examples/desktop/scatter/scatter_size.py +++ b/examples/desktop/scatter/scatter_size.py @@ -1,46 +1,48 @@ """ -Scatter Plot -============ +Scatter Plot Size +================= + Example showing point size change for scatter plot. """ # test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + import numpy as np import fastplotlib as fpl -# grid with 2 rows and 3 columns -grid_shape = (2, 1) +# figure with 2 rows and 3 columns +shape = (2, 1) # you can give string names for each subplot within the gridplot -names = [ - ["scalar_size"], - ["array_size"] -] +names = [["scalar_size"], ["array_size"]] # Create the grid plot -plot = fpl.GridPlot( - shape=grid_shape, - names=names, - size=(1000, 1000) -) +figure = fpl.Figure(shape=shape, names=names, size=(1000, 1000)) # get y_values using sin function -angles = np.arange(0, 20*np.pi+0.001, np.pi / 20) -y_values = 30*np.sin(angles) # 1 thousand points +angles = np.arange(0, 20 * np.pi + 0.001, np.pi / 20) +y_values = 30 * np.sin(angles) # 1 thousand points x_values = np.array([x for x in range(len(y_values))], dtype=np.float32) data = np.column_stack([x_values, y_values]) -plot["scalar_size"].add_scatter(data=data, sizes=5, colors="blue") # add a set of scalar sizes +figure["scalar_size"].add_scatter( + data=data, sizes=5, colors="blue" +) # add a set of scalar sizes -non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5 -plot["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red") +non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5 +figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red") -for graph in plot: +for graph in figure: graph.auto_scale(maintain_aspect=True) -plot.show() +figure.show() + +figure.canvas.set_logical_size(700, 560) +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter if __name__ == "__main__": print(__doc__) fpl.run() diff --git a/examples/desktop/screenshots/gridplot.png b/examples/desktop/screenshots/gridplot.png index bc35ccf8c..315958673 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:e416fc968edd3788513e369f4d265b0abd7216a7ef19ec8b84659c30ca7c8ca1 -size 307384 +oid sha256:d43e6972bf76aa2de400616bde4275cd05d3a945475742ec7f63f7658628292b +size 264437 diff --git a/examples/desktop/screenshots/gridplot_non_square.png b/examples/desktop/screenshots/gridplot_non_square.png index 82b2b0eb4..689585b40 100644 --- a/examples/desktop/screenshots/gridplot_non_square.png +++ b/examples/desktop/screenshots/gridplot_non_square.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab5598b67b80efce0d2559e400e77098e734c91608a3f49b691ddaa030d47edb -size 203434 +oid sha256:703285790dc96500a7a376f6e78953c943643f4ecf3102182072c2bd0bf8190c +size 173753 diff --git a/examples/desktop/screenshots/heatmap.png b/examples/desktop/screenshots/heatmap.png index a0655cf3a..0514daf94 100644 --- a/examples/desktop/screenshots/heatmap.png +++ b/examples/desktop/screenshots/heatmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6872c3cc3e35ec918b054fb2d76525bbd3d82d8b49916aca1046aa1be65ff923 -size 111825 +oid sha256:03b3ab1fc8aa602eb94beed1f5fa5712452ee802bb3230c4fd066d073bdd4ad2 +size 40100 diff --git a/examples/desktop/screenshots/heatmap_cmap.png b/examples/desktop/screenshots/heatmap_cmap.png deleted file mode 100644 index 2eb769c14..000000000 --- a/examples/desktop/screenshots/heatmap_cmap.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f2eba96c2bfb1d07365810a69e99c79b068741f5dcf74fc745c13d5ff21f16f2 -size 106671 diff --git a/examples/desktop/screenshots/heatmap_data.png b/examples/desktop/screenshots/heatmap_data.png deleted file mode 100644 index 50a8ae79e..000000000 --- a/examples/desktop/screenshots/heatmap_data.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f0576063658e05e19b7723b4c88dc4d55a8178b090b4a88e33251fc92408b4a1 -size 18051 diff --git a/examples/desktop/screenshots/heatmap_vmin_vmax.png b/examples/desktop/screenshots/heatmap_vmin_vmax.png deleted file mode 100644 index f10382e87..000000000 --- a/examples/desktop/screenshots/heatmap_vmin_vmax.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:639d50f2f5fb07dba461e7a38de3886092f8754277eadbb5e305e32023289abd -size 124403 diff --git a/examples/desktop/screenshots/image_cmap.png b/examples/desktop/screenshots/image_cmap.png index bed07a41a..91124db6a 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:e1d78cc0681079a5c43d9fdb4142f5fee75d477d9f9a1469fca8bc8933c244fc -size 216210 +oid sha256:f18a55da8cede25dbb77b18e8cf374d158a66b823d029714983218e55ee68249 +size 187688 diff --git a/examples/desktop/screenshots/image_rgb.png b/examples/desktop/screenshots/image_rgb.png index a21c0658b..8ae39eaad 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:820a73b9b2e5bbaed84fb11438e2c5672b76c8b57a33823f4840a71be03d7dd1 -size 251438 +oid sha256:3851bea9ee908a460750b40a0a5709aff1b28afa6adf11c9ad2ed8239958caa4 +size 216343 diff --git a/examples/desktop/screenshots/image_rgbvminvmax.png b/examples/desktop/screenshots/image_rgbvminvmax.png index 88acfadc5..478ce40fe 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:5f2f562573f8104342ae01b5852f71c960341bdd70ae0bc7967df663166edbd3 -size 39604 +oid sha256:2ec8ddd362197ba802f8381d5baea226dc30689eee5e5dc744c2da710f0b3482 +size 33860 diff --git a/examples/desktop/screenshots/image_simple.png b/examples/desktop/screenshots/image_simple.png index 098d5a055..c60293498 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:a4ed42d042d8bb7e35f31b5ad0a3e3a495cf9c3164516eb457d8b41d7fae6bab -size 213075 +oid sha256:216791f48cee8ddb9979ecc8b7b7435c0fe22c2734148c25314f1827a5c9ad66 +size 187868 diff --git a/examples/desktop/screenshots/image_small.png b/examples/desktop/screenshots/image_small.png new file mode 100644 index 000000000..cda3a2584 --- /dev/null +++ b/examples/desktop/screenshots/image_small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f2af0ed16ec82842ad9d45d5a8b6189e77a2f2f8adb21dd82bc1636979cd2c7 +size 2325 diff --git a/examples/desktop/screenshots/image_vminvmax.png b/examples/desktop/screenshots/image_vminvmax.png index 88acfadc5..478ce40fe 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:5f2f562573f8104342ae01b5852f71c960341bdd70ae0bc7967df663166edbd3 -size 39604 +oid sha256:2ec8ddd362197ba802f8381d5baea226dc30689eee5e5dc744c2da710f0b3482 +size 33860 diff --git a/examples/desktop/screenshots/line.png b/examples/desktop/screenshots/line.png index cbc0a7b21..605540225 100644 --- a/examples/desktop/screenshots/line.png +++ b/examples/desktop/screenshots/line.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:018f4a36c60b3de2f1406aa2823b751de5fae2c2340f9d49368d007ba7379637 -size 44422 +oid sha256:d7f3736d4464cfd942e87d21be1a18d09f5d0d239a7e1c7679e918dcc5c9331c +size 26701 diff --git a/examples/desktop/screenshots/line_cmap.png b/examples/desktop/screenshots/line_cmap.png index a07b15d36..cab91220f 100644 --- a/examples/desktop/screenshots/line_cmap.png +++ b/examples/desktop/screenshots/line_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c6df49f5f900caa217a5a0b01f601d6e83e1ee5c3a60c8968b9be837df785905 -size 43673 +oid sha256:1f154346cffbaa0957a9986d8b7beef417b66ef0cec7dbed3c20780d91425567 +size 29231 diff --git a/examples/desktop/screenshots/line_collection.png b/examples/desktop/screenshots/line_collection.png index 60ec82bc8..f3fb5052b 100644 --- a/examples/desktop/screenshots/line_collection.png +++ b/examples/desktop/screenshots/line_collection.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e0ddd75bc2fd8e4844ccb46c53fe463dc604927737ed00c8fb6b1c29fd2b1ab2 -size 146797 +oid sha256:ca08ce57a1cf57c334add1c41351f3b823f06ad8da463017d0815cf7cfea03b3 +size 91085 diff --git a/examples/desktop/screenshots/line_collection_cmap_values.png b/examples/desktop/screenshots/line_collection_cmap_values.png index 7223db9ee..33af5b917 100644 --- a/examples/desktop/screenshots/line_collection_cmap_values.png +++ b/examples/desktop/screenshots/line_collection_cmap_values.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:311c7ef6f6f46e32983a5a531adc179b0179f382300f3cafbb6d8a7d4aeab565 -size 93676 +oid sha256:12ddca084dc83478c6b3d263f11f456f8b81e7a8a291d6b9024dbcecbfb049c0 +size 57107 diff --git a/examples/desktop/screenshots/line_collection_cmap_values_qualitative.png b/examples/desktop/screenshots/line_collection_cmap_values_qualitative.png index 500de82bb..57f45605b 100644 --- a/examples/desktop/screenshots/line_collection_cmap_values_qualitative.png +++ b/examples/desktop/screenshots/line_collection_cmap_values_qualitative.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8714dd43aa3d5c791fb9359e745895447d3a3234e3e8598d171ea3666a3fd7a3 -size 95660 +oid sha256:74d5999cdd0b992f73bafb1bd74c318fd9cf058aed232068ab7dcb76d86df556 +size 60881 diff --git a/examples/desktop/screenshots/line_collection_colors.png b/examples/desktop/screenshots/line_collection_colors.png index f60faae32..9c27854ed 100644 --- a/examples/desktop/screenshots/line_collection_colors.png +++ b/examples/desktop/screenshots/line_collection_colors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2fad15985608a4c6b1fda3005dc89950b4cad5fed956f5d26672257385985d0 -size 82753 +oid sha256:a152331c51ed5440c5faf2a59439d90832521fbb1498d9635ddae088219ca353 +size 46941 diff --git a/examples/desktop/screenshots/line_collection_slicing.png b/examples/desktop/screenshots/line_collection_slicing.png new file mode 100644 index 000000000..1145e84dc --- /dev/null +++ b/examples/desktop/screenshots/line_collection_slicing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdfdc2b2c5799e814ef5a1e32748a2a6d2dd88005f6fa0d9c456b8dadfada5db +size 124609 diff --git a/examples/desktop/screenshots/line_colorslice.png b/examples/desktop/screenshots/line_colorslice.png index 7b652f165..825ce8e3f 100644 --- a/examples/desktop/screenshots/line_colorslice.png +++ b/examples/desktop/screenshots/line_colorslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5bd80cee80f491da6ab64c868a3c70254a68072e0bc0caad80c7999cadcb2df9 -size 50497 +oid sha256:de5a56c96a062ed0ec154ae21f3a3a67087e0c8aef6d8e4681c67a016424144a +size 31971 diff --git a/examples/desktop/screenshots/line_dataslice.png b/examples/desktop/screenshots/line_dataslice.png index d68a554bd..71c3d1918 100644 --- a/examples/desktop/screenshots/line_dataslice.png +++ b/examples/desktop/screenshots/line_dataslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:823651905a775b5cbcc2ce0f79d25d69b29f17b4c060c244d80ae87019f05d5b -size 69332 +oid sha256:e4dece6f721068a1ae37c6830110f97df64ea57c467ef4d7f42b73575d2ee476 +size 43995 diff --git a/examples/desktop/screenshots/line_present_scaling.png b/examples/desktop/screenshots/line_present_scaling.png deleted file mode 100644 index 02cd2b1f8..000000000 --- a/examples/desktop/screenshots/line_present_scaling.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3d18669d5e75cee3326d0380ae5dd26cab71ea97725ff99bc5228d2555d51454 -size 30373 diff --git a/examples/desktop/screenshots/line_stack.png b/examples/desktop/screenshots/line_stack.png index 443184247..026b1f61e 100644 --- a/examples/desktop/screenshots/line_stack.png +++ b/examples/desktop/screenshots/line_stack.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29580e5ebb0597d54adb2b7f2f91de44e4480cf23c0c271ee44426203b4c1c44 -size 360892 +oid sha256:1384f1030e81fc05b24db040ac47a3bd62663358dcbdd0e77b3d675d5edd4357 +size 86938 diff --git a/examples/desktop/screenshots/scatter.png b/examples/desktop/screenshots/scatter.png deleted file mode 100644 index bf5e8c92a..000000000 --- a/examples/desktop/screenshots/scatter.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd38399b77e09d915c5bb1e7ee022f936ae90682f598357bc774a95c372dc78f -size 25231 diff --git a/examples/desktop/screenshots/scatter_cmap.png b/examples/desktop/screenshots/scatter_cmap.png deleted file mode 100644 index eec22566a..000000000 --- a/examples/desktop/screenshots/scatter_cmap.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e712693b403166909dcaa65256131eacba0a15892cd144ad97fdecb6b9835e93 -size 57273 diff --git a/examples/desktop/screenshots/scatter_cmap_iris.png b/examples/desktop/screenshots/scatter_cmap_iris.png new file mode 100644 index 000000000..2a6ae7016 --- /dev/null +++ b/examples/desktop/screenshots/scatter_cmap_iris.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b781b387476028a5eaf2083c40d57306afbcbc2a6754dce6fb66cf71ddd689d1 +size 31719 diff --git a/examples/desktop/screenshots/scatter_colorslice.png b/examples/desktop/screenshots/scatter_colorslice.png deleted file mode 100644 index 0da0fcd9f..000000000 --- a/examples/desktop/screenshots/scatter_colorslice.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c620cad9976f390e44a5b037f3ff61fb80e6487e17f4be8118be5df55f276a35 -size 23664 diff --git a/examples/desktop/screenshots/scatter_colorslice_iris.png b/examples/desktop/screenshots/scatter_colorslice_iris.png new file mode 100644 index 000000000..45c5d940c --- /dev/null +++ b/examples/desktop/screenshots/scatter_colorslice_iris.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68f93c08d361232c9be2220a68db8659c9c3c81c3cdb4e1a1ce9b366fb28b4f5 +size 13215 diff --git a/examples/desktop/screenshots/scatter_dataslice.png b/examples/desktop/screenshots/scatter_dataslice.png deleted file mode 100644 index 32f56ad11..000000000 --- a/examples/desktop/screenshots/scatter_dataslice.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:69d2f0999b0bb334e48320702095fc76444f4d89d43a51ac6c5c8f49e1df96ac -size 25999 diff --git a/examples/desktop/screenshots/scatter_dataslice_iris.png b/examples/desktop/screenshots/scatter_dataslice_iris.png new file mode 100644 index 000000000..1121d032c --- /dev/null +++ b/examples/desktop/screenshots/scatter_dataslice_iris.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d662e151062a136a17dac1f8693ba13f41daac05e91e32ee9c7053715f9ee17 +size 14437 diff --git a/examples/desktop/screenshots/scatter_iris.png b/examples/desktop/screenshots/scatter_iris.png new file mode 100644 index 000000000..7d107d964 --- /dev/null +++ b/examples/desktop/screenshots/scatter_iris.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fc88e52cc4ede6d1453746461da645f8b3df0a3099155caf639768a5ad4424c +size 14148 diff --git a/examples/desktop/screenshots/scatter_present.png b/examples/desktop/screenshots/scatter_present.png deleted file mode 100644 index 8c1e5eed4..000000000 --- a/examples/desktop/screenshots/scatter_present.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e45c1a936771e569e562ed3496421e498e725325093e84243ab494c0718ead3a -size 23639 diff --git a/examples/desktop/screenshots/scatter_size.png b/examples/desktop/screenshots/scatter_size.png index da211cde1..66b31cab9 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:10533aa5831a50a0f9b38c0a60b89a9b6c33311ecb3a569c5e0b4c82379dc20a -size 66037 +oid sha256:9d1eeb96dc1f52c4d48889a8b00387387cccb7b83d479c1c4b47789b281a1cd5 +size 34222 diff --git a/examples/notebooks/heatmap.ipynb b/examples/notebooks/heatmap.ipynb index 82583b1df..7de3af2a0 100644 --- a/examples/notebooks/heatmap.ipynb +++ b/examples/notebooks/heatmap.ipynb @@ -5,9 +5,7 @@ "id": "d8c90f4b-b635-4027-b7d5-080d77bd40a3", "metadata": {}, "source": [ - "# The `HeatmapGraphic` is useful for looking at very large arrays\n", - "\n", - "`ImageGraphic` is limited to a max size of `8192 x 8192`" + "# Looking at very large arrays" ] }, { @@ -40,13 +38,11 @@ }, "outputs": [], "source": [ - "xs = np.linspace(0, 50, 10_000)\n", - "\n", - "sine_data = np.sin(xs)\n", + "xs = np.linspace(0, 1_000, 20_000)\n", "\n", - "cosine_data = np.cos(xs)\n", + "sine = np.sin(np.sqrt(xs))\n", "\n", - "data = np.vstack([(sine_data, cosine_data) for i in range(5)])" + "data = np.vstack([sine * i for i in range(10_000)])" ] }, { @@ -70,11 +66,11 @@ }, "outputs": [], "source": [ - "plot = fpl.Plot()\n", + "fig = fpl.Figure()\n", "\n", - "plot.add_heatmap(data, cmap=\"viridis\")\n", + "fig[0, 0].add_image(data, cmap=\"viridis\")\n", "\n", - "plot.show(maintain_aspect=False)" + "fig.show(maintain_aspect=False)" ] }, { diff --git a/examples/notebooks/image_widget.ipynb b/examples/notebooks/image_widget.ipynb index 56d5c8a81..5136ba028 100644 --- a/examples/notebooks/image_widget.ipynb +++ b/examples/notebooks/image_widget.ipynb @@ -78,7 +78,7 @@ }, "outputs": [], "source": [ - "iw.gridplot[0, 0][\"image_widget_managed\"].cmap = \"gnuplot2\"" + "iw.figure[0, 0][\"image_widget_managed\"].cmap = \"gnuplot2\"" ] }, { @@ -115,7 +115,6 @@ "source": [ "iw_movie = ImageWidget(\n", " data=gray_movie, \n", - " slider_dims=[\"t\"],\n", " cmap=\"gray\"\n", ")" ] @@ -232,7 +231,7 @@ "outputs": [], "source": [ "iw_movie.set_data(new_data=new_data)\n", - "iw_movie.gridplot[0, 0].auto_scale()# sidecar is optional" + "iw_movie.figure[0, 0].auto_scale()# sidecar is optional" ] }, { @@ -341,7 +340,7 @@ }, "outputs": [], "source": [ - "iw_zfish.gridplot[\"plane-2\"]" + "iw_zfish.figure[\"plane-2\"]" ] }, { diff --git a/examples/notebooks/image_widget_test.ipynb b/examples/notebooks/image_widget_test.ipynb index 90747757c..321f7b84f 100644 --- a/examples/notebooks/image_widget_test.ipynb +++ b/examples/notebooks/image_widget_test.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "07019035-83f2-4753-9e7c-628ae439b441", "metadata": { "tags": [] @@ -18,12 +18,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "10b8ab40-944d-472c-9b7e-cae8a129e7ce", "metadata": {}, "outputs": [], "source": [ - "from nb_test_utils import plot_test, notebook_finished " + "from nb_test_utils import plot_test, notebook_finished\n", + "import nb_test_utils\n", + "nb_test_utils.TOLERANCE = 0.035" ] }, { @@ -57,7 +59,8 @@ "source": [ "iw = ImageWidget(\n", " data=a,\n", - " cmap=\"viridis\"\n", + " cmap=\"viridis\",\n", + " figure_kwargs={\"size\": (900, 600)},\n", ")" ] }, @@ -78,9 +81,9 @@ "metadata": {}, "outputs": [], "source": [ - "plot_test(\"image-widget-single\", iw.gridplot)\n", - "iw.gridplot[0, 0][\"image_widget_managed\"].cmap = \"gnuplot2\"\n", - "plot_test(\"image-widget-single-gnuplot2\", iw.gridplot)" + "plot_test(\"image-widget-single\", iw.figure)\n", + "iw.figure[0, 0][\"image_widget_managed\"].cmap = \"gnuplot2\"\n", + "plot_test(\"image-widget-single-gnuplot2\", iw.figure)" ] }, { @@ -127,8 +130,8 @@ "source": [ "iw_movie = ImageWidget(\n", " data=gray_movie, \n", - " slider_dims=[\"t\"],\n", - " cmap=\"gray\"\n", + " cmap=\"gray\",\n", + " figure_kwargs={\"size\": (900, 600)},\n", ")" ] }, @@ -154,23 +157,23 @@ "# testing cell ignore\n", "assert iw_movie.sliders[\"t\"].max == gray_movie.shape[0] - 1\n", "assert iw_movie.sliders[\"t\"].min == 0\n", - "plot_test(\"image-widget-movie-single-0\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-0\", iw_movie.figure)\n", "iw_movie.sliders[\"t\"].value = 50\n", - "plot_test(\"image-widget-movie-single-50\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-50\", iw_movie.figure)\n", "iw_movie.sliders[\"t\"].value = 279\n", - "plot_test(\"image-widget-movie-single-279\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-279\", iw_movie.figure)\n", "iw_movie.sliders[\"t\"].value = 0\n", - "plot_test(\"image-widget-movie-single-0-reset\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-0-reset\", iw_movie.figure)\n", "iw_movie.sliders[\"t\"].value = 50\n", "iw_movie.window_funcs = {\"t\": (np.mean, 13)}\n", "# testing cell ignore\n", - "plot_test(\"image-widget-movie-single-50-window-mean-13\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-50-window-mean-13\", iw_movie.figure)\n", "iw_movie.window_funcs[\"t\"].window_size = 33\n", - "plot_test(\"image-widget-movie-single-50-window-mean-33\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-50-window-mean-33\", iw_movie.figure)\n", "iw_movie.window_funcs[\"t\"].func = np.max\n", - "plot_test(\"image-widget-movie-single-50-window-max-33\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-50-window-max-33\", iw_movie.figure)\n", "iw_movie.window_funcs = None\n", - "plot_test(\"image-widget-movie-single-50-window-reset\", iw_movie.gridplot)\n", + "plot_test(\"image-widget-movie-single-50-window-reset\", iw_movie.figure)\n", "iw_movie.sliders[\"t\"].value = 0" ] }, @@ -202,7 +205,7 @@ "outputs": [], "source": [ "iw_movie.set_data(new_data=new_data)\n", - "iw_movie.gridplot[0, 0].auto_scale()" + "iw_movie.figure[0, 0].auto_scale()" ] }, { @@ -212,7 +215,7 @@ "metadata": {}, "outputs": [], "source": [ - "plot_test(\"image-widget-movie-set_data\", iw_movie.gridplot)" + "plot_test(\"image-widget-movie-set_data\", iw_movie.figure)" ] }, { @@ -280,6 +283,7 @@ " window_funcs={\"t\": (np.mean, 5)},\n", " names=[f\"plane-{i}\" for i in range(n_planes)],\n", " cmap=\"gnuplot2\", \n", + " figure_kwargs={\"size\": (900, 600)},\n", ")" ] }, @@ -305,25 +309,25 @@ "# testing cell ignore\n", "assert iw_zfish.sliders[\"t\"].max == zfish_data.shape[0] - 1\n", "assert iw_zfish.sliders[\"t\"].min == 0\n", - "plot_test(\"image-widget-zfish-grid-init-mean-window-5\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-init-mean-window-5\", iw_zfish.figure)\n", "iw_zfish.sliders[\"t\"].value = 50\n", - "plot_test(\"image-widget-zfish-grid-frame-50-mean-window-5\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-frame-50-mean-window-5\", iw_zfish.figure)\n", "iw_zfish.window_funcs[\"t\"].window_size = 13\n", - "plot_test(\"image-widget-zfish-grid-frame-50-mean-window-13\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-frame-50-mean-window-13\", iw_zfish.figure)\n", "iw_zfish.window_funcs = None\n", - "plot_test(\"image-widget-zfish-grid-frame-50\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-frame-50\", iw_zfish.figure)\n", "iw_zfish.sliders[\"t\"].value = 99\n", - "plot_test(\"image-widget-zfish-grid-frame-99\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-frame-99\", iw_zfish.figure)\n", "iw_zfish.sliders[\"t\"].value = 50\n", "iw_zfish.window_funcs = {\"t\": (np.max, 13)}\n", - "plot_test(\"image-widget-zfish-grid-frame-50-max-window-13\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-frame-50-max-window-13\", iw_zfish.figure)\n", "iw_zfish.window_funcs = None\n", "iw_zfish.frame_apply = lambda frame: gaussian_filter(frame.astype(np.float32), sigma=3)\n", "iw_zfish.reset_vmin_vmax()\n", - "plot_test(\"image-widget-zfish-grid-frame-50-frame-apply-gaussian\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-frame-50-frame-apply-gaussian\", iw_zfish.figure)\n", "iw_zfish.frame_apply = None\n", "iw_zfish.reset_vmin_vmax()\n", - "plot_test(\"image-widget-zfish-grid-frame-50-frame-apply-reset\", iw_zfish.gridplot)" + "plot_test(\"image-widget-zfish-grid-frame-50-frame-apply-reset\", iw_zfish.figure)" ] }, { @@ -339,13 +343,13 @@ " reset_indices=False\n", ")\n", "\n", - "plot_test(\"image-widget-zfish-grid-set_data-reset-indices-false\", iw_zfish.gridplot)\n", + "plot_test(\"image-widget-zfish-grid-set_data-reset-indices-false\", iw_zfish.figure)\n", "\n", "iw_zfish.set_data(\n", " [zfish_data[:, i] for i in range(n_planes - 1, -1, -1)],\n", " reset_indices=True\n", ")\n", - "plot_test(\"image-widget-zfish-grid-set_data-reset-indices-true\", iw_zfish.gridplot)" + "plot_test(\"image-widget-zfish-grid-set_data-reset-indices-true\", iw_zfish.figure)" ] }, { @@ -379,6 +383,7 @@ " data=zfish_data, # you can also provide a list of tzxy arrays\n", " window_funcs={\"t\": (np.mean, 5)},\n", " cmap=\"gnuplot2\", \n", + " figure_kwargs={\"size\": (900, 600)},\n", ")" ] }, @@ -401,28 +406,28 @@ "metadata": {}, "outputs": [], "source": [ - "# same tests as with the gridplot\n", + "# same tests as with the figure\n", "assert iw_z.sliders[\"t\"].max == zfish_data.shape[0] - 1\n", "assert iw_z.sliders[\"t\"].min == 0\n", - "plot_test(\"image-widget-zfish-init-mean-window-5\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-init-mean-window-5\", iw_z.figure)\n", "iw_z.sliders[\"t\"].value = 50\n", - "plot_test(\"image-widget-zfish-frame-50-mean-window-5\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-frame-50-mean-window-5\", iw_z.figure)\n", "iw_z.window_funcs[\"t\"].window_size = 13\n", - "plot_test(\"image-widget-zfish-frame-50-mean-window-13\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-frame-50-mean-window-13\", iw_z.figure)\n", "iw_z.window_funcs = None\n", - "plot_test(\"image-widget-zfish-frame-50\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-frame-50\", iw_z.figure)\n", "iw_z.sliders[\"t\"].value = 99\n", - "plot_test(\"image-widget-zfish-frame-99\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-frame-99\", iw_z.figure)\n", "iw_z.sliders[\"t\"].value = 50\n", "iw_z.window_funcs = {\"t\": (np.max, 13)}\n", - "plot_test(\"image-widget-zfish-frame-50-max-window-13\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-frame-50-max-window-13\", iw_z.figure)\n", "iw_z.window_funcs = None\n", "iw_z.frame_apply = lambda frame: gaussian_filter(frame.astype(np.float32), sigma=3)\n", "iw_z.reset_vmin_vmax()\n", - "plot_test(\"image-widget-zfish-frame-50-frame-apply-gaussian\", iw_z.gridplot)\n", + "plot_test(\"image-widget-zfish-frame-50-frame-apply-gaussian\", iw_z.figure)\n", "iw_z.frame_apply = None\n", "iw_z.reset_vmin_vmax()\n", - "plot_test(\"image-widget-zfish-frame-50-frame-apply-reset\", iw_z.gridplot)" + "plot_test(\"image-widget-zfish-frame-50-frame-apply-reset\", iw_z.figure)" ] }, { @@ -435,6 +440,57 @@ "iw_z.close()" ] }, + { + "cell_type": "markdown", + "id": "6716f255-44c2-400d-a2bf-254683e4cd9d", + "metadata": {}, + "source": [ + "# Test Mixed Shapes, RGB (and set data)" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "ed783360-992d-40f8-bb6f-152a59edff43", + "metadata": {}, + "outputs": [], + "source": [ + "zfish_data = np.load(\"./zfish_test.npy\")\n", + "zfish_frame_1 = zfish_data[0, 0, :, :]\n", + "zfish_frame_2 = zfish_data[20, 3, :, :]\n", + "movie = iio.imread(\"imageio:cockatoo.mp4\")\n", + "\n", + "iw_mixed_shapes = ImageWidget(\n", + " data=[zfish_frame_1, movie], # you can also provide a list of tzxy arrays\n", + " rgb=[False, True],\n", + " histogram_widget=True,\n", + " cmap=\"gnuplot2\", \n", + " figure_kwargs = {\"controller_ids\": None},\n", + ")\n", + "\n", + "iw_mixed_shapes.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "274c67b4-aa07-4fcf-a094-1b1e70d0378a", + "metadata": {}, + "outputs": [], + "source": [ + "iw_mixed_shapes.sliders[\"t\"].value = 50\n", + "plot_test(\"image-widget-zfish-mixed-rgb-cockatoo-frame-50\", iw_mixed_shapes.figure)\n", + "\n", + "#Set the data, changing the first array and also the size of the \"T\" slider\n", + "iw_mixed_shapes.set_data([zfish_frame_2, movie[:200, :, :, :]], reset_indices=True)\n", + "plot_test(\"image-widget-zfish-mixed-rgb-cockatoo-set-data\", iw_mixed_shapes.figure)\n", + "\n", + "#Check how a window function might work on the RGB data\n", + "iw_mixed_shapes.window_funcs = {\"t\": (np.mean, 4)}\n", + "iw_mixed_shapes.sliders[\"t\"].value = 20\n", + "plot_test(\"image-widget-zfish-mixed-rgb-cockatoo-windowrgb\", iw_mixed_shapes.figure)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -462,7 +518,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.2" + "version": "3.11.8" } }, "nbformat": 4, diff --git a/examples/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb index 43cea4f81..74b304a35 100644 --- a/examples/notebooks/linear_region_selector.ipynb +++ b/examples/notebooks/linear_region_selector.ipynb @@ -17,26 +17,27 @@ "source": [ "import fastplotlib as fpl\n", "import numpy as np\n", - "from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", + "# from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", "\n", - "gp = fpl.GridPlot((2, 2))\n", + "fig = fpl.Figure((2, 2))\n", "\n", "# preallocated size for zoomed data\n", "zoomed_prealloc = 1_000\n", "\n", "# data to plot\n", - "xs = np.linspace(0, 100, 1_000)\n", - "sine = np.sin(xs) * 20\n", + "xs = np.linspace(0, 10* np.pi, 1_000)\n", + "sine = np.sin(xs)\n", + "sine += 100\n", "\n", "# make sine along x axis\n", - "sine_graphic_x = gp[0, 0].add_line(sine)\n", + "sine_graphic_x = fig[0, 0].add_line(np.column_stack([xs, sine]), offset=(10, 0, 0))\n", "\n", "# just something that looks different for line along y-axis\n", "sine_y = sine\n", "sine_y[sine_y > 0] = 0\n", "\n", "# sine along y axis\n", - "sine_graphic_y = gp[0, 1].add_line(np.column_stack([sine_y, xs]))\n", + "sine_graphic_y = fig[0, 1].add_line(np.column_stack([sine_y, xs]))\n", "\n", "# offset the position of the graphic to demonstrate `get_selected_data()` later\n", "sine_graphic_y.position_x = 50\n", @@ -47,11 +48,11 @@ "ls_y = sine_graphic_y.add_linear_region_selector(axis=\"y\")\n", "\n", "# preallocate array for storing zoomed in data\n", - "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.random.rand(zoomed_prealloc)])\n", + "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)])\n", "\n", "# make line graphics for displaying zoomed data\n", - "zoomed_x = gp[1, 0].add_line(zoomed_init)\n", - "zoomed_y = gp[1, 1].add_line(zoomed_init)\n", + "zoomed_x = fig[1, 0].add_line(zoomed_init)\n", + "zoomed_y = fig[1, 1].add_line(zoomed_init)\n", "\n", "\n", "def interpolate(subdata: np.ndarray, axis: int):\n", @@ -62,82 +63,34 @@ " # interpolate to preallocated size\n", " return np.interp(x, xp, fp=subdata[:, axis]) # use the y-values\n", "\n", - "\n", + "@ls_x.add_event_handler(\"selection\")\n", "def set_zoom_x(ev):\n", " \"\"\"sets zoomed x selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_x.data = interpolate(selected_data, axis=1) # use the y-values\n", - " gp[1, 0].auto_scale()\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_x.data[:, 1] = 0\n", + "\n", + " # set the y-values\n", + " zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)\n", + " fig[1, 0].auto_scale()\n", "\n", "\n", "def set_zoom_y(ev):\n", - " \"\"\"sets zoomed y selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_y.data = -interpolate(selected_data, axis=0) # use the x-values\n", - " gp[1, 1].auto_scale()\n", + " \"\"\"sets zoomed x selector data\"\"\"\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_y.data[:, 0] = 0\n", "\n", + " # set the x-values\n", + " zoomed_y.data[:, 0] = -interpolate(selected_data, axis=1)\n", + " fig[1, 1].auto_scale()\n", "\n", - "# update zoomed plots when bounds change\n", - "ls_x.selection.add_event_handler(set_zoom_x)\n", - "ls_y.selection.add_event_handler(set_zoom_y)\n", "\n", - "gp.show()" - ] - }, - { - "cell_type": "markdown", - "id": "0bad4a35-f860-4f85-9061-920154ab682b", - "metadata": {}, - "source": [ - "### On the x-axis we have a 1-1 mapping from the data that we have passed and the line geometry positions. So the `bounds` min max corresponds directly to the data indices." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c96a3ff-c2e7-4683-8097-8491e97dd6d3", - "metadata": {}, - "outputs": [], - "source": [ - "ls_x.selection()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3ec71e3f-291c-43c6-a954-0a082ba5981c", - "metadata": {}, - "outputs": [], - "source": [ - "ls_x.get_selected_indices()" - ] - }, - { - "cell_type": "markdown", - "id": "1588a89e-1da4-4ada-92e2-7437ba942065", - "metadata": {}, - "source": [ - "### However, for the y-axis line we have passed a 2D array where we've used a linspace, so there is not a 1-1 mapping from the data to the line geometry positions. Use `get_selected_indices()` to get the indices of the data bounded by the current selection. In addition the position of the Graphic is not `(0, 0)`. You must use `get_selected_indices()` whenever you want the indices of the selected data." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18e10277-6d5d-42fe-8715-1733efabefa0", - "metadata": {}, - "outputs": [], - "source": [ - "ls_y.selection()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8e9c42b9-60d2-4544-96c5-c8c6832b79e3", - "metadata": {}, - "outputs": [], - "source": [ - "ls_y.get_selected_indices()" + "fig.show(maintain_aspect=False)" ] }, { @@ -147,28 +100,25 @@ "metadata": {}, "outputs": [], "source": [ - "import fastplotlib as fpl\n", - "import numpy as np\n", - "\n", "# data to plot\n", "xs = np.linspace(0, 100, 1_000)\n", "sine = np.sin(xs) * 20\n", "cosine = np.cos(xs) * 20\n", "\n", - "plot = fpl.GridPlot((5, 1))\n", + "fig_stack = fpl.Figure((5, 1))\n", "\n", "# sines and cosines\n", "sines = [sine] * 2\n", "cosines = [cosine] * 2\n", "\n", "# make line stack\n", - "line_stack = plot[0, 0].add_line_stack(sines + cosines, separation=50)\n", + "line_stack = fig_stack[0, 0].add_line_stack(sines + cosines, separation=50)\n", "\n", "# make selector\n", "selector = line_stack.add_linear_region_selector()\n", "\n", "# populate subplots with preallocated graphics\n", - "for i, subplot in enumerate(plot):\n", + "for i, subplot in enumerate(fig_stack):\n", " if i == 0:\n", " # skip the first one\n", " continue\n", @@ -176,18 +126,19 @@ " subplot.add_line(zoomed_init, name=\"zoomed\")\n", "\n", "\n", + "@selector.add_event_handler(\"selection\")\n", "def update_zoomed_subplots(ev):\n", " \"\"\"update the zoomed subplots\"\"\"\n", - " zoomed_data = selector.get_selected_data()\n", + " zoomed_data = ev.get_selected_data()\n", " \n", " for i in range(len(zoomed_data)):\n", + " # interpolate y-vals\n", " data = interpolate(zoomed_data[i], axis=1)\n", - " plot[i + 1, 0][\"zoomed\"].data = data\n", - " plot[i + 1, 0].auto_scale()\n", + " fig_stack[i + 1, 0][\"zoomed\"].data[:, 1] = data\n", + " fig_stack[i + 1, 0].auto_scale()\n", "\n", "\n", - "selector.selection.add_event_handler(update_zoomed_subplots)\n", - "plot.show()" + "fig_stack.show()" ] }, { @@ -198,50 +149,6 @@ "# Large line stack with selector" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "d5ffb678-c989-49ee-85a9-4fd7822f033c", - "metadata": {}, - "outputs": [], - "source": [ - "import fastplotlib as fpl\n", - "import numpy as np\n", - "\n", - "# data to plot\n", - "xs = np.linspace(0, 250, 10_000)\n", - "sine = np.sin(xs) * 20\n", - "cosine = np.cos(xs) * 20\n", - "\n", - "plot = fpl.GridPlot((1, 2))\n", - "\n", - "# sines and cosines\n", - "sines = [sine] * 1_00\n", - "cosines = [cosine] * 1_00\n", - "\n", - "# make line stack\n", - "line_stack = plot[0, 0].add_line_stack(sines + cosines, separation=50)\n", - "\n", - "# make selector\n", - "stack_selector = line_stack.add_linear_region_selector(padding=200)\n", - "\n", - "zoomed_line_stack = plot[0, 1].add_line_stack([zoomed_init] * 2_000, separation=50, name=\"zoomed\")\n", - " \n", - "def update_zoomed_stack(ev):\n", - " \"\"\"update the zoomed subplots\"\"\"\n", - " zoomed_data = stack_selector.get_selected_data()\n", - " \n", - " for i in range(len(zoomed_data)):\n", - " data = interpolate(zoomed_data[i], axis=1)\n", - " zoomed_line_stack.graphics[i].data = data\n", - " \n", - " plot[0, 1].auto_scale()\n", - "\n", - "\n", - "stack_selector.selection.add_event_handler(update_zoomed_stack)\n", - "plot.show()" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index 0f81bc36b..bac8df182 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -5,7 +5,7 @@ "id": "a06e1fd9-47df-42a3-a76c-19e23d7b89fd", "metadata": {}, "source": [ - "## `LinearSelector`, draggable selector that can optionally associated with an ipywidget." + "## `LinearSelector`, draggable selector that can also be linked to an ipywidget slider" ] }, { @@ -16,35 +16,32 @@ "outputs": [], "source": [ "import fastplotlib as fpl\n", - "from fastplotlib.graphics.selectors import Synchronizer\n", "\n", "import numpy as np\n", "from ipywidgets import VBox, IntSlider, FloatSlider\n", "\n", - "plot = fpl.Plot()\n", + "fig = fpl.Figure()\n", "\n", "# data to plot\n", "xs = np.linspace(0, 100, 1000)\n", "sine = np.sin(xs) * 20\n", "\n", "# make sine along x axis\n", - "sine_graphic = plot.add_line(np.column_stack([xs, sine]).astype(np.float32))\n", + "sine_graphic = fig[0, 0].add_line(np.column_stack([xs, sine]).astype(np.float32))\n", "\n", "# make some selectors\n", "selector = sine_graphic.add_linear_selector()\n", "selector2 = sine_graphic.add_linear_selector(20)\n", "selector3 = sine_graphic.add_linear_selector(40)\n", "\n", - "ss = Synchronizer(selector, selector2, selector3)\n", - "\n", + "# one of the selectors will change the line colors when it moves\n", + "@selector.add_event_handler(\"selection\")\n", "def set_color_at_index(ev):\n", " # changes the color at the index where the slider is\n", - " ix = ev.pick_info[\"selected_index\"]\n", - " g = ev.pick_info[\"graphic\"].parent\n", + " ix = ev.get_selected_index()\n", + " g = ev.graphic.parent\n", " g.colors[ix] = \"green\"\n", "\n", - "selector.selection.add_event_handler(set_color_at_index)\n", - "\n", "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", "ipywidget_slider = selector.make_ipywidget_slider()\n", "ipywidget_slider.description = \"slider1\"\n", @@ -56,8 +53,16 @@ "selector2.add_ipywidget_handler(ipywidget_slider2, step=5)\n", "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n", "\n", - "plot.auto_scale()\n", - "plot.show(add_widgets=[ipywidget_slider])" + "fig[0, 0].auto_scale()\n", + "VBox([fig.show(), ipywidget_slider, ipywidget_slider2, ipywidget_slider3])" + ] + }, + { + "cell_type": "markdown", + "id": "d83caca6-e9b6-45df-b93c-0dfe0498d20e", + "metadata": {}, + "source": [ + "Double click the first selctor, and then use `Shift` + Right/Left Arrow Key to move it!" ] }, { @@ -67,13 +72,16 @@ "metadata": {}, "outputs": [], "source": [ + "# this controls the step-size of arrow key movements\n", "selector.step = 0.1" ] }, { "cell_type": "markdown", "id": "3b0f448f-bbe4-4b87-98e3-093f561c216c", - "metadata": {}, + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, "source": [ "### Drag linear selectors with the mouse, hold \"Shift\" to synchronize movement of all the selectors" ] @@ -95,9 +103,9 @@ "source": [ "sines = [sine] * 10\n", "\n", - "plot = fpl.Plot()\n", + "fig = fpl.Figure()\n", "\n", - "sine_stack = plot.add_line_stack(sines)\n", + "sine_stack = fig[0, 0].add_line_stack(sines)\n", "\n", "colors = \"y\", \"blue\", \"red\", \"green\"\n", "\n", @@ -105,10 +113,8 @@ "for i, c in enumerate(colors):\n", " sel = sine_stack.add_linear_selector(i * 100, color=c, name=str(i))\n", " selectors.append(sel)\n", - " \n", - "ss = Synchronizer(*selectors)\n", "\n", - "plot.show()" + "fig.show()" ] }, { diff --git a/examples/notebooks/lineplot.ipynb b/examples/notebooks/lineplot.ipynb index 667cae178..85ebb60f5 100644 --- a/examples/notebooks/lineplot.ipynb +++ b/examples/notebooks/lineplot.ipynb @@ -7,7 +7,7 @@ "tags": [] }, "source": [ - "# A more complex example combing different graphics, gridplot and multiple perspectives" + "# A more complex example combing different graphics, subplots and multiple perspectives" ] }, { @@ -18,7 +18,7 @@ "outputs": [], "source": [ "import numpy as np\n", - "from fastplotlib import GridPlot" + "import fastplotlib as fpl" ] }, { @@ -46,11 +46,11 @@ "metadata": {}, "outputs": [], "source": [ - "# grid with 2 rows and 2 columns\n", + "# figure with 2 rows and 2 columns\n", "shape = (2, 2)\n", "\n", - "# pan-zoom controllers for each view\n", - "# views are synced if they have the \n", + "# pan-zoom controllers for each subplot\n", + "# subplots are synced if they have the\n", "# same controller ID\n", "# in this example the first view has its own controller\n", "# and the last 3 views are synced\n", @@ -59,14 +59,14 @@ " [1, 1]\n", "]\n", "\n", - "# create the grid plot\n", - "grid_plot = GridPlot(\n", + "# create the figure\n", + "fig = fpl.Figure(\n", " shape=shape,\n", - " cameras='3d', # 3D view for all subplots within the grid\n", + " cameras='3d', # 3D view for all subplots within the figure\n", " controller_ids=controller_ids\n", ")\n", "\n", - "for i, subplot in enumerate(grid_plot):\n", + "for i, subplot in enumerate(fig):\n", " # create and add the LineGraphic\n", " line_graphic = subplot.add_line(data=spiral, thickness=3, cmap='jet')\n", " \n", @@ -87,13 +87,13 @@ " if marker_index == spiral.shape[0]:\n", " marker_index = 0\n", " \n", - " for subplot in grid_plot:\n", + " for subplot in fig:\n", " subplot[\"marker\"].data = spiral[marker_index]\n", " \n", "# add `move_marker` to the animations\n", - "grid_plot.add_animations(move_marker)\n", + "fig.add_animations(move_marker)\n", "\n", - "grid_plot.show()" + "fig.show()" ] }, { diff --git a/examples/notebooks/lines_cmap.ipynb b/examples/notebooks/lines_cmap.ipynb index c6dc604b4..3ceb25326 100644 --- a/examples/notebooks/lines_cmap.ipynb +++ b/examples/notebooks/lines_cmap.ipynb @@ -39,11 +39,11 @@ "xs = np.linspace(-10, 10, 100)\n", "# sine wave\n", "ys = np.sin(xs)\n", - "sine = np.dstack([xs, ys])[0]\n", + "sine = np.column_stack([xs, ys])\n", "\n", "# cosine wave\n", "ys = np.cos(xs)\n", - "cosine = np.dstack([xs, ys])[0]" + "cosine = np.column_stack([xs, ys])" ] }, { @@ -55,11 +55,11 @@ }, "outputs": [], "source": [ - "plot = fpl.Plot()\n", + "fig = fpl.Figure()\n", "\n", - "plot.add_line(sine, thickness=10)\n", + "fig[0, 0].add_line(sine, thickness=10)\n", "\n", - "plot.show()" + "fig.show()" ] }, { @@ -71,7 +71,7 @@ }, "outputs": [], "source": [ - "plot_test(\"lines-cmap-white\", plot)" + "plot_test(\"lines-cmap-white\", fig)" ] }, { @@ -91,7 +91,7 @@ }, "outputs": [], "source": [ - "plot.graphics[0].cmap = \"jet\"" + "fig[0, 0].graphics[0].cmap = \"jet\"" ] }, { @@ -104,7 +104,15 @@ "outputs": [], "source": [ "# for testing, ignore\n", - "plot_test(\"lines-cmap-jet\", plot)" + "plot_test(\"lines-cmap-jet\", fig)" + ] + }, + { + "cell_type": "markdown", + "id": "13c1c034-2b3b-4568-b979-7c0bbea698ae", + "metadata": {}, + "source": [ + "map colors from sine data values by setting the cmap transform" ] }, { @@ -116,7 +124,7 @@ }, "outputs": [], "source": [ - "plot.graphics[0].cmap.values = sine[:, 1]" + "fig[0, 0].graphics[0].cmap.transform = sine[:, 1]" ] }, { @@ -129,7 +137,7 @@ "outputs": [], "source": [ "# for testing, ignore\n", - "plot_test(\"lines-cmap-jet-values\", plot)" + "plot_test(\"lines-cmap-jet-values\", fig)" ] }, { @@ -141,7 +149,8 @@ }, "outputs": [], "source": [ - "plot.graphics[0].cmap.values = cosine[:, 1]" + "# set transform from cosine\n", + "fig[0, 0].graphics[0].cmap.transform = cosine[:, 1]" ] }, { @@ -154,7 +163,7 @@ "outputs": [], "source": [ "# for testing, ignore\n", - "plot_test(\"lines-cmap-jet-values-cosine\", plot)" + "plot_test(\"lines-cmap-jet-values-cosine\", fig)" ] }, { @@ -166,7 +175,8 @@ }, "outputs": [], "source": [ - "plot.graphics[0].cmap = \"viridis\"" + "# change cmap\n", + "fig[0, 0].graphics[0].cmap = \"viridis\"" ] }, { @@ -179,7 +189,15 @@ "outputs": [], "source": [ "# for testing, ignore\n", - "plot_test(\"lines-cmap-viridis\", plot)" + "plot_test(\"lines-cmap-viridis\", fig)" + ] + }, + { + "cell_type": "markdown", + "id": "1f52bfdc-8151-4bab-973c-1bac36011802", + "metadata": {}, + "source": [ + "use cmap transform to map for a qualitative transform" ] }, { @@ -191,7 +209,7 @@ }, "outputs": [], "source": [ - "cmap_values = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20" + "cmap_transform = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20" ] }, { @@ -203,7 +221,7 @@ }, "outputs": [], "source": [ - "plot.graphics[0].cmap.values = cmap_values" + "fig[0, 0].graphics[0].cmap.transform = cmap_transform" ] }, { @@ -216,7 +234,7 @@ "outputs": [], "source": [ "# for testing, ignore\n", - "plot_test(\"lines-cmap-viridis-values\", plot)" + "plot_test(\"lines-cmap-viridis-values\", fig)" ] }, { @@ -228,7 +246,7 @@ }, "outputs": [], "source": [ - "plot.graphics[0].cmap = \"tab10\"" + "fig[0, 0].graphics[0].cmap = \"tab10\"" ] }, { @@ -239,7 +257,7 @@ "outputs": [], "source": [ "# for testing, ignore\n", - "plot_test(\"lines-cmap-tab-10\", plot)" + "plot_test(\"lines-cmap-tab-10\", fig)" ] }, { diff --git a/examples/notebooks/multiprocessing_zmq/README.md b/examples/notebooks/multiprocessing_zmq/README.md new file mode 100644 index 000000000..184453d0c --- /dev/null +++ b/examples/notebooks/multiprocessing_zmq/README.md @@ -0,0 +1,3 @@ +This example shows how to use a zmq publisher-subscriber pattern to perform a computation in one process and visualize results in another process. First, run all cells in `multiprocessing_zmq_plot.ipynb`, and then run cells in `multiprocessing_zmq_compute.ipynb`. The raw bytes for the numpy array are sent using zmq in the compute notebook and received in the plot notebook and displayed. + +For more information on zmq see: https://zeromq.org/languages/python/ diff --git a/examples/notebooks/multiprocessing_zmq/multiprocessing_zmq_compute.ipynb b/examples/notebooks/multiprocessing_zmq/multiprocessing_zmq_compute.ipynb new file mode 100644 index 000000000..7f24f6411 --- /dev/null +++ b/examples/notebooks/multiprocessing_zmq/multiprocessing_zmq_compute.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "ca2817a3-869c-4cc6-901b-c34509518175", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import zmq" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd0b9780-b507-4ea2-af09-134abd76f45b", + "metadata": {}, + "outputs": [], + "source": [ + "context = zmq.Context()\n", + "\n", + "# create publisher\n", + "socket = context.socket(zmq.PUB)\n", + "socket.bind(\"tcp://127.0.0.1:5555\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4729bfe8-3474-4bc9-a489-a57d02e5a287", + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(2_000):\n", + " # make some data, make note of the dtype\n", + " data = np.random.rand(512, 512).astype(np.float32)\n", + "\n", + " # sent bytes over the socket\n", + " socket.send(data.tobytes())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d47dab72-1061-439f-bf6e-a88b9ee8e5aa", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/multiprocessing_zmq/multiprocessing_zmq_plot.ipynb b/examples/notebooks/multiprocessing_zmq/multiprocessing_zmq_plot.ipynb new file mode 100644 index 000000000..564512451 --- /dev/null +++ b/examples/notebooks/multiprocessing_zmq/multiprocessing_zmq_plot.ipynb @@ -0,0 +1,114 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "491e6050-64ae-4bfc-a480-5805cd684710", + "metadata": {}, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "import numpy as np\n", + "import zmq" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97135f98-6810-49b6-a8de-d0e114720d6c", + "metadata": {}, + "outputs": [], + "source": [ + "context = zmq.Context()\n", + "\n", + "# create subscriber\n", + "sub = context.socket(zmq.SUB)\n", + "sub.setsockopt(zmq.SUBSCRIBE, b\"\")\n", + "\n", + "# keep only the most recent message\n", + "sub.setsockopt(zmq.CONFLATE, 1)\n", + "\n", + "# publisher address and port\n", + "sub.connect(\"tcp://127.0.0.1:5555\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d4420f2-364a-445a-9658-63e9ffa586c3", + "metadata": {}, + "outputs": [], + "source": [ + "def get_bytes():\n", + " \"\"\"\n", + " Gets the bytes from the publisher\n", + " \"\"\"\n", + " try:\n", + " b = sub.recv(zmq.NOBLOCK)\n", + " except zmq.Again:\n", + " pass\n", + " else:\n", + " return b\n", + " \n", + " return None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42d20d77-b884-4379-80e4-e08738506eeb", + "metadata": {}, + "outputs": [], + "source": [ + "fig = fpl.Figure()\n", + "\n", + "# initialize some data, must be of same dtype and shape as data sent by publisher\n", + "data = np.random.rand(512, 512).astype(np.float32)\n", + "fig[0, 0].add_image(data, name=\"image\")\n", + "\n", + "def update_frame(subplot):\n", + " # recieve bytes\n", + " b = get_bytes()\n", + " \n", + " if b is not None:\n", + " # numpy array from bytes, MUST specify dtype and make sure it matches what you sent\n", + " a = np.frombuffer(b, dtype=np.float32).reshape(512, 512)\n", + " \n", + " # set graphic data\n", + " subplot[\"image\"].data = a\n", + "\n", + "fig[0, 0].add_animations(update_frame)\n", + "fig.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f8ac188-9359-4d3c-b8f1-384be84d1585", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/nb_test_utils.py b/examples/notebooks/nb_test_utils.py index e16ed2eaf..791640fe2 100644 --- a/examples/notebooks/nb_test_utils.py +++ b/examples/notebooks/nb_test_utils.py @@ -5,7 +5,7 @@ import imageio.v3 as iio import numpy as np -from fastplotlib import Plot, GridPlot +import fastplotlib as fpl # make dirs for screenshots and diffs current_dir = Path(__file__).parent @@ -16,22 +16,92 @@ os.makedirs(SCREENSHOTS_DIR, exist_ok=True) os.makedirs(DIFFS_DIR, exist_ok=True) +TOLERANCE = 0.025 # store all the failures to allow the nb to proceed to test other examples FAILURES = list() +if "FASTPLOTLIB_NB_TESTS" not in os.environ.keys(): + TESTING = False -def plot_test(name, plot: Union[Plot, GridPlot]): - snapshot = plot.canvas.snapshot() +else: + if os.environ["FASTPLOTLIB_NB_TESTS"] == "1": + TESTING = True + + +# TODO: consolidate testing functions into one module so we don't have this separate one for notebooks + +def rgba_to_rgb(img: np.ndarray) -> np.ndarray: + black = np.zeros(img.shape).astype(np.uint8) + black[:, :, -1] = 255 + + img_alpha = img[..., -1] / 255 + + rgb = img[..., :-1] * img_alpha[..., None] + black[..., :-1] * np.ones( + img_alpha.shape + )[..., None] * (1 - img_alpha[..., None]) + + return rgb.round().astype(np.uint8) + + +# image comparison functions from: https://github.com/pygfx/image-comparison +def image_similarity(src, target, threshold=0.2): + """Compute normalized RMSE 0..1 and decide if similar based on threshold. + + For every pixel, the euclidian distance between RGB values is computed, + and normalized by the maximum possible distance (between black and white). + The RMSE is then computed from those errors. + + The normalized RMSE is used to compute the + similarity metric, so larger errors (euclidian distance + between two RGB colors) will have a disproportionately + larger effect on the score than smaller errors. + + In other words, lots of small errors will lead to a good score + (closer to 0) whereas a few large errors will lead to a bad score + (closer to 1). + """ + float_type = np.float64 + src = np.asarray(src, dtype=float_type) + target = np.asarray(target, dtype=float_type) + denom = np.sqrt(np.mean(src * src)) + mse = np.mean((src - target) ** 2) + rmse = np.sqrt(mse) / denom + + similar = bool(rmse < threshold) + return similar, rmse + + +def normalize_image(img): + """Discard the alpha channel and convert from 0..255 uint8 to 0..1 float.""" + assert len(img.shape) == 3 + + # normalize to 0..1 range + if img.dtype == "u1" or np.max(img) > 1: + img = img / 255 + assert np.min(img) >= 0 and np.max(img) <= 1 + + # discard alpha channel + # unsupported if it's not fully opaque + if img.shape[-1] == 4: + assert np.max(img[..., 3]) == 1 + img = img[..., :-1] + + return img + + +def plot_test(name, fig: fpl.Figure): + if not TESTING: + return + + snapshot = fig.canvas.snapshot() + rgb_img = rgba_to_rgb(snapshot.data) if "REGENERATE_SCREENSHOTS" in os.environ.keys(): if os.environ["REGENERATE_SCREENSHOTS"] == "1": - regenerate_screenshot(name, snapshot.data) + regenerate_screenshot(name, rgb_img) - try: - assert_screenshot_equal(name, snapshot.data) - except AssertionError: - FAILURES.append(name) + assert_screenshot_equal(name, rgb_img) def regenerate_screenshot(name, data): @@ -41,13 +111,17 @@ def regenerate_screenshot(name, data): def assert_screenshot_equal(name, data): ground_truth = iio.imread(SCREENSHOTS_DIR.joinpath(f"nb-{name}.png")) - is_similar = np.allclose(data, ground_truth) + img = normalize_image(data) + ref_img = normalize_image(ground_truth) - update_diffs(name, is_similar, data, ground_truth) + similar, rmse = image_similarity(img, ref_img, threshold=TOLERANCE) - assert is_similar, ( - f"notebook snapshot for {name} has changed" - ) + update_diffs(name, similar, data, ground_truth) + + if not similar: + FAILURES.append( + (name, rmse) + ) def update_diffs(name, is_similar, img, ground_truth): @@ -69,7 +143,6 @@ def get_diffs_rgba(slicer): # split into an rgb and an alpha diff diffs = { DIFFS_DIR.joinpath(f"nb-diff-{name}-rgb.png"): slice(0, 3), - DIFFS_DIR.joinpath(f"nb-diff-{name}-alpha.png"): 3, } for path, slicer in diffs.items(): @@ -81,6 +154,9 @@ def get_diffs_rgba(slicer): def notebook_finished(): + if not TESTING: + return + if len(FAILURES) > 0: raise AssertionError( f"Failures for plots:\n{FAILURES}" diff --git a/docs/source/quickstart.ipynb b/examples/notebooks/quickstart.ipynb similarity index 54% rename from docs/source/quickstart.ipynb rename to examples/notebooks/quickstart.ipynb index 0de4667bf..09317110d 100644 --- a/docs/source/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -7,18 +7,14 @@ "tags": [] }, "source": [ - "# Quick Start Guide 🚀\n", + "# Introduction to `fastplotlib` 🚀\n", "\n", - "This notebook goes through the basic components of the `fastplotlib` API, images, image updates, line plots, scatter plots, and grid plots.\n", - "\n", - "**NOTE: This quick start guide in the docs is NOT interactive. Download the examples from the repo and try them on your own computer. You can run the desktop examples directly if you have `glfw` installed, or try the notebook demos:** https://github.com/kushalkolar/fastplotlib/tree/master/examples\n", - "\n", - "It will not be possible to have live demos on the docs until someone can figure out how to get [pygfx](https://github.com/pygfx/pygfx) to work with `wgpu` in the browser, perhaps through [pyodide](https://github.com/pyodide/pyodide) or something :D." + "This notebook goes through the basic components of the `fastplotlib` API, image, line, scatter plots, subplots and simple animations" ] }, { "cell_type": "markdown", - "id": "5d21c330-89cd-49ab-9069-4e3652d4286b", + "id": "ae07272b-e94b-4262-b486-6b3ddac63038", "metadata": {}, "source": [ "**The example images are from `imageio` so you will need to install it for this example notebook. But `imageio` is not required to use `fasptlotlib`**" @@ -27,7 +23,7 @@ { "cell_type": "code", "execution_count": null, - "id": "07f064bb-025a-4794-9b05-243810edaf60", + "id": "6674c90b-bfe3-4a71-ab7d-21e9cc03c050", "metadata": { "tags": [] }, @@ -39,8 +35,9 @@ { "cell_type": "code", "execution_count": null, - "id": "5f842366-bd39-47de-ad00-723b2be707e4", + "id": "5c50e177-5800-4e19-a4f6-d0e0a082e4cd", "metadata": { + "is_executing": true, "tags": [] }, "outputs": [], @@ -62,12 +59,27 @@ "import numpy as np" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d374d5e-70e0-4946-937f-82d16a56009f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# this is only for testing, you do not need this to use fastplotlib\n", + "from nb_test_utils import plot_test, notebook_finished, TESTING" + ] + }, { "cell_type": "markdown", "id": "a9b386ac-9218-4f8f-97b3-f29b4201ef55", "metadata": {}, "source": [ - "## Images" + "## Simple image\n", + "\n", + "We are going to be using `jupyterlab-sidecar` to render some of the plots on the side. This makes it very easy to interact with your plots without having to constantly scroll up and down :D" ] }, { @@ -79,17 +91,18 @@ }, "outputs": [], "source": [ - "# create a `Plot` instance\n", - "plot = fpl.Plot()\n", + "# create a `Figure` instance\n", + "# by default the figure will have 1 subplot\n", + "fig = fpl.Figure()\n", "\n", "# get a grayscale image\n", "data = iio.imread(\"imageio:camera.png\")\n", "\n", "# plot the image data\n", - "image_graphic = plot.add_image(data=data, name=\"sample-image\")\n", + "image_graphic = fig[0, 0].add_image(data=data, name=\"sample-image\")\n", "\n", "# show the plot\n", - "plot.show()" + "fig.show(sidecar=True)" ] }, { @@ -97,32 +110,14 @@ "id": "be5b408f-dd91-4e36-807a-8c22c8d7d216", "metadata": {}, "source": [ - "**In live notebooks or desktop applications, you can use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**" - ] - }, - { - "cell_type": "markdown", - "id": "9ba07ec1-a0cb-4461-87c6-c7b64d4a882b", - "metadata": {}, - "source": [ - "This is how you can take a snapshot of the canvas. Snapshots are shown throughout this doc page for the purposes of documentation, they are NOT necessary for real interactive usage. Download the notebooks to run live demos." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b64ba135-e753-43a9-ad1f-adcc7310792d", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.canvas.snapshot()" + "**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", + "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`" ] }, { "cell_type": "markdown", - "id": "ac5f5e75-9aa4-441f-9a41-66c22cd53de8", + "id": "7c3b637c-a26b-416e-936c-705275852a8a", "metadata": {}, "source": [ "Changing graphic **\"features\"**" @@ -131,7 +126,7 @@ { "cell_type": "code", "execution_count": null, - "id": "d3541d1d-0819-450e-814c-588ffc8e7ed5", + "id": "de816c88-1c4a-4071-8a5e-c46c93671ef5", "metadata": { "tags": [] }, @@ -140,21 +135,9 @@ "image_graphic.cmap = \"viridis\"" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "ab544719-9187-45bd-8127-aac79eea30e4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.canvas.snapshot()" - ] - }, { "cell_type": "markdown", - "id": "9693cf94-11e9-46a6-a5b7-b0fbed42ad81", + "id": "da1efe85-c5b8-42e8-ae81-6cbddccc30f7", "metadata": {}, "source": [ "### Slicing data\n", @@ -167,19 +150,21 @@ { "cell_type": "code", "execution_count": null, - "id": "330a47b5-50b1-4e6a-b8ab-d55d92af2042", + "id": "a04afe48-5534-4ef6-a159-f6e6a4337d8d", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.data().shape" + "# some graphic properties behave like arrays\n", + "# access the underlying array using .values\n", + "image_graphic.data.value.shape" ] }, { "cell_type": "code", "execution_count": null, - "id": "601f46d9-7f32-4a43-9090-4674218800ea", + "id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a", "metadata": { "tags": [] }, @@ -189,21 +174,9 @@ "image_graphic.data[:, ::15] = 1" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "3443948f-9ac9-484a-a4bf-3a06c1ce5658", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.canvas.snapshot()" - ] - }, { "cell_type": "markdown", - "id": "53125b3b-3ce2-43c5-b2e3-7cd37cec7d7d", + "id": "135db5d2-53fb-4d50-8164-2c1f00560c25", "metadata": {}, "source": [ "**Fancy indexing**" @@ -212,7 +185,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7344cbbe-40c3-4d9e-ae75-7abe3ddaeeeb", + "id": "a89120eb-108b-4df3-8d3f-8192c9315aa6", "metadata": { "tags": [] }, @@ -221,21 +194,9 @@ "image_graphic.data[data > 175] = 255" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef113d79-5d86-4be0-868e-30f82f8ab528", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.canvas.snapshot()" - ] - }, { "cell_type": "markdown", - "id": "4df5296e-2a18-403f-82f1-acb8eaf280e3", + "id": "096ccb73-bf6d-4dba-8168-788a63450406", "metadata": {}, "source": [ "Adjust vmin vmax" @@ -244,31 +205,32 @@ { "cell_type": "code", "execution_count": null, - "id": "28af88d1-0518-47a4-ab73-431d6aaf9cb8", + "id": "f8e69df8-7aaf-4d7c-92e3-861d9ebc8c5f", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.cmap.vmin = 50\n", - "image_graphic.cmap.vmax = 150" + "image_graphic.vmin = 50\n", + "image_graphic.vmax = 150" ] }, { "cell_type": "code", "execution_count": null, - "id": "e3dfb827-c812-447d-b413-dc15653160b1", + "id": "aa67b34a-2694-4ec0-9ba2-e88c469f1a06", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot.canvas.snapshot()" + "# testing cell, ignore\n", + "plot_test(\"camera\", fig)" ] }, { "cell_type": "markdown", - "id": "19a1b56b-fdca-40c5-91c9-3c9486fd8a21", + "id": "da9c9b25-7c8b-49b2-9531-7c741debd71d", "metadata": {}, "source": [ "**Set the entire data array again**\n", @@ -279,7 +241,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4dc3d0e4-b128-42cd-a53e-76846fc9b8a8", + "id": "089170fd-016e-4b2f-a090-c30beb85cc1b", "metadata": { "tags": [] }, @@ -291,7 +253,7 @@ }, { "cell_type": "markdown", - "id": "3bd06068-fe3f-404d-ba4a-a72a2105904f", + "id": "d14cf14a-282f-40c6-b086-9bcf332ed0c8", "metadata": {}, "source": [ "This is an RGB image, convert to grayscale to maintain the shape of (512, 512)" @@ -300,7 +262,7 @@ { "cell_type": "code", "execution_count": null, - "id": "150047a6-a6ac-442d-a468-3e0c224a2b7e", + "id": "ec9b2874-ce1a-49c6-9b84-ee8f14d55966", "metadata": { "tags": [] }, @@ -313,7 +275,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bf24576b-d336-4754-9992-9649ccaa4d1e", + "id": "8a8fc1d3-19ba-42c0-b9ec-39f6ddd23314", "metadata": { "tags": [] }, @@ -322,125 +284,140 @@ "image_graphic.data = gray" ] }, + { + "cell_type": "markdown", + "id": "bb568f89-ac92-4dde-9359-789049dc758a", + "metadata": {}, + "source": [ + "\n", + "\n", + "reset vmin vmax" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "67d810c2-4020-4769-a5ba-0d4a972ee243", + "id": "de09d977-88ea-472c-8d89-9e24abc845a9", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "2fe82654-e554-4be6-92a0-ecdee0ef8519", - "metadata": {}, - "source": [ - "reset vmin vmax" + "image_graphic.reset_vmin_vmax()" ] }, { "cell_type": "code", "execution_count": null, - "id": "0be6e4bb-cf9a-4155-9f6a-8106e66e6132", + "id": "9cf84998-03e1-41b3-8e63-92d5b59426e6", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic.cmap.reset_vmin_vmax()" + "# testing cell, ignore\n", + "plot_test(\"astronaut\", fig)" ] }, { "cell_type": "code", "execution_count": null, - "id": "bd51936c-ad80-4b33-b855-23565265a430", + "id": "0bb1cfc7-1a06-4abb-a10a-a877a0d51c6b", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot.canvas.snapshot()" + "fig.canvas.get_logical_size()" ] }, { "cell_type": "markdown", - "id": "a6c1f3fb-a3a7-4175-bd8d-bb3203740771", + "id": "b53bc11a-ddf1-4786-8dca-8f3d2eaf993d", "metadata": {}, "source": [ - "### Indexing plots" + "### Indexing subplots" ] }, { "cell_type": "markdown", - "id": "3fc38694-aca6-4f56-97ac-3435059a6be7", + "id": "67b92ffd-40cc-43fe-9df9-0e0d94763d8e", "metadata": {}, "source": [ - "**Plots are indexable and give you their graphics by name**" + "**Subplots are indexable and give you their graphics by name**" ] }, { "cell_type": "code", "execution_count": null, - "id": "8a547138-0f7d-470b-9925-8df479c3979e", + "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot" + "fig[0, 0]" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "5551861f-9860-4515-8222-2f1c6d6a3220", + "cell_type": "markdown", + "id": "e6eccef1", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, - "outputs": [], "source": [ - "plot[\"sample-image\"]" + "Access graphics in a subplot" ] }, { - "cell_type": "markdown", - "id": "0c29b36e-0eb4-4bb3-a8db-add58c303ee8", - "metadata": {}, + "cell_type": "code", + "execution_count": null, + "id": "7d8eaaf3", + "metadata": { + "collapsed": false, + "is_executing": true, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], "source": [ - "**You can also use numerical indexing on `plot.graphics`**" + "# by name\n", + "fig[0, 0][\"sample-image\"]" ] }, { "cell_type": "code", "execution_count": null, - "id": "ce6adbb0-078a-4e74-b189-58f860ee5df5", + "id": "c09a1924-70f8-4d9e-9e92-510d700ac715", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot.graphics" + "# or through the .graphics property of a subplot\n", + "fig[0, 0].graphics" ] }, { "cell_type": "code", "execution_count": null, - "id": "119bd6af-c486-4378-bc23-79b1759aa3a4", + "id": "ec9e6ba6-553f-4718-ba13-471c8c7c3c4e", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot.graphics[0]" + "# these are the same!\n", + "fig[0, 0].graphics[0] is fig[0, 0][\"sample-image\"]" ] }, { "cell_type": "markdown", - "id": "6b8e3f0d-56f8-447f-bf26-b52629d06e95", + "id": "4316a8b5-5f33-427a-8f52-b101d1daab67", "metadata": {}, "source": [ "The `Graphic` instance is also returned when you call `plot.add_`." @@ -449,7 +426,7 @@ { "cell_type": "code", "execution_count": null, - "id": "967c0cbd-287c-4d99-9891-9baf18f7b56a", + "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", "metadata": { "tags": [] }, @@ -461,56 +438,56 @@ { "cell_type": "code", "execution_count": null, - "id": "5da72e26-3536-47b8-839c-53452dd94f7a", + "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", "metadata": { "tags": [] }, "outputs": [], "source": [ - "image_graphic == plot[\"sample-image\"]" + "image_graphic == fig[0, 0][\"sample-image\"]" ] }, { - "cell_type": "markdown", - "id": "2b5ee18b-e61b-415d-902a-688b1c9c03b8", + "cell_type": "code", + "execution_count": null, + "id": "058d9785-a692-46f6-a062-cdec9c040afe", "metadata": {}, + "outputs": [], "source": [ - "### RGB images\n", - "\n", - "`cmap` arguments are ignored for rgb images, but vmin vmax still works" + "# close the figure\n", + "fig.close()" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "1f7143ec-8ee1-47d2-b017-d0a8efc69fc6", - "metadata": { - "tags": [] - }, - "outputs": [], + "cell_type": "markdown", + "id": "5694dca1-1041-4e09-a1da-85b293c5af47", + "metadata": {}, "source": [ - "plot_rgb = fpl.Plot()\n", - "\n", - "plot_rgb.add_image(new_data, name=\"rgb-image\")\n", + "### RGB images are also supported\n", "\n", - "plot_rgb.show()" + "`cmap` arguments are ignored for rgb images, but vmin vmax still works" ] }, { "cell_type": "code", "execution_count": null, - "id": "a47b1eaf-3638-470a-88a5-0026c81d7e2b", + "id": "d6b8ca51-073d-47aa-a464-44511fcaccbc", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_rgb.canvas.snapshot()" + "fig_rgb = fpl.Figure()\n", + "\n", + "fig_rgb[0, 0].add_image(new_data, name=\"rgb-image\")\n", + "\n", + "# show the figure\n", + "fig_rgb.show()" ] }, { "cell_type": "markdown", - "id": "4848a929-4f3b-46d7-921b-ebfe8de0ebb5", + "id": "7fc66377-00e8-4f32-9671-9cf63f74529f", "metadata": {}, "source": [ "vmin and vmax are still applicable to rgb images" @@ -519,25 +496,37 @@ { "cell_type": "code", "execution_count": null, - "id": "ffe50132-8dd0-433c-b9c6-9ead8c3d48de", + "id": "cafaa403-50a2-403c-b8e7-b0938d48cadd", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_rgb[\"rgb-image\"].cmap.vmin = 100" + "fig_rgb[0, 0][\"rgb-image\"].vmin = 100" ] }, { "cell_type": "code", "execution_count": null, - "id": "161468ba-b836-4021-8d11-8dfc140b94eb", + "id": "b8d600c7-aa80-4c3f-8ec0-6641e9359c3a", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_rgb.canvas.snapshot()" + "# testing cell, ignore\n", + "plot_test(\"astronaut_RGB\", fig_rgb)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8316b4f2-3d6e-46b5-8776-c7c963a7aa99", + "metadata": {}, + "outputs": [], + "source": [ + "# close figure\n", + "fig_rgb.close()" ] }, { @@ -547,7 +536,7 @@ "tags": [] }, "source": [ - "## Image updates\n", + "### Image updates\n", "\n", "This examples show how you can define animation functions that run on every render cycle." ] @@ -561,28 +550,35 @@ }, "outputs": [], "source": [ - "# create another `Plot` instance\n", - "plot_v = fpl.Plot()\n", + "# create a figure\n", + "fig_v = fpl.Figure()\n", "\n", - "plot.canvas.max_buffered_frames = 1\n", + "fig.canvas.max_buffered_frames = 1\n", "\n", "# make some random data again\n", "data = np.random.rand(512, 512)\n", "\n", "# plot the data\n", - "plot_v.add_image(data=data, name=\"random-image\")\n", + "fig_v[0, 0].add_image(data=data, name=\"random-image\")\n", "\n", "# a function to update the image_graphic\n", - "# a plot will pass its plot instance to the animation function as an argument\n", - "def update_data(plot_instance):\n", + "# a figure-level animation function will optionally take the figure as an argument\n", + "def update_data(figure_instance):\n", " new_data = np.random.rand(512, 512)\n", - " plot_instance[\"random-image\"].data = new_data\n", + " figure_instance[0, 0][\"random-image\"].data = new_data\n", "\n", - "#add this as an animation function\n", - "plot_v.add_animations(update_data)\n", + "# you can also add animation functions to individual subplots\n", + "def update_data_subplot(subplot_instance):\n", + " pass\n", + "\n", + "# add this as an animation function\n", + "fig_v.add_animations(update_data)\n", + "\n", + "# similarly you can add animation function to a subplot\n", + "# fig_v[0, 0].add_animations(update_data_subplot)\n", "\n", "# show the plot\n", - "plot_v.show()" + "fig_v.show()" ] }, { @@ -590,9 +586,9 @@ "id": "b313eda1-6e6c-466f-9fd5-8b70c1d3c110", "metadata": {}, "source": [ - "**Share controllers across plots**\n", + "### We can share controllers across plots\n", "\n", - "This example creates a new plot, but it synchronizes the pan-zoom controller" + "This example creates a new figure, but it share the pan-zoom controllers from the previous figure!" ] }, { @@ -602,21 +598,21 @@ "metadata": {}, "outputs": [], "source": [ - "plot_sync = fpl.Plot(controller=plot_v.controller)\n", + "fig_sync = fpl.Figure(controllers=fig_v.controllers)\n", "\n", "data = np.random.rand(512, 512)\n", "\n", - "image_graphic_instance = plot_sync.add_image(data=data, cmap=\"viridis\")\n", + "image_graphic_instance = fig_sync[0, 0].add_image(data=data, cmap=\"viridis\")\n", "\n", "# you will need to define a new animation function for this graphic\n", "def update_data_2():\n", " new_data = np.random.rand(512, 512)\n", - " # alternatively, you can use the stored reference to the graphic as well instead of indexing the Plot\n", + " # alternatively, you can use the stored reference to the graphic as well instead of indexing the subplot\n", " image_graphic_instance.data = new_data\n", "\n", - "plot_sync.add_animations(update_data_2)\n", + "fig_sync.add_animations(update_data_2)\n", "\n", - "plot_sync.show()" + "fig_sync.show()" ] }, { @@ -624,7 +620,7 @@ "id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5", "metadata": {}, "source": [ - "Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" + "#### Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" ] }, { @@ -632,29 +628,29 @@ "id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d", "metadata": {}, "source": [ - "You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting\n", - "\n", - "Not shown in the docs, try the live demo for this feature" + "### You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `subplot` notebooks for more automated subplotting" ] }, { "cell_type": "code", "execution_count": null, - "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", + "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", "metadata": {}, "outputs": [], "source": [ - "#VBox([plot_v.canvas, plot_sync.show()])" + "HBox([fig_v.show(), fig_sync.show()])" ] }, { "cell_type": "code", "execution_count": null, - "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", + "id": "f33f4cd9-02fc-41b7-961b-9dfeb455b63a", "metadata": {}, "outputs": [], "source": [ - "#HBox([plot_v.show(), plot_sync.show()])" + "# close figures\n", + "fig_v.close()\n", + "fig_sync.close()" ] }, { @@ -664,9 +660,9 @@ "tags": [] }, "source": [ - "## Line plots\n", + "# Line plots\n", "\n", - "2D line plots\n", + "## 2D line plots\n", "\n", "This example plots a sine wave, cosine wave, and ricker wavelet and demonstrates how **Graphic Features** can be modified by slicing!" ] @@ -676,7 +672,7 @@ "id": "a6fee1c2-4a24-4325-bca2-26e5a4bf6338", "metadata": {}, "source": [ - "Generate some data." + "### First generate some data." ] }, { @@ -690,16 +686,16 @@ "xs = np.linspace(-10, 10, 100)\n", "# sine wave\n", "ys = np.sin(xs)\n", - "sine = np.dstack([xs, ys])[0]\n", + "sine = np.column_stack([xs, ys])\n", "\n", "# cosine wave\n", "ys = np.cos(xs) + 5\n", - "cosine = np.dstack([xs, ys])[0]\n", + "cosine = np.column_stack([xs, ys])\n", "\n", "# sinc function\n", "a = 0.5\n", "ys = np.sinc(xs) * 3 + 8\n", - "sinc = np.dstack([xs, ys])[0]" + "sinc = np.column_stack([xs, ys])" ] }, { @@ -707,7 +703,7 @@ "id": "fbb806e5-1565-4189-936c-b7cf147a59ee", "metadata": {}, "source": [ - "Plot all of it on the same plot. Each line plot will be an individual Graphic, you can have any combination of graphics on a plot." + "### We will plot all of it on the same plot. Each line plot will be an individual Graphic, you can have any combination of graphics on a plot." ] }, { @@ -717,44 +713,49 @@ "metadata": {}, "outputs": [], "source": [ - "# Create a plot instance\n", - "plot_l = fpl.Plot()\n", + "# Create a figure\n", + "fig_lines = fpl.Figure()\n", + "\n", + "# we will add all the lines to the same subplot\n", + "subplot = fig_lines[0, 0]\n", "\n", "# plot sine wave, use a single color\n", - "sine_graphic = plot_l.add_line(data=sine, thickness=5, colors=\"magenta\")\n", + "sine_graphic = subplot.add_line(data=sine, thickness=5, colors=\"magenta\")\n", "\n", "# you can also use colormaps for lines!\n", - "cosine_graphic = plot_l.add_line(data=cosine, thickness=12, cmap=\"autumn\")\n", + "cosine_graphic = subplot.add_line(data=cosine, thickness=12, cmap=\"autumn\")\n", "\n", "# or a list of colors for each datapoint\n", "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n", - "sinc_graphic = plot_l.add_line(data=sinc, thickness=5, colors = colors)\n", - "\n", - "plot_l.show()" - ] - }, - { - "cell_type": "markdown", - "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", - "metadata": {}, - "source": [ - "\"stretching\" the camera, useful for large timeseries data\n", + "sinc_graphic = subplot.add_line(data=sinc, thickness=5, colors = colors)\n", "\n", - "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", - "\n", - "You can also click the **`1:1`** button to toggle this." + "# show the plot\n", + "fig_lines.show(sidecar=True, sidecar_kwargs={\"title\": \"lines\"})" ] }, { "cell_type": "code", "execution_count": null, - "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d", + "id": "a4060576-2f29-4e4b-a86a-0410c766bd98", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_l.camera.maintain_aspect = False" + "# testing cell, ignore\n", + "plot_test(\"lines\", fig_lines)" + ] + }, + { + "cell_type": "markdown", + "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", + "metadata": {}, + "source": [ + "### \"stretching\" the camera, useful for large timeseries data\n", + "\n", + "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", + "\n", + "You can also click the **`1:1`** button to toggle this, or use `subplot.camera.maintain_aspect`" ] }, { @@ -762,7 +763,7 @@ "id": "1651e965-f750-47ac-bf53-c23dae84cc98", "metadata": {}, "source": [ - "reset the plot area" + "### reset the plot area" ] }, { @@ -774,7 +775,7 @@ }, "outputs": [], "source": [ - "plot_l.auto_scale(maintain_aspect=True)" + "subplot.auto_scale(maintain_aspect=True)" ] }, { @@ -782,7 +783,7 @@ "id": "dcd68796-c190-4c3f-8519-d73b98ff6367", "metadata": {}, "source": [ - "Graphic features support slicing! :D " + "## Graphic features support slicing! :D " ] }, { @@ -805,32 +806,12 @@ "cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65)" ] }, - { - "cell_type": "markdown", - "id": "bfe14ed3-e81f-4058-96a7-e2720b6d2f45", - "metadata": {}, - "source": [ - "Make a snapshot of the canvas after slicing" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a061a888-d732-406e-a9c2-8cc632fbc368", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_l.canvas.snapshot()" - ] - }, { "cell_type": "markdown", "id": "c9689887-cdf3-4a4d-948f-7efdb09bde4e", "metadata": {}, "source": [ - "**You can capture changes to a graphic feature as events**" + "## You can capture changes to a graphic feature as events" ] }, { @@ -844,7 +825,7 @@ " print(event_data)\n", "\n", "# Will print event data when the color changes\n", - "cosine_graphic.colors.add_event_handler(callback_func)" + "cosine_graphic.add_event_handler(callback_func, \"colors\")" ] }, { @@ -862,13 +843,14 @@ { "cell_type": "code", "execution_count": null, - "id": "3da9a43b-35bd-4b56-9cc7-967536aac967", + "id": "ef8cab1b-8327-43e2-b021-176125b91ca9", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "# testing cell, ignore\n", + "plot_test(\"lines-colors\", fig_lines)" ] }, { @@ -876,7 +858,7 @@ "id": "c29f81f9-601b-49f4-b20c-575c56e58026", "metadata": {}, "source": [ - "Graphic `data` is also indexable" + "## Graphic _data_ is also slicable and settable" ] }, { @@ -903,211 +885,424 @@ { "cell_type": "code", "execution_count": null, - "id": "f779cba0-7ee2-4795-8da8-9a9593d3893e", + "id": "96086bd4-cdaa-467d-a68b-1f57002ad6c5", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "# testing cell, ignore\n", + "plot_test(\"lines-data\", fig_lines)" ] }, { "cell_type": "markdown", - "id": "3f6d264b-1b03-407e-9d83-cd6cfb02e706", + "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", "metadata": {}, "source": [ - "Toggle the presence of a graphic within the scene" + "### You can set the z-positions of graphics to have them appear under other graphics" ] }, { "cell_type": "code", "execution_count": null, - "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", - "metadata": {}, + "id": "6bb33406-5bef-455b-86ea-358a7d3ffa94", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "sinc_graphic.present = False" + "img = iio.imread(\"imageio:camera.png\")\n", + "\n", + "subplot.add_image(\n", + " img[::20, ::20],\n", + " name=\"image\",\n", + " cmap=\"gray\",\n", + ")\n", + "\n", + "subplot[\"image\"].offset = (-12, -10, -1)" ] }, { "cell_type": "code", "execution_count": null, - "id": "a5e22d0f-a244-47e2-9a2d-1eaf79eda1d9", + "id": "ae3e3dc9-e49b-430a-8471-5d0a0d659d20", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "# testing cell, ignore\n", + "plot_test(\"lines-underlay\", fig_lines)" ] }, { "cell_type": "code", "execution_count": null, - "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", + "id": "bef729ea-f524-4efd-a189-bfca23b39af5", "metadata": {}, "outputs": [], "source": [ - "sinc_graphic.present = True" + "# close plot\n", + "fig_lines.close()" + ] + }, + { + "cell_type": "markdown", + "id": "3ada943c-f02c-419b-b384-3865ecbe25fb", + "metadata": {}, + "source": [ + "# Animation example with lines" ] }, { "cell_type": "code", "execution_count": null, - "id": "b22a8660-26b3-4c73-b87a-df9d7cb4353a", - "metadata": { - "tags": [] - }, + "id": "8fb64bed-3b47-43e3-9ef5-f8223005b7d2", + "metadata": {}, + "outputs": [], + "source": [ + "# just another example of animations\n", + "start, stop = 0, 2 * np.pi\n", + "increment = (2 * np.pi) / 50\n", + "\n", + "# make a simple sine wave\n", + "xs = np.linspace(start, stop, 100)\n", + "ys = np.sin(xs)\n", + "\n", + "fig = fpl.Figure()\n", + "fig[0, 0].add_line(ys, name=\"sine\")\n", + "\n", + "fig.show(maintain_aspect=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3002bc55-2612-40c8-a088-c07e138b739a", + "metadata": {}, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "# increment along the x-axis on each render loop :D \n", + "def update_line(subplot):\n", + " global increment, start, stop\n", + " xs = np.linspace(start + increment, stop + increment, 100)\n", + " ys = np.sin(xs)\n", + " \n", + " start += increment\n", + " stop += increment\n", + "\n", + " # change only the y-axis values of the line\n", + " subplot[\"sine\"].data[:, 1] = ys\n", + "\n", + "\n", + "fig[0, 0].add_animations(update_line)" ] }, { "cell_type": "markdown", - "id": "86f4e535-ce88-415a-b8d2-53612a2de7b9", + "id": "fc8c68af-810a-4564-b97d-020054b57f37", "metadata": {}, "source": [ - "You can create callbacks to `present` too, for example to re-scale the plot w.r.t. graphics that are present in the scene" + "You can remove an animation" ] }, { "cell_type": "code", "execution_count": null, - "id": "64a20a16-75a5-4772-a849-630ade9be4ff", + "id": "6321c989-60b2-4c9b-a638-a7ac1a2e4a84", "metadata": {}, "outputs": [], "source": [ - "sinc_graphic.present.add_event_handler(plot_l.auto_scale)" + "fig[0, 0].remove_animation(update_line)" + ] + }, + { + "cell_type": "markdown", + "id": "21bb17a8-cfca-4f4b-adc9-614fcffad447", + "metadata": {}, + "source": [ + "And add it back" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7749a085-d853-4859-bf99-f2bbecb50306", + "metadata": {}, + "outputs": [], + "source": [ + "fig[0, 0].add_animations(update_line)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2cf02a6c-cb1a-4972-ae96-09e6ba37e9dd", + "metadata": {}, + "outputs": [], + "source": [ + "fig.close()" + ] + }, + { + "cell_type": "markdown", + "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", + "metadata": {}, + "source": [ + "### 3D line plot" ] }, { "cell_type": "code", "execution_count": null, - "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", + "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", "metadata": {}, "outputs": [], "source": [ - "sinc_graphic.present = False" + "# just set the camera as \"3d\", the rest is basically the same :D\n", + "fig_l3d = fpl.Figure(cameras=\"3d\")\n", + "\n", + "# create a spiral\n", + "phi = np.linspace(0, 30, 200)\n", + "\n", + "xs = phi * np.cos(phi)\n", + "ys = phi * np.sin(phi)\n", + "zs = phi\n", + "\n", + "# use 3D data\n", + "# note: you usually mix 3D and 2D graphics on the same plot\n", + "spiral = np.column_stack([xs, ys, zs])\n", + "\n", + "fig_l3d[0, 0].add_line(data=spiral, thickness=2, cmap='winter')\n", + "\n", + "fig_l3d.show()" + ] + }, + { + "cell_type": "markdown", + "id": "29f07af0-cdcb-47cc-bbb3-2fa4449fa084", + "metadata": {}, + "source": [ + "**Use WASD keys and the mouse to move around, just like in a game :D. Use the mouse weel to control the speed of movement.**" ] }, { "cell_type": "code", "execution_count": null, - "id": "f9dd6a54-3460-4fb7-bffb-82fd9288902f", + "id": "28eb7014-4773-4a34-8bfc-bd3a46429012", "metadata": { "tags": [] }, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "fig_l3d[0, 0].auto_scale(maintain_aspect=True)" ] }, { "cell_type": "code", "execution_count": null, - "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", + "id": "5135f3f1-a004-4451-86cd-ead6acea6e13", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# testing cell, ignore\n", + "plot_test(\"lines-3d\", fig_l3d)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a6da884-8b40-4ebf-837f-929b3e9cf4c4", "metadata": {}, "outputs": [], "source": [ - "sinc_graphic.present = True" + "# change the FOV of the persepctive camera\n", + "fig_l3d[0, 0].camera.fov = 70" ] }, { "cell_type": "code", "execution_count": null, - "id": "cb5bf73e-b015-4b4f-82a0-c3ae8cc39ef7", + "id": "9e126e6c", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "# change the controller, ex. from the current \"fly\" controller to a \"panzoom\" controller\n", + "fig_l3d[0, 0].controller = \"panzoom\"" ] }, { - "cell_type": "markdown", - "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", + "cell_type": "code", + "execution_count": null, + "id": "11577230-8268-4b1d-a384-62d4c7f2483f", "metadata": {}, + "outputs": [], "source": [ - "You can set the z-positions of graphics to have them appear under or over other graphics" + "# or an orbit controller\n", + "fig_l3d[0, 0].controller = \"orbit\"" ] }, { "cell_type": "code", "execution_count": null, - "id": "6bb33406-5bef-455b-86ea-358a7d3ffa94", + "id": "c2c70541-98fe-4e02-a718-ac2857cc25be", "metadata": { "tags": [] }, "outputs": [], "source": [ - "img = np.random.rand(20, 100)\n", + "# close plot\n", + "fig_l3d.close()" + ] + }, + { + "cell_type": "markdown", + "id": "4221ecae-74dc-464c-addf-f4fe91614a26", + "metadata": {}, + "source": [ + "# A travelling electromagnetic wave :D " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34139d49-af6e-4dc9-90cc-fbf193a64e7f", + "metadata": {}, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0716a5c9-cf7b-417c-b809-032f5a217c4d", + "metadata": {}, + "outputs": [], + "source": [ + "fig_em = fpl.Figure(\n", + " cameras=\"3d\", \n", + " controller_types=\"orbit\", \n", + " size=(700, 400)\n", + ")\n", + "\n", + "start, stop = 0, 4 * np.pi\n", + "\n", + "# let's define the x, y and z axes for each with direction of wave propogation along the z-axis\n", + "# electric field in the xz plane travelling along\n", + "zs = np.linspace(start, stop, 200)\n", + "e_ys = np.zeros(200)\n", + "e_xs = np.sin(zs)\n", + "electric = np.column_stack([e_xs, e_ys, zs])\n", + "\n", + "# magnetic field in the yz plane\n", + "zs = np.linspace(start, stop, 200)\n", + "m_ys = np.sin(zs)\n", + "m_xs = np.zeros(200)\n", + "magnetic = np.column_stack([m_xs, m_ys, zs])\n", + "\n", + "# add the lines\n", + "fig_em[0, 0].add_line(electric, colors=\"blue\", thickness=2, name=\"e\")\n", + "fig_em[0, 0].add_line(magnetic, colors=\"red\", thickness=2, name=\"m\")\n", + "\n", + "# draw vector line at every 10th position\n", + "electric_vectors = [np.array([[0, 0, z], [x, 0, z]]) for (x, z) in zip(e_xs[::10], zs[::10])]\n", + "magnetic_vectors = [np.array([[0, 0, z], [0, y, z]]) for (y, z) in zip(m_ys[::10], zs[::10])]\n", "\n", - "plot_l.add_image(img, name=\"image\", cmap=\"gray\")\n", + "# add as a line collection\n", + "fig_em[0, 0].add_line_collection(electric_vectors, colors=\"blue\", thickness=1.5, name=\"e-vec\")\n", + "fig_em[0, 0].add_line_collection(magnetic_vectors, colors=\"red\", thickness=1.5, name=\"m-vec\")\n", "\n", - "# z axis position -1 so it is below all the lines\n", - "plot_l[\"image\"].position_z = -1\n", - "plot_l[\"image\"].position_x = -50" + "# axes are a WIP, just draw a white line along z for now\n", + "z_axis = np.array([[0, 0, 0], [0, 0, stop]])\n", + "fig_em[0, 0].add_line(z_axis, colors=\"w\", thickness=1)\n", + "\n", + "# just a pre-saved camera state\n", + "state = {\n", + " 'position': np.array([-8.0 , 6.0, -2.0]),\n", + " 'rotation': np.array([0.09, 0.9 , 0.2, -0.5]),\n", + " 'scale': np.array([1., 1., 1.]),\n", + " 'reference_up': np.array([0., 1., 0.]),\n", + " 'fov': 50.0,\n", + " 'width': 12,\n", + " 'height': 12,\n", + " 'zoom': 1.35,\n", + " 'maintain_aspect': True,\n", + " 'depth_range': None\n", + "}\n", + "\n", + "\n", + "fig_em[0, 0].camera.set_state(state)\n", + "\n", + "fig_em.show()" ] }, { "cell_type": "code", "execution_count": null, - "id": "5b586a89-ca3e-4e88-a801-bdd665384f59", - "metadata": { - "tags": [] - }, + "id": "faa52d55-2631-422d-8836-ec371be728c0", + "metadata": {}, "outputs": [], "source": [ - "plot_l.canvas.snapshot()" + "fig_em[0, 0].camera.zoom = 1.5" ] }, { "cell_type": "markdown", - "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", + "id": "d886c63b-7bcb-40d4-b315-dffff71f82f0", "metadata": {}, "source": [ - "### 3D line plot" + "## Animation for the EM wave" ] }, { "cell_type": "code", "execution_count": null, - "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", + "id": "7ae54258-0c5a-40cc-9ca0-40b47d25de9a", "metadata": {}, "outputs": [], "source": [ - "# just set the camera as \"3d\", the rest is basically the same :D \n", - "plot_l3d = fpl.Plot(camera='3d')\n", + "increment = np.pi * 4 / 100\n", "\n", - "# create a spiral\n", - "phi = np.linspace(0, 30, 200)\n", + "# moves the wave one step along the z-axis\n", + "def tick(subplot):\n", + " global increment, start, stop, zs\n", + " new_zs = np.linspace(start, stop, 200)\n", + " new_data = np.sin(new_zs)\n", "\n", - "xs = phi * np.cos(phi)\n", - "ys = phi * np.sin(phi)\n", - "zs = phi\n", + " # just change the x-axis vals for the electric field\n", + " subplot[\"e\"].data[:, 0] = new_data\n", + " # and y-axis vals for magnetic field\n", + " subplot[\"m\"].data[:, 1] = new_data\n", "\n", - "# use 3D data\n", - "# note: you usually mix 3D and 2D graphics on the same plot\n", - "spiral = np.dstack([xs, ys, zs])[0]\n", - "\n", - "plot_l3d.add_line(data=spiral, thickness=2, cmap='winter')\n", + " # update the vector lines\n", + " for i, (value, z) in enumerate(zip(new_data[::10], zs[::10])):\n", + " subplot[\"e-vec\"].graphics[i].data = np.array([[0, 0, z], [value, 0, z]])\n", + " subplot[\"m-vec\"].graphics[i].data = np.array([[0, 0, z], [0, value, z]])\n", + " \n", + " start += increment\n", + " stop += increment\n", "\n", - "plot_l3d.show()" + "fig_em[0, 0].add_animations(tick)" ] }, { "cell_type": "code", "execution_count": null, - "id": "28eb7014-4773-4a34-8bfc-bd3a46429012", - "metadata": { - "tags": [] - }, + "id": "774dba2e-f4c1-4c97-a427-c6f447139342", + "metadata": {}, "outputs": [], "source": [ - "plot_l3d.auto_scale(maintain_aspect=True)" + "fig_em.close()" ] }, { @@ -1115,11 +1310,11 @@ "id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d", "metadata": {}, "source": [ - "## Scatter plots\n", + "# Scatter plots\n", "\n", - "Plot tens of thousands or millions of points\n", + "### Plot tens of thousands or millions of points\n", "\n", - "There might be a small delay for a few seconds before the plot shows, this is due to shaders being compiled and a few other things. The plot should be very fast and responsive once it is displayed and future modifications should also be fast!" + "#### There might be a small delay for a few seconds before the plot shows, this is due to shaders being compiled and a few other things. The plot should be very fast and responsive once it is displayed and future modifications should also be fast!" ] }, { @@ -1129,9 +1324,8 @@ "metadata": {}, "outputs": [], "source": [ - "# create a random distribution\n", - "# only 1,000 points shown here in the docs, but it can be millions\n", - "n_points = 1_000\n", + "# create a random distribution of 10,000 xyz coordinates\n", + "n_points = 10_000\n", "\n", "# if you have a good GPU go for 1.5 million points :D \n", "# this is multiplied by 3\n", @@ -1157,12 +1351,12 @@ "colors = [\"yellow\"] * n_points + [\"cyan\"] * n_points + [\"magenta\"] * n_points\n", "\n", "# create plot\n", - "plot_s = fpl.Plot()\n", - "\n", + "fig_scatter = fpl.Figure()\n", + "subplot_scatter = fig_scatter[0, 0]\n", "# use an alpha value since this will be a lot of points\n", - "scatter_graphic = plot_s.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.7)\n", + "scatter_graphic = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)\n", "\n", - "plot_s.show()" + "fig_scatter.show(sidecar=True)" ] }, { @@ -1170,7 +1364,7 @@ "id": "b6e4a704-ee6b-4316-956e-acb4dcc1c6f2", "metadata": {}, "source": [ - "**Scatter graphic features work similarly to line graphic**" + "### scatter graphic features work similarly to line graphic" ] }, { @@ -1187,13 +1381,12 @@ { "cell_type": "code", "execution_count": null, - "id": "293a4793-44b9-4d18-ae6a-68e7c6f91acc", - "metadata": { - "tags": [] - }, + "id": "a5962263-8032-40ad-9981-fa0a649e2643", + "metadata": {}, "outputs": [], "source": [ - "plot_s.canvas.snapshot()" + "# other half of the first cloud's points to purple\n", + "scatter_graphic.colors[1:n_points:2] = \"purple\"" ] }, { @@ -1210,135 +1403,148 @@ { "cell_type": "code", "execution_count": null, - "id": "5ea7852d-fdae-401b-83b6-b6cfd975f64f", - "metadata": { - "tags": [] - }, + "id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd", + "metadata": {}, "outputs": [], "source": [ - "plot_s.canvas.snapshot()" + "# set color values directly using an array\n", + "scatter_graphic.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)" ] }, { "cell_type": "code", "execution_count": null, - "id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd", + "id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1", "metadata": {}, "outputs": [], "source": [ - "# set color values directly using an array\n", - "scatter_graphic.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)" + "# change the data, change y-values\n", + "scatter_graphic.data[n_points:n_points * 2, 1] += 15" ] }, { "cell_type": "code", "execution_count": null, - "id": "02c19f51-6436-4601-976e-04326df0de81", - "metadata": { - "tags": [] - }, + "id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed", + "metadata": {}, "outputs": [], "source": [ - "plot_s.canvas.snapshot()" + "# set x values directly but using an array\n", + "scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1", + "cell_type": "markdown", + "id": "5f3e206d-97af-4e07-9969-94f2fdb41004", "metadata": {}, - "outputs": [], "source": [ - "# change the data, change y-values\n", - "scatter_graphic.data[n_points:n_points * 2, 1] += 15" + "**Switch to a fly controller to move around the plot in 3D!**" ] }, { "cell_type": "code", "execution_count": null, - "id": "2ec43f58-4710-4603-9358-682c4af3f701", - "metadata": { - "tags": [] - }, + "id": "c67944ca-52e7-4213-b820-6572cc3f76f0", + "metadata": {}, "outputs": [], "source": [ - "plot_s.canvas.snapshot()" + "subplot_scatter.camera = \"3d\"\n", + "subplot_scatter.controller = \"fly\"" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed", + "cell_type": "markdown", + "id": "43ae13f1-d59b-4673-b0b3-669542b4c127", "metadata": {}, - "outputs": [], "source": [ - "# set x values directly but using an array\n", - "scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)" + "## Animation\n", + "\n", + "Move the cloud by a small delta on every render cycle" ] }, { "cell_type": "code", "execution_count": null, - "id": "6bcb3bc3-4b75-4bbc-b8ca-f8a3219ec3d7", - "metadata": { - "tags": [] - }, + "id": "50d2e96f-718c-4925-9e81-a92e81134741", + "metadata": {}, "outputs": [], "source": [ - "plot_s.canvas.snapshot()" + "def update_points(subplot):\n", + " # move every point by a small amount\n", + " deltas = np.random.normal(size=scatter_graphic.data.value.shape, loc=0, scale=0.15)\n", + " scatter_graphic.data = scatter_graphic.data[:] + deltas\n", + "\n", + "subplot_scatter.add_animations(update_points)" ] }, { "cell_type": "markdown", - "id": "d9e554de-c436-4684-a46a-ce8a33d409ac", + "id": "1592c6cd-d10a-4bda-ac4b-e06d428ffa1d", "metadata": {}, "source": [ - "## ipywidget layouts\n", - "\n", - "This just plots everything from these examples in a single output cell" + "Another animation function to cycle the colors of one of the clouds" ] }, { "cell_type": "code", "execution_count": null, - "id": "01a6f70b-c81b-4ee5-8a6b-d979b87227eb", - "metadata": { - "tags": [] - }, + "id": "fb0394a3-47d9-4620-a754-d04d5f313cc7", + "metadata": {}, "outputs": [], "source": [ - "# row1 = HBox([plot.show(), plot_v.show(), plot_sync.show()])\n", - "# row2 = HBox([plot_l.show(), plot_l3d.show(), plot_s.show()])\n", + "i = 0.05\n", + "def cycle_colors(subplot):\n", + " global i\n", + " # cycle the red values\n", + " scatter_graphic.colors[n_points * 2:, 0] = np.abs(np.sin(i))\n", + " scatter_graphic.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4)))\n", + " scatter_graphic.colors[n_points * 2:, 2] = np.abs(np.cos(i))\n", + " i += 0.05\n", "\n", - "# VBox([row1, row2])" + "subplot_scatter.add_animations(cycle_colors)" ] }, { - "cell_type": "markdown", - "id": "a26c0063-b7e0-4f36-bb14-db06bafa31aa", + "cell_type": "code", + "execution_count": null, + "id": "a9ffdde4-4b8e-4ff7-98b3-464cf5462d20", "metadata": {}, + "outputs": [], "source": [ - "## Gridplot\n", - "\n", - "Subplots within a `GridPlot` behave the same as simple `Plot` instances! \n", - "\n", - "💡 `Plot` is actually a subclass of `Subplot`!" + "# close plot\n", + "fig_scatter.close()" + ] + }, + { + "cell_type": "markdown", + "id": "b354b04d", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "source": [ + "## More subplots" ] }, { "cell_type": "code", "execution_count": null, - "id": "6b7e1129-ae8e-4a0f-82dc-bd8fb65871fc", + "id": "e0797523", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "# GridPlot of shape 2 x 3 with all controllers synced\n", - "grid_plot = fpl.GridPlot(shape=(2, 3), controller_ids=\"sync\")\n", + "# Figure of shape 2 x 3 with all controllers synced\n", + "figure_grid = fpl.Figure(shape=(2, 3), controller_ids=\"sync\")\n", "\n", "# Make a random image graphic for each subplot\n", - "for subplot in grid_plot:\n", + "for subplot in figure_grid:\n", " # create image data\n", " data = np.random.rand(512, 512)\n", " # add an image to the subplot\n", @@ -1346,23 +1552,28 @@ "\n", "# Define a function to update the image graphics with new data\n", "# add_animations will pass the gridplot to the animation function\n", - "def update_data(gp):\n", - " for sp in gp:\n", + "def update_data(f):\n", + " for subplot in f:\n", " new_data = np.random.rand(512, 512)\n", " # index the image graphic by name and set the data\n", - " sp[\"rand-img\"].data = new_data\n", - " \n", + " subplot[\"rand-img\"].data = new_data\n", + "\n", "# add the animation function\n", - "grid_plot.add_animations(update_data)\n", + "figure_grid.add_animations(update_data)\n", "\n", - "# show the gridplot \n", - "grid_plot.show()" + "# show the gridplot\n", + "figure_grid.show()" ] }, { "cell_type": "markdown", - "id": "f4f71c34-3925-442f-bd76-60dd57d09f48", - "metadata": {}, + "id": "0c5b20e5", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "### Slicing GridPlot" ] @@ -1370,21 +1581,29 @@ { "cell_type": "code", "execution_count": null, - "id": "d8194c9e-9a99-4d4a-8984-a4cfcab0c42c", + "id": "a14b7e90", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# positional indexing\n", "# row 0 and col 0\n", - "grid_plot[0, 0]" + "figure_grid[0, 0]" ] }, { "cell_type": "markdown", - "id": "d626640f-bc93-4883-9bf4-47b825bbc663", - "metadata": {}, + "id": "45f29bed", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "You can get the graphics within a subplot, just like with simple `Plot`" ] @@ -1392,19 +1611,27 @@ { "cell_type": "code", "execution_count": null, - "id": "bffec80c-e81b-4945-85a2-c2c5e8395677", + "id": "fbe632aa", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics" + "figure_grid[0, 1].graphics" ] }, { "cell_type": "markdown", - "id": "a4e3184f-c86a-4a7e-b803-31632cc163b0", - "metadata": {}, + "id": "44ccf745", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "and change their properties" ] @@ -1412,19 +1639,27 @@ { "cell_type": "code", "execution_count": null, - "id": "04b616fb-6644-42ba-8683-0589ce7d165e", + "id": "85e6bf84", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].vmax = 0.5" + "figure_grid[0, 1].graphics[0].vmax = 0.5" ] }, { "cell_type": "markdown", - "id": "28f7362c-d1b9-43ef-85c5-4d68f70f459c", - "metadata": {}, + "id": "fb4155b9", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "more slicing with `GridPlot`" ] @@ -1432,58 +1667,75 @@ { "cell_type": "code", "execution_count": null, - "id": "920e6365-bb50-4882-9b0d-8367dc485360", + "id": "e6c3af07", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# you can give subplots human-readable string names\n", - "grid_plot[0, 2].name = \"top-right-plot\"" + "figure_grid[0, 2].name = \"top-right-plot\"" ] }, { "cell_type": "code", "execution_count": null, - "id": "73300d2c-3e70-43ad-b5a2-40341b701ac8", + "id": "8848486b", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "grid_plot[\"top-right-plot\"]" + "figure_grid[\"top-right-plot\"]" ] }, { "cell_type": "code", "execution_count": null, - "id": "834d9905-35e9-4711-9375-5b1828c80ee2", + "id": "cb7566a5", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# view its position\n", - "grid_plot[\"top-right-plot\"].position" + "figure_grid[\"top-right-plot\"].position" ] }, { "cell_type": "code", "execution_count": null, - "id": "9aa61efa-c6a5-4611-a03b-1b8da66b19f0", + "id": "a002a426", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# these are really the same\n", - "grid_plot[\"top-right-plot\"] is grid_plot[0, 2]" + "figure_grid[\"top-right-plot\"] is figure_grid[0, 2]" ] }, { "cell_type": "markdown", - "id": "28c8b145-86cb-4445-92be-b7537a87f7ca", - "metadata": {}, + "id": "df361421", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "Indexing with subplot name and graphic name" ] @@ -1491,37 +1743,48 @@ { "cell_type": "code", "execution_count": null, - "id": "2b7b73a3-5335-4bd5-bbef-c7d3cfbb3ca7", + "id": "c9915469", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "grid_plot[\"top-right-plot\"][\"rand-img\"].vmin = 0.5" + "figure_grid[\"top-right-plot\"][\"rand-img\"].vmin = 0.5" ] }, { "cell_type": "markdown", - "id": "6a5b4368-ae4d-442c-a11f-45c70267339b", - "metadata": {}, + "id": "219648d3", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ - "## GridPlot customization" + "## Figure subplot customization" ] }, { "cell_type": "code", "execution_count": null, - "id": "175d45a6-3351-4b75-8ff3-08797fe0a389", + "id": "1dcfe24c", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "# grid with 2 rows and 3 columns\n", - "grid_shape = (2, 3)\n", + "# 2 rows and 3 columns\n", + "shape = (2, 3)\n", "\n", "# pan-zoom controllers for each view\n", - "# views are synced if they have the \n", + "# views are synced if they have the\n", "# same controller ID\n", "controller_ids = [\n", " [0, 3, 1], # id each controller with an integer\n", @@ -1536,21 +1799,21 @@ "]\n", "\n", "# Create the grid plot\n", - "grid_plot = fpl.GridPlot(\n", - " shape=grid_shape,\n", + "figure_grid = fpl.Figure(\n", + " shape=shape,\n", " controller_ids=controller_ids,\n", " names=names,\n", ")\n", "\n", "\n", "# Make a random image graphic for each subplot\n", - "for subplot in grid_plot:\n", + "for subplot in figure_grid:\n", " data = np.random.rand(512, 512)\n", " # create and add an ImageGraphic\n", " subplot.add_image(data=data, name=\"rand-image\")\n", - " \n", "\n", - "# Define a function to update the image graphics \n", + "\n", + "# Define a function to update the image graphics\n", "# with new randomly generated data\n", "def set_random_frame(gp):\n", " for subplot in gp:\n", @@ -1558,14 +1821,19 @@ " subplot[\"rand-image\"].data = new_data\n", "\n", "# add the animation\n", - "grid_plot.add_animations(set_random_frame)\n", - "grid_plot.show()" + "figure_grid.add_animations(set_random_frame)\n", + "figure_grid.show()" ] }, { "cell_type": "markdown", - "id": "4224f1c2-5e61-4894-8d72-0519598a3cef", - "metadata": {}, + "id": "be699284", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "Indexing the gridplot to access subplots" ] @@ -1573,33 +1841,44 @@ { "cell_type": "code", "execution_count": null, - "id": "d88dd9b2-9359-42e8-9dfb-96dcbbb34b95", + "id": "212a6e4f", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# can access subplot by name\n", - "grid_plot[\"subplot0\"]" + "figure_grid[\"subplot0\"]" ] }, { "cell_type": "code", "execution_count": null, - "id": "a14df7ea-14c3-4a8a-84f2-2e2194236d9e", + "id": "b758b240", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# can access subplot by index\n", - "grid_plot[0, 0]" + "figure_grid[0, 0]" ] }, { "cell_type": "markdown", - "id": "5f8a3427-7949-40a4-aec2-38d5d95ef156", - "metadata": {}, + "id": "868f0de4", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "**subplots also support indexing!**\n", "\n", @@ -1609,33 +1888,44 @@ { "cell_type": "code", "execution_count": null, - "id": "8c99fee0-ce46-4f18-8300-af025c9a967c", + "id": "bc14549d", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ "# can access graphic directly via name\n", - "grid_plot[\"subplot0\"][\"rand-image\"]" + "figure_grid[\"subplot0\"][\"rand-image\"]" ] }, { "cell_type": "code", "execution_count": null, - "id": "ed4eebb7-826d-4856-bbb8-db2de966a0c3", + "id": "99e3726e", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "grid_plot[\"subplot0\"][\"rand-image\"].vmin = 0.6\n", - "grid_plot[\"subplot0\"][\"rand-image\"].vmax = 0.8" + "figure_grid[\"subplot0\"][\"rand-image\"].vmin = 0.6\n", + "figure_grid[\"subplot0\"][\"rand-image\"].vmax = 0.8" ] }, { "cell_type": "markdown", - "id": "ad322f6f-e7de-4eb3-a1d9-cf28701a2eae", - "metadata": {}, + "id": "e3350b37", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "positional indexing also works event if subplots have string names" ] @@ -1643,15 +1933,37 @@ { "cell_type": "code", "execution_count": null, - "id": "759d3966-d92b-460f-ba48-e57adabbf163", + "id": "3b1986f5", "metadata": { - "tags": [] + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } }, "outputs": [], "source": [ - "grid_plot[1, 0][\"rand-image\"].vim = 0.1\n", - "grid_plot[1, 0][\"rand-image\"].vmax = 0.3" + "figure_grid[1, 0][\"rand-image\"].vim = 0.1\n", + "figure_grid[1, 0][\"rand-image\"].vmax = 0.3" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "370d5837-aecf-4e52-9323-b899ac458bbf", + "metadata": {}, + "outputs": [], + "source": [ + "# for testing, ignore\n", + "notebook_finished()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52b6c281-ab27-4984-9a6e-f1e27f609e44", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/examples/notebooks/scatter.ipynb b/examples/notebooks/scatter.ipynb index 9d7ff099f..b78521064 100644 --- a/examples/notebooks/scatter.ipynb +++ b/examples/notebooks/scatter.ipynb @@ -20,7 +20,7 @@ "outputs": [], "source": [ "import numpy as np\n", - "from fastplotlib import GridPlot" + "import fastplotlib as fpl" ] }, { @@ -63,7 +63,7 @@ }, "outputs": [], "source": [ - "# grid with 2 rows and 2 columns\n", + "# figure with 2 rows and 2 columns\n", "shape = (2, 2)\n", "\n", "# define the camera\n", @@ -73,8 +73,8 @@ " ['3d', '2d']\n", "]\n", "\n", - "# pan-zoom controllers for each view\n", - "# views are synced if they have the \n", + "# pan-zoom controllers for each subplot\n", + "# subplots are synced if they have the\n", "# same controller ID\n", "# you can only sync controllers that use the same camera type\n", "# i.e. you cannot sync between 2d and 3d subplots\n", @@ -83,21 +83,21 @@ " [1, 0]\n", "]\n", "\n", - "# create the grid plot\n", - "grid_plot = GridPlot(\n", + "# create the figure\n", + "fig = fpl.Figure(\n", " shape=shape,\n", " cameras=cameras,\n", " controller_ids=controller_ids\n", ")\n", "\n", - "for subplot in grid_plot:\n", + "for subplot in fig:\n", " subplot.add_scatter(data=cloud, colors=colors, alpha=0.7, sizes=5)\n", " \n", " subplot.set_axes_visibility(True)\n", " subplot.set_grid_visibility(True)\n", "\n", "\n", - "grid_plot.show()" + "fig.show()" ] }, { @@ -109,7 +109,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[n_points:int(n_points * 1.5)] = \"r\"" + "fig[0, 1].graphics[0].colors[n_points:int(n_points * 1.5)] = \"r\"" ] }, { @@ -121,7 +121,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[:n_points:10] = \"blue\"" + "fig[0, 1].graphics[0].colors[:n_points:10] = \"blue\"" ] }, { @@ -133,7 +133,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[n_points:] = \"green\"" + "fig[0, 1].graphics[0].colors[n_points:] = \"green\"" ] }, { @@ -145,7 +145,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].colors[n_points:, -1] = 0" + "fig[0, 1].graphics[0].colors[n_points:, -1] = 0" ] }, { @@ -157,7 +157,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].data[:n_points] = grid_plot[0, 1].graphics[0].data[n_points * 2:]" + "fig[0, 1].graphics[0].data[:n_points] = fig[0, 1].graphics[0].data[n_points * 2:]" ] }, { diff --git a/examples/notebooks/scatter_sizes_animation.ipynb b/examples/notebooks/scatter_sizes_animation.ipynb index 06a6b11a2..0cd301fb1 100644 --- a/examples/notebooks/scatter_sizes_animation.ipynb +++ b/examples/notebooks/scatter_sizes_animation.ipynb @@ -2,75 +2,39 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "5d9f9913391a42af95d4d43d07c17b19", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "9cd08c319b814934a09fd266a1b6322b", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterOutputContext(children=(JupyterWgpuCanvas(), IpywidgetToolBar(children=(Button(icon='expand-arrows-alt'…" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from time import time\n", "\n", "import numpy as np\n", "import fastplotlib as fpl\n", "\n", - "plot = fpl.Plot()\n", + "fig = fpl.Figure()\n", "\n", "points = np.array([[-1,0,1],[-1,0,1]], dtype=np.float32).swapaxes(0,1)\n", "size_delta_scales = np.array([10, 40, 100], dtype=np.float32)\n", "min_sizes = 6\n", "\n", - "def update_positions():\n", - " current_time = time()\n", - " newPositions = points + np.sin(((current_time / 4) % 1)*np.pi)\n", - " plot.graphics[0].data = newPositions\n", "\n", - "def update_sizes():\n", - " current_time = time()\n", - " sin_sample = np.sin(((current_time / 4) % 1)*np.pi)\n", - " size_delta = sin_sample*size_delta_scales\n", - " plot.graphics[0].sizes = min_sizes + size_delta\n", + "def update_positions(subplot):\n", + " g = subplot.graphics[0]\n", + " g.data[:, :-1] += np.sin(((time() / 4))*np.pi)\n", + "\n", "\n", - "scatter = plot.add_scatter(points, colors=[\"red\", \"green\", \"blue\"], sizes=12)\n", - "plot.add_animations(update_positions, update_sizes)\n", + "def update_sizes(subplot):\n", + " sin_sample = np.abs(np.sin((time() / 1)*np.pi))\n", + " size_delta = sin_sample * size_delta_scales\n", + " subplot.graphics[0].sizes = size_delta\n", "\n", - "plot.camera.width = 12\n", - "plot.show(autoscale=False)" + "\n", + "scatter = fig[0, 0].add_scatter(points, colors=[\"red\", \"green\", \"blue\"], sizes=12)\n", + "fig[0, 0].add_animations(update_positions, update_sizes)\n", + "\n", + "fig[0, 0].camera.width = 12\n", + "fig.show(autoscale=False)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/examples/notebooks/scatter_sizes_grid.ipynb b/examples/notebooks/scatter_sizes_grid.ipynb index e152056c9..21985f189 100644 --- a/examples/notebooks/scatter_sizes_grid.ipynb +++ b/examples/notebooks/scatter_sizes_grid.ipynb @@ -16,18 +16,18 @@ "import numpy as np\n", "import fastplotlib as fpl\n", "\n", - "# grid with 2 rows and 3 columns\n", - "grid_shape = (2,1)\n", + "# figure with 2 rows and 3 columns\n", + "shape = (2, 1)\n", "\n", - "# you can give string names for each subplot within the gridplot\n", + "# you can give string names for each subplot\n", "names = [\n", " [\"scalar_size\"],\n", " [\"array_size\"]\n", "]\n", "\n", - "# Create the grid plot\n", - "plot = fpl.GridPlot(\n", - " shape=grid_shape,\n", + "# Create the figure\n", + "fig = fpl.Figure(\n", + " shape=shape,\n", " names=names,\n", " size=(1000, 1000)\n", ")\n", @@ -39,15 +39,15 @@ "\n", "data = np.column_stack([x_values, y_values])\n", "\n", - "plot[\"scalar_size\"].add_scatter(data=data, sizes=5, colors=\"blue\") # add a set of scalar sizes\n", + "fig[\"scalar_size\"].add_scatter(data=data, sizes=5, colors=\"blue\") # add a set of scalar sizes\n", "\n", "non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5\n", - "plot[\"array_size\"].add_scatter(data=data, sizes=non_scalar_sizes, colors=\"red\")\n", + "fig[\"array_size\"].add_scatter(data=data, sizes=non_scalar_sizes, colors=\"red\")\n", "\n", - "for graph in plot:\n", - " graph.auto_scale(maintain_aspect=True)\n", + "for subplot in fig:\n", + " subplot.auto_scale(maintain_aspect=True)\n", "\n", - "plot.show()" + "fig.show()" ] }, { diff --git a/examples/notebooks/screenshots/nb-astronaut.png b/examples/notebooks/screenshots/nb-astronaut.png index 2faf79def..9c28b6cfa 100644 --- a/examples/notebooks/screenshots/nb-astronaut.png +++ b/examples/notebooks/screenshots/nb-astronaut.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4845a61b99f7a489ac82a8688cc3350ce66e4771bb1399354591fd39688a58a2 -size 127977 +oid sha256:afb405dfcd90d9165b4be8c2b79a82b45964debb119d25851835b8a6e2f18785 +size 111986 diff --git a/examples/notebooks/screenshots/nb-astronaut_RGB.png b/examples/notebooks/screenshots/nb-astronaut_RGB.png index 22b2627cb..1939c12d7 100644 --- a/examples/notebooks/screenshots/nb-astronaut_RGB.png +++ b/examples/notebooks/screenshots/nb-astronaut_RGB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c34bd21fd7bf98bab25431019e8fee30b0f4912b6b4495ad963fb9e107b1f21 -size 125479 +oid sha256:2f86ef886266279ace4672904860bdaeee49dd23498998c8f68ae0b36cecc529 +size 110588 diff --git a/examples/notebooks/screenshots/nb-camera.png b/examples/notebooks/screenshots/nb-camera.png index 32e83f3ba..cfdf2673e 100644 --- a/examples/notebooks/screenshots/nb-camera.png +++ b/examples/notebooks/screenshots/nb-camera.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ce695e954332a9b9122d418645d785900506fc30a897844bdf7fdce0bffb316 -size 89342 +oid sha256:124e52fdb8c200be3295f79331f25a51d423d159a7f8cde1863daa00e54c0894 +size 77665 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png b/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png index 61c3aeb8c..e49ad3c38 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60318615e4850d37a4ffae16ca1e3bbf2985ddafd0dd65ba6fae997e1d123d67 -size 31251 +oid sha256:5acd7eeccbf47af45aa8306befb040f9b53d21f1727e7366b536d73261b407ce +size 43494 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png index d09ce18e4..dfcb98736 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b9187c64b7439f629a87a3828cc46a855e1b4609eca52d5484223d2c24e8bf7 -size 62562 +oid sha256:9ca702fffc4eebea5ba31b77062b60f848c2e5d689568d16b39a62561a0b8b73 +size 134201 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png index d09ce18e4..dfcb98736 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b9187c64b7439f629a87a3828cc46a855e1b4609eca52d5484223d2c24e8bf7 -size 62562 +oid sha256:9ca702fffc4eebea5ba31b77062b60f848c2e5d689568d16b39a62561a0b8b73 +size 134201 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png index d4c299683..787e2757e 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:11eb83e3489a8e9c55fcffb0e67f1cf773e538629ddf98e109601749927caa56 -size 72525 +oid sha256:73bdd6a91ab679dcf237626bc7d3edd267d402ea8de2b6e2c3db7bba9b9418ac +size 169211 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png index ef5db6693..ca2357ddd 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51bc6a90fba5c67935838b8e44dca477e250cbc4ee2b98ddd69f931e683ec17a -size 63906 +oid sha256:afb9c5bfbfbc2ce800d613f779021b0a93d098f415d89157f994cc9b1632361b +size 149454 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png index 86287ea9e..ac3f4cb61 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a2318ab1f242d70045b9784337e4421c635b2345331b6a5e8edc0f32ff15f07 -size 54432 +oid sha256:c3c07d75cd4673e411d814c1dab1e62d6543b26c89f208eed15ccb941bbe3ab2 +size 124795 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png index 14c101960..3a77efced 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c5b5df5b0efb0b3b641129e08429e4a92bd5d2c62b1c63c97337287867d685e -size 50341 +oid sha256:5f39d68bbc2c7d52cc13609ff60274dbfe49bea4d4a03cfbf1d1c15cf7fb8e8c +size 114013 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png index 0df0df92b..e34f9deb3 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2e5a5734992333165ef2d5f6f810d869e157e59fb7f54c8dd5f413ac750a3fa -size 65067 +oid sha256:2825af49b1964fb76dcf2ccd494bb61623df4d5fffad7be30cf389b9b7e6d4bf +size 146186 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png index 0df0df92b..e34f9deb3 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2e5a5734992333165ef2d5f6f810d869e157e59fb7f54c8dd5f413ac750a3fa -size 65067 +oid sha256:2825af49b1964fb76dcf2ccd494bb61623df4d5fffad7be30cf389b9b7e6d4bf +size 146186 diff --git a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png index da3033219..4cd3248a0 100644 --- a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png +++ b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10912f4358278fb26c5efa030a335b0bdb80ebcc2a57fd97838fea3780f9f5d1 -size 143543 +oid sha256:aff55757a29cac06c1c158599681e8c10e27fd772425c6b3137a06d5d604f95e +size 435106 diff --git a/examples/notebooks/screenshots/nb-image-widget-single.png b/examples/notebooks/screenshots/nb-image-widget-single.png index 346c1a987..dd37a74db 100644 --- a/examples/notebooks/screenshots/nb-image-widget-single.png +++ b/examples/notebooks/screenshots/nb-image-widget-single.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf23c969c93bc526244360af4babf72a1377a171f55b1f44443dc026faf12631 -size 134432 +oid sha256:1e70812decf8d1c591b1d97c24346159255e8b5cba5722f9c4d67c5b5aa92a8a +size 403368 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png index 6be1058e9..9be76e5bd 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49b65e35bb12e0bce8e753f6700084b2e1100eb6efd0336afc219d9e26972901 -size 64206 +oid sha256:8d6b97c351f51ee8b0429e7001ba16cb3862c9cfc4f4e0f0227524b8c20d5906 +size 157300 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png index ead51e894..c877ac887 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df13f86bb18ad52f962f8944cd780579440b4e1d40941019632d4f46a4d9dc2d -size 50107 +oid sha256:d74649c5ca7b0401a8e42ffe9b73cebeebdce80953c4790f44a99bfe6624902b +size 71618 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png index 295180169..7613ae2a9 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42676358a0199022972b69263c32a977f90a27ce6c3789d18129c67e9b730679 -size 121369 +oid sha256:e9c99c189dbfffbc3fa24fb6f48015518a2e1c3e681191abb45cf4e29185dcff +size 196855 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png index adf129ab2..e803cdc68 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3d1972c575f1659fc2c611a2c703cb408b74ca47e30c2e4564641dc0a6ad887 -size 76550 +oid sha256:916800ae449d12e875f14be3d13a75db85339524dbd594f9963074b9fc5316ae +size 177769 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png index f123f83a9..5b5ef1009 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e24ea022d48ff41174206e43da911632f38237f6250340f73a7169a43d55f2a6 -size 72238 +oid sha256:3006a07bfbf6276967ca55809788f61a1852db9851f959cc1db00016a9b3747f +size 140019 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png index 8c65f7840..4e8803a7b 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:983cf6804561b4287f6acfdac04e2a31810c9d3191108e2da4623cbb852258d5 -size 56707 +oid sha256:3e55ffde023955d00804a7272a4e963b4d2a74b74fb401962d32c1a29d76bc24 +size 80880 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png index fdc616d07..061195a98 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e6f528bf9508397a97e33d463ccd8b3584d3ebd04499b976b4989e001648626 -size 45174 +oid sha256:405495c384aa52d6e3c8a65237876682f4be62967dce1b6af526e4d069fa44d3 +size 62621 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png index 66d5557ee..0da3abb21 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69f8a085b84ee851ad1579791ff96fe6bc73551d86f376524d1194c43edf819f -size 74941 +oid sha256:7b30ef1dca9711bd72eb28f0035552f93e143a683f818c3f2aec8df4323306e4 +size 178459 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png index 3f53f463b..21ea17c27 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0616c08a8cf008fbc976638cfb7d76d533547bdb6cbfa56071567dac3c7e703d -size 75619 +oid sha256:b3e8fc84f5ea2d5a93bc02e19965781fbe9ec697b660430a5203cb1c91803974 +size 142748 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png index e2f0161b7..ece0fee5f 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4f7893445c9f2d26f126a5ac87c1fdf0737b31fec730f754ba4c771eae5ec5b -size 116744 +oid sha256:7b01f2385991f4941f35d1b913fe54c72cbe42c62522ab181ddb2466b2f2be8d +size 372324 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png index fde3c576b..93dd3b254 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a73e0ef31acf4304bc9a649f0761e0d282eb4517ade89538edfe6b7d8a9b61d0 -size 75487 +oid sha256:4bac6aedfebab2bf97497dbecd17f59b36cb78b27dcdb1547c6d78f902d5f89b +size 213579 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png index c100dcaf3..b6392f095 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00ade44cd4478bf77e7bd33ccee64e2905136e35620171f013be7e90ddaa0ba6 -size 79123 +oid sha256:5458f9488a19207c7d4f8a971de06a028dfb22e4a2847c3a0b1e1f45c41109f0 +size 200566 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png index 08b244755..8165824cb 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1eb4d6a4cfbdbfffc198dbe5987f621e5cc3fdcf574699c544a12cf3b28bcdb7 -size 82281 +oid sha256:8588b720e7d970a0c5d0b9e43c68ee0695d7ced8c51797d50143b0737d3ae2c1 +size 160340 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png index 6868bf1ff..f46e58b4f 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:758c4f444ab3792c6328e51d619c51f59f27fe2589795cc82bdef7fb5daf57e3 -size 79663 +oid sha256:b86bc324f13ca3a958d0db80251874478e0191b0c30c301f3022913e7b1f62d5 +size 147084 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png index 3bcfe6c24..8e3e7e2de 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b75c179afde357fd69d7c3e918ad34e072685c536e388b1424596bd493b041e8 -size 81563 +oid sha256:9993fe8f8d3e6d6e48d863b251fdd7b37926ba7b97b2d70683cbc3ab45910c99 +size 184668 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png index 4a8ee413a..aae5c9066 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6d9460835d55d696ffcef1bff7cb7a86672d4c7345112c62050312669055870 -size 66010 +oid sha256:f4cdb28c8aa72b1cd968f4f78f3c2413d2338b6a2b5c200df02ecdd2bce1568b +size 126337 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png index 984f1537c..346495cfc 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7b83f6353ce227d76fbf7362218bcc35afa26216e5600cf145cbcce666bb6dd6 -size 66520 +oid sha256:19000f2cc6d78e2cc18dd5213778e595ee6710ca3fcd71cb4cbe6286b42b1e8b +size 130255 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png index 2767f8699..2298f904e 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:130c7a58ceee6ffe0ec58f194bce04e6f0892c97448b43ef9f90ced85c9c10ba -size 62566 +oid sha256:4a141cd3e0d3647accb18c55d84026d16ca2280611b80682737a61151dd9c377 +size 99397 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png new file mode 100644 index 000000000..58f4fd87e --- /dev/null +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbd3cb8399c32cc611a86bb482782bfe55393ec73f2c2a3f4eb0d4e8af2442d6 +size 58842 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png new file mode 100644 index 000000000..0eff22834 --- /dev/null +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6e201ecce9db938796d1fc710a154ae8bc49e0a7e1f51d9af586f29f4ee63de +size 57116 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png new file mode 100644 index 000000000..03a1fc30c --- /dev/null +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:608c9a0b1466886652299887a4f0f16a77dfb400fc46200a453df25c5a0e7016 +size 55903 diff --git a/examples/notebooks/screenshots/nb-lines-3d.png b/examples/notebooks/screenshots/nb-lines-3d.png index 727450428..d1e46a618 100644 --- a/examples/notebooks/screenshots/nb-lines-3d.png +++ b/examples/notebooks/screenshots/nb-lines-3d.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1da691b87d324d1a5b2c4f9231be89c0c3dddb0584615a07f28a4d027dc59d5c -size 23057 +oid sha256:91f74b1ad6d4eeb08da8a33bfccfc0e9e80d48fc33b2a783cb94890f3c603a94 +size 14131 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png b/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png index 2f149e7f4..db1a0e658 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-jet-values-cosine.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a755f36126b805b8d63d6ea679ffa270dc20d976a9a3a2cd1420ccdf0e981474 -size 17158 +oid sha256:58af931da3307204f2699b2ac04d8546b93aa0b4d3c058ab6d181656fd79fae8 +size 11674 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png b/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png index 45a34c5f0..9bb734365 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-jet-values.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76e55108ce7ace466fb9b90b852a1102ff8b4c931e16f05af231a854519c8467 -size 18505 +oid sha256:9949949767455061caa08b96dfdf0948d511d604d39ded4a028a9a50deca9797 +size 12990 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-jet.png b/examples/notebooks/screenshots/nb-lines-cmap-jet.png index ed8138ab1..10f9252f3 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-jet.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-jet.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e463ac93808329ffa4fe421838ff8d39ce49081b888d09954405cd170af8a85 -size 16267 +oid sha256:c04746bb9c6e168644981e808b83b878d5d72e2101f441979765c74bb36c087a +size 10979 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png b/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png index e10d6f5e9..a769ff769 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-tab-10.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99c10da11e298d69cd85d587b133fe31a5528657ba456e5f0050ca0e48ed0f31 -size 14865 +oid sha256:704cddf180de18dfc02cccced26dc57a7d8bff3938ceaf5ca9b6db7ccaed5928 +size 9582 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png b/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png index da5693226..861efcef5 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-viridis-values.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d356938c29834263d9879c3217782414d631e998ea02f11f3c00c0f64d8a63a6 -size 15084 +oid sha256:67310ed0deb418bf0d6d10e1184e902f928f0e914518b91c23e948f3bb9e7b25 +size 9850 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-viridis.png b/examples/notebooks/screenshots/nb-lines-cmap-viridis.png index ddcb6d54a..2d71b4428 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-viridis.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-viridis.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfd17b5b191edf58377451b0c34636d047f69de1a89087d9d2d561d967c4d236 -size 19118 +oid sha256:6295649505902ac1f37ae6453e278dbbcdacb64426f1c51e27e16ef38650f8a8 +size 13725 diff --git a/examples/notebooks/screenshots/nb-lines-cmap-white.png b/examples/notebooks/screenshots/nb-lines-cmap-white.png index 93efe221c..b450a8ea4 100644 --- a/examples/notebooks/screenshots/nb-lines-cmap-white.png +++ b/examples/notebooks/screenshots/nb-lines-cmap-white.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3b512a195ab30075a028d19884716c5f776acd6f118e682046d6659d1e1095e -size 12962 +oid sha256:a1abc26476bbabf31094bd70929afc918e4064a1996d7742adb716ed6e9c2617 +size 7532 diff --git a/examples/notebooks/screenshots/nb-lines-colors.png b/examples/notebooks/screenshots/nb-lines-colors.png index a8af1a4be..88fef4e39 100644 --- a/examples/notebooks/screenshots/nb-lines-colors.png +++ b/examples/notebooks/screenshots/nb-lines-colors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c8db3d4ccb1e56919866a6ba510033dc903d23a5af06a961f90ddb7382d16ec7 -size 40477 +oid sha256:bbbb1b63c69ef4061f0b64fc2360e0c613ee4732d581929657068f55141d6fd9 +size 27274 diff --git a/examples/notebooks/screenshots/nb-lines-data.png b/examples/notebooks/screenshots/nb-lines-data.png index 369a999b2..b8c5bf582 100644 --- a/examples/notebooks/screenshots/nb-lines-data.png +++ b/examples/notebooks/screenshots/nb-lines-data.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2907942f9307eb21764ea02d363a8c6c8b7e4fbf375257b7c19225e1f7b66279 -size 54404 +oid sha256:6f677a3c0a1b2fb57771af6118d45d23b1d86f88d3431ca06ef89b79a48dad06 +size 38880 diff --git a/examples/notebooks/screenshots/nb-lines-underlay.png b/examples/notebooks/screenshots/nb-lines-underlay.png index d6b630362..93edd81d6 100644 --- a/examples/notebooks/screenshots/nb-lines-underlay.png +++ b/examples/notebooks/screenshots/nb-lines-underlay.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70800d1739bb5ba7d5d9d2399335ebc1ce8a0874042ed4b6256b6d041014eb18 -size 55884 +oid sha256:35e0ea48cac0242e79da491629bda9fccedb94814e8d3d1188323c7d9668e513 +size 49940 diff --git a/examples/notebooks/screenshots/nb-lines.png b/examples/notebooks/screenshots/nb-lines.png index 2fcd0637f..e28486bf4 100644 --- a/examples/notebooks/screenshots/nb-lines.png +++ b/examples/notebooks/screenshots/nb-lines.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f70a9dd8cc631337ba1ccc7931ef2d412d0d6713d5403011994d59258f61e34 -size 37714 +oid sha256:17ee8c3de59b9e80d66c30d61287a38ac06ee996833f32648506a6bf1ebb0da8 +size 23317 diff --git a/examples/notebooks/simple.ipynb b/examples/notebooks/simple.ipynb deleted file mode 100644 index f5901080b..000000000 --- a/examples/notebooks/simple.ipynb +++ /dev/null @@ -1,1280 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "93740a09-9111-4777-ad57-173e9b80a2f0", - "metadata": { - "tags": [] - }, - "source": [ - "# Introduction to `fastplotlib`\n", - "\n", - "This notebook goes through the basic components of the `fastplotlib` API, image, image updates, line plots, and scatter plots. " - ] - }, - { - "cell_type": "markdown", - "id": "ae07272b-e94b-4262-b486-6b3ddac63038", - "metadata": {}, - "source": [ - "**The example images are from `imageio` so you will need to install it for this example notebook. But `imageio` is not required to use `fasptlotlib`**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6674c90b-bfe3-4a71-ab7d-21e9cc03c050", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "!pip install imageio" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5c50e177-5800-4e19-a4f6-d0e0a082e4cd", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "import imageio.v3 as iio" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from fastplotlib import Plot\n", - "from ipywidgets import VBox, HBox, IntSlider\n", - "import numpy as np" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1d374d5e-70e0-4946-937f-82d16a56009f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# this is only for testing, you do not need this to use fastplotlib\n", - "from nb_test_utils import plot_test, notebook_finished" - ] - }, - { - "cell_type": "markdown", - "id": "a9b386ac-9218-4f8f-97b3-f29b4201ef55", - "metadata": {}, - "source": [ - "## Simple image\n", - "\n", - "We are going to be using `jupyterlab-sidecar` to render some of the plots on the side. This makes it very easy to interact with your plots without having to constantly scroll up and down :D" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "237823b7-e2c0-4e2f-9ee8-e3fc2b4453c4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# create a `Plot` instance\n", - "plot = Plot()\n", - "\n", - "# get a grayscale image\n", - "data = iio.imread(\"imageio:camera.png\")\n", - "\n", - "# plot the image data\n", - "image_graphic = plot.add_image(data=data, name=\"sample-image\")\n", - "\n", - "# show the plot\n", - "plot.show(sidecar=True)" - ] - }, - { - "cell_type": "markdown", - "id": "be5b408f-dd91-4e36-807a-8c22c8d7d216", - "metadata": {}, - "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", - "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`" - ] - }, - { - "cell_type": "markdown", - "id": "7c3b637c-a26b-416e-936c-705275852a8a", - "metadata": {}, - "source": [ - "Changing graphic **\"features\"**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "de816c88-1c4a-4071-8a5e-c46c93671ef5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.cmap = \"viridis\"" - ] - }, - { - "cell_type": "markdown", - "id": "da1efe85-c5b8-42e8-ae81-6cbddccc30f7", - "metadata": {}, - "source": [ - "### Slicing data\n", - "\n", - "**Most features, such as `data` support slicing!**\n", - "\n", - "Out image data is of shape [n_rows, n_cols]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a04afe48-5534-4ef6-a159-f6e6a4337d8d", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data().shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data[::15, :] = 1\n", - "image_graphic.data[:, ::15] = 1" - ] - }, - { - "cell_type": "markdown", - "id": "135db5d2-53fb-4d50-8164-2c1f00560c25", - "metadata": {}, - "source": [ - "**Fancy indexing**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a89120eb-108b-4df3-8d3f-8192c9315aa6", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data[data > 175] = 255" - ] - }, - { - "cell_type": "markdown", - "id": "096ccb73-bf6d-4dba-8168-788a63450406", - "metadata": {}, - "source": [ - "Adjust vmin vmax" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f8e69df8-7aaf-4d7c-92e3-861d9ebc8c5f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.cmap.vmin = 50\n", - "image_graphic.cmap.vmax = 150" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "aa67b34a-2694-4ec0-9ba2-e88c469f1a06", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"camera\", plot)" - ] - }, - { - "cell_type": "markdown", - "id": "da9c9b25-7c8b-49b2-9531-7c741debd71d", - "metadata": {}, - "source": [ - "**Set the entire data array again**\n", - "\n", - "Note: The shape of the new data array must match the current data shown in the Graphic." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "089170fd-016e-4b2f-a090-c30beb85cc1b", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "new_data = iio.imread(\"imageio:astronaut.png\")\n", - "new_data.shape" - ] - }, - { - "cell_type": "markdown", - "id": "d14cf14a-282f-40c6-b086-9bcf332ed0c8", - "metadata": {}, - "source": [ - "This is an RGB image, convert to grayscale to maintain the shape of (512, 512)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ec9b2874-ce1a-49c6-9b84-ee8f14d55966", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "gray = new_data.dot([0.3, 0.6, 0.1])\n", - "gray.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8a8fc1d3-19ba-42c0-b9ec-39f6ddd23314", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data = gray" - ] - }, - { - "cell_type": "markdown", - "id": "bb568f89-ac92-4dde-9359-789049dc758a", - "metadata": {}, - "source": [ - "\n", - "\n", - "reset vmin vmax" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "de09d977-88ea-472c-8d89-9e24abc845a9", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.cmap.reset_vmin_vmax()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9cf84998-03e1-41b3-8e63-92d5b59426e6", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"astronaut\", plot)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0bb1cfc7-1a06-4abb-a10a-a877a0d51c6b", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.canvas.get_logical_size()" - ] - }, - { - "cell_type": "markdown", - "id": "b53bc11a-ddf1-4786-8dca-8f3d2eaf993d", - "metadata": {}, - "source": [ - "### Indexing plots" - ] - }, - { - "cell_type": "markdown", - "id": "67b92ffd-40cc-43fe-9df9-0e0d94763d8e", - "metadata": {}, - "source": [ - "**Plots are indexable and give you their graphics by name**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot[\"sample-image\"]" - ] - }, - { - "cell_type": "markdown", - "id": "a64314bf-a737-4858-803b-ea2adbd3578c", - "metadata": {}, - "source": [ - "**You can also use numerical indexing on `plot.graphics`**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c09a1924-70f8-4d9e-9e92-510d700ac715", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.graphics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ec9e6ba6-553f-4718-ba13-471c8c7c3c4e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.graphics[0]" - ] - }, - { - "cell_type": "markdown", - "id": "4316a8b5-5f33-427a-8f52-b101d1daab67", - "metadata": {}, - "source": [ - "The `Graphic` instance is also returned when you call `plot.add_`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic == plot[\"sample-image\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "058d9785-a692-46f6-a062-cdec9c040afe", - "metadata": {}, - "outputs": [], - "source": [ - "# close the plot\n", - "plot.close()" - ] - }, - { - "cell_type": "markdown", - "id": "5694dca1-1041-4e09-a1da-85b293c5af47", - "metadata": {}, - "source": [ - "### RGB images are also supported\n", - "\n", - "`cmap` arguments are ignored for rgb images, but vmin vmax still works" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d6b8ca51-073d-47aa-a464-44511fcaccbc", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_rgb = Plot()\n", - "\n", - "plot_rgb.add_image(new_data, name=\"rgb-image\")\n", - "\n", - "# show the plot\n", - "plot_rgb.show()" - ] - }, - { - "cell_type": "markdown", - "id": "7fc66377-00e8-4f32-9671-9cf63f74529f", - "metadata": {}, - "source": [ - "vmin and vmax are still applicable to rgb images" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cafaa403-50a2-403c-b8e7-b0938d48cadd", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_rgb[\"rgb-image\"].cmap.vmin = 100" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b8d600c7-aa80-4c3f-8ec0-6641e9359c3a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"astronaut_RGB\", plot_rgb)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8316b4f2-3d6e-46b5-8776-c7c963a7aa99", - "metadata": {}, - "outputs": [], - "source": [ - "# close plot\n", - "plot_rgb.close()" - ] - }, - { - "cell_type": "markdown", - "id": "1cb03f42-1029-4b16-a16b-35447d9e2955", - "metadata": { - "tags": [] - }, - "source": [ - "### Image updates\n", - "\n", - "This examples show how you can define animation functions that run on every render cycle." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "aadd757f-6379-4f52-a709-46aa57c56216", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# create another `Plot` instance\n", - "plot_v = Plot()\n", - "\n", - "plot.canvas.max_buffered_frames = 1\n", - "\n", - "# make some random data again\n", - "data = np.random.rand(512, 512)\n", - "\n", - "# plot the data\n", - "plot_v.add_image(data=data, name=\"random-image\")\n", - "\n", - "# a function to update the image_graphic\n", - "# a plot will pass its plot instance to the animation function as an argument\n", - "def update_data(plot_instance):\n", - " new_data = np.random.rand(512, 512)\n", - " plot_instance[\"random-image\"].data = new_data\n", - "\n", - "#add this as an animation function\n", - "plot_v.add_animations(update_data)\n", - "\n", - "# show the plot\n", - "plot_v.show()" - ] - }, - { - "cell_type": "markdown", - "id": "b313eda1-6e6c-466f-9fd5-8b70c1d3c110", - "metadata": {}, - "source": [ - "### We can share controllers across plots\n", - "\n", - "This example creates a new plot, but it synchronizes the pan-zoom controller" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "86e70b1e-4328-4035-b992-70dff16d2a69", - "metadata": {}, - "outputs": [], - "source": [ - "plot_sync = Plot(controller=plot_v.controller)\n", - "\n", - "data = np.random.rand(512, 512)\n", - "\n", - "image_graphic_instance = plot_sync.add_image(data=data, cmap=\"viridis\")\n", - "\n", - "# you will need to define a new animation function for this graphic\n", - "def update_data_2():\n", - " new_data = np.random.rand(512, 512)\n", - " # alternatively, you can use the stored reference to the graphic as well instead of indexing the Plot\n", - " image_graphic_instance.data = new_data\n", - "\n", - "plot_sync.add_animations(update_data_2)\n", - "\n", - "plot_sync.show()" - ] - }, - { - "cell_type": "markdown", - "id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5", - "metadata": {}, - "source": [ - "#### Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" - ] - }, - { - "cell_type": "markdown", - "id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d", - "metadata": {}, - "source": [ - "### 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": "11839d95-8ff7-444c-ae13-6b072c3112c5", - "metadata": {}, - "outputs": [], - "source": [ - "HBox([plot_v.show(), plot_sync.show()])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f33f4cd9-02fc-41b7-961b-9dfeb455b63a", - "metadata": {}, - "outputs": [], - "source": [ - "# close plot\n", - "plot_v.close()\n", - "plot_sync.close()" - ] - }, - { - "cell_type": "markdown", - "id": "e7859338-8162-408b-ac72-37e606057045", - "metadata": { - "tags": [] - }, - "source": [ - "# Line plots\n", - "\n", - "## 2D line plots\n", - "\n", - "This example plots a sine wave, cosine wave, and ricker wavelet and demonstrates how **Graphic Features** can be modified by slicing!" - ] - }, - { - "cell_type": "markdown", - "id": "a6fee1c2-4a24-4325-bca2-26e5a4bf6338", - "metadata": {}, - "source": [ - "### First generate some data." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8e8280da-b421-43a5-a1a6-2a196a408e9a", - "metadata": {}, - "outputs": [], - "source": [ - "# linspace, create 100 evenly spaced x values from -10 to 10\n", - "xs = np.linspace(-10, 10, 100)\n", - "# sine wave\n", - "ys = np.sin(xs)\n", - "sine = np.dstack([xs, ys])[0]\n", - "\n", - "# cosine wave\n", - "ys = np.cos(xs) + 5\n", - "cosine = np.dstack([xs, ys])[0]\n", - "\n", - "# sinc function\n", - "a = 0.5\n", - "ys = np.sinc(xs) * 3 + 8\n", - "sinc = np.dstack([xs, ys])[0]" - ] - }, - { - "cell_type": "markdown", - "id": "fbb806e5-1565-4189-936c-b7cf147a59ee", - "metadata": {}, - "source": [ - "### We will plot all of it on the same plot. Each line plot will be an individual Graphic, you can have any combination of graphics on a plot." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7", - "metadata": {}, - "outputs": [], - "source": [ - "# Create a plot instance\n", - "plot_l = Plot()\n", - "\n", - "# plot sine wave, use a single color\n", - "sine_graphic = plot_l.add_line(data=sine, thickness=5, colors=\"magenta\")\n", - "\n", - "# you can also use colormaps for lines!\n", - "cosine_graphic = plot_l.add_line(data=cosine, thickness=12, cmap=\"autumn\")\n", - "\n", - "# or a list of colors for each datapoint\n", - "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n", - "sinc_graphic = plot_l.add_line(data=sinc, thickness=5, colors = colors)\n", - "\n", - "# show the plot\n", - "plot_l.show(sidecar=True, sidecar_kwargs={\"title\": \"lines\"})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a4060576-2f29-4e4b-a86a-0410c766bd98", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"lines\", plot_l)" - ] - }, - { - "cell_type": "markdown", - "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", - "metadata": {}, - "source": [ - "### \"stretching\" the camera, useful for large timeseries data\n", - "\n", - "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", - "\n", - "You can also click the **`1:1`** button to toggle this, or use `plot.camera.maintain_aspect`" - ] - }, - { - "cell_type": "markdown", - "id": "1651e965-f750-47ac-bf53-c23dae84cc98", - "metadata": {}, - "source": [ - "### reset the plot area" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ba50a6ed-0f1b-4795-91dd-a7c3e40b8e3c", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_l.auto_scale(maintain_aspect=True)" - ] - }, - { - "cell_type": "markdown", - "id": "dcd68796-c190-4c3f-8519-d73b98ff6367", - "metadata": {}, - "source": [ - "## Graphic features support slicing! :D " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", - "metadata": {}, - "outputs": [], - "source": [ - "# indexing of colors\n", - "cosine_graphic.colors[:15] = \"magenta\"\n", - "cosine_graphic.colors[90:] = \"red\"\n", - "cosine_graphic.colors[60] = \"w\"\n", - "\n", - "# indexing to assign colormaps to entire lines or segments\n", - "sinc_graphic.cmap[10:50] = \"gray\"\n", - "sine_graphic.cmap = \"seismic\"\n", - "\n", - "# more complex indexing, set the blue value directly from an array\n", - "cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65)" - ] - }, - { - "cell_type": "markdown", - "id": "c9689887-cdf3-4a4d-948f-7efdb09bde4e", - "metadata": {}, - "source": [ - "## You can capture changes to a graphic feature as events" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cfa001f6-c640-4f91-beb0-c19b030e503f", - "metadata": {}, - "outputs": [], - "source": [ - "def callback_func(event_data):\n", - " print(event_data)\n", - "\n", - "# Will print event data when the color changes\n", - "cosine_graphic.colors.add_event_handler(callback_func)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d", - "metadata": {}, - "outputs": [], - "source": [ - "# more complex indexing of colors\n", - "# from point 15 - 30, set every 3rd point as \"cyan\"\n", - "cosine_graphic.colors[15:50:3] = \"cyan\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef8cab1b-8327-43e2-b021-176125b91ca9", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"lines-colors\", plot_l)" - ] - }, - { - "cell_type": "markdown", - "id": "c29f81f9-601b-49f4-b20c-575c56e58026", - "metadata": {}, - "source": [ - "## Graphic _data_ is also indexable" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", - "metadata": {}, - "outputs": [], - "source": [ - "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", - "cosine_graphic.data[90:, 1] = 7" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", - "metadata": {}, - "outputs": [], - "source": [ - "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "96086bd4-cdaa-467d-a68b-1f57002ad6c5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"lines-data\", plot_l)" - ] - }, - { - "cell_type": "markdown", - "id": "3f6d264b-1b03-407e-9d83-cd6cfb02e706", - "metadata": {}, - "source": [ - "### Toggle the presence of a graphic within the scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = False" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = True" - ] - }, - { - "cell_type": "markdown", - "id": "86f4e535-ce88-415a-b8d2-53612a2de7b9", - "metadata": {}, - "source": [ - "### You can create callbacks to this too, for example to re-scale the plot w.r.t. graphics that are present in the scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "64a20a16-75a5-4772-a849-630ade9be4ff", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present.add_event_handler(plot_l.auto_scale)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = False" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = True" - ] - }, - { - "cell_type": "markdown", - "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", - "metadata": {}, - "source": [ - "### You can set the z-positions of graphics to have them appear under other graphics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6bb33406-5bef-455b-86ea-358a7d3ffa94", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "img = iio.imread(\"imageio:camera.png\")\n", - "\n", - "plot_l.add_image(img[::20, ::20], name=\"image\", cmap=\"gray\")\n", - "\n", - "# z axis position -1 so it is below all the lines\n", - "plot_l[\"image\"].position_z = -1\n", - "plot_l[\"image\"].position_x = -8\n", - "plot_l[\"image\"].position_y = -8" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ae3e3dc9-e49b-430a-8471-5d0a0d659d20", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"lines-underlay\", plot_l)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bef729ea-f524-4efd-a189-bfca23b39af5", - "metadata": {}, - "outputs": [], - "source": [ - "# close plot\n", - "plot_l.close()" - ] - }, - { - "cell_type": "markdown", - "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", - "metadata": {}, - "source": [ - "### 3D line plot" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", - "metadata": {}, - "outputs": [], - "source": [ - "# just set the camera as \"3d\", the rest is basically the same :D \n", - "plot_l3d = Plot(camera='3d')\n", - "\n", - "# create a spiral\n", - "phi = np.linspace(0, 30, 200)\n", - "\n", - "xs = phi * np.cos(phi)\n", - "ys = phi * np.sin(phi)\n", - "zs = phi\n", - "\n", - "# use 3D data\n", - "# note: you usually mix 3D and 2D graphics on the same plot\n", - "spiral = np.dstack([xs, ys, zs])[0]\n", - "\n", - "plot_l3d.add_line(data=spiral, thickness=2, cmap='winter')\n", - "\n", - "plot_l3d.show()" - ] - }, - { - "cell_type": "markdown", - "id": "29f07af0-cdcb-47cc-bbb3-2fa4449fa084", - "metadata": {}, - "source": [ - "**Use WASD keys and the mouse to move around, just like in a game :D. Use the mouse weel to control the speed of movement.**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28eb7014-4773-4a34-8bfc-bd3a46429012", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_l3d.auto_scale(maintain_aspect=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5135f3f1-a004-4451-86cd-ead6acea6e13", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# testing cell, ignore\n", - "plot_test(\"lines-3d\", plot_l3d)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7a6da884-8b40-4ebf-837f-929b3e9cf4c4", - "metadata": {}, - "outputs": [], - "source": [ - "# change the FOV of the persepctive camera\n", - "plot_l3d.camera.fov = 70" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c2c70541-98fe-4e02-a718-ac2857cc25be", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# close plot\n", - "plot_l3d.close()" - ] - }, - { - "cell_type": "markdown", - "id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d", - "metadata": {}, - "source": [ - "# Scatter plots\n", - "\n", - "### Plot tens of thousands or millions of points\n", - "\n", - "#### There might be a small delay for a few seconds before the plot shows, this is due to shaders being compiled and a few other things. The plot should be very fast and responsive once it is displayed and future modifications should also be fast!" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2ecb2385-8fa4-4239-881c-b754c24aed9f", - "metadata": {}, - "outputs": [], - "source": [ - "from fastplotlib import Plot\n", - "from ipywidgets import VBox, HBox, IntSlider\n", - "import numpy as np" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "39252df5-9ae5-4132-b97b-2785c5fa92ea", - "metadata": {}, - "outputs": [], - "source": [ - "# create a random distribution of 10,000 xyz coordinates\n", - "n_points = 10_000\n", - "\n", - "# if you have a good GPU go for 1.5 million points :D \n", - "# this is multiplied by 3\n", - "#n_points = 500_000\n", - "\n", - "# dimensions always have to be [n_points, xyz]\n", - "dims = (n_points, 3)\n", - "\n", - "clouds_offset = 15\n", - "\n", - "# create some random clouds\n", - "normal = np.random.normal(size=dims, scale=5)\n", - "# stack the data into a single array\n", - "cloud = np.vstack(\n", - " [\n", - " normal - clouds_offset,\n", - " normal,\n", - " normal + clouds_offset,\n", - " ]\n", - ")\n", - "\n", - "# color each of them separately\n", - "colors = [\"yellow\"] * n_points + [\"cyan\"] * n_points + [\"magenta\"] * n_points\n", - "\n", - "# create plot\n", - "plot_s = Plot()\n", - "\n", - "# use an alpha value since this will be a lot of points\n", - "scatter_graphic = plot_s.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.7)\n", - "\n", - "plot_s.show()" - ] - }, - { - "cell_type": "markdown", - "id": "b6e4a704-ee6b-4316-956e-acb4dcc1c6f2", - "metadata": {}, - "source": [ - "### scatter graphic features work similarly to line graphic" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8fa46ec0-8680-44f5-894c-559de3145932", - "metadata": {}, - "outputs": [], - "source": [ - "# half of the first cloud's points to red\n", - "scatter_graphic.colors[:n_points:2] = \"r\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e4dc71e4-5144-436f-a464-f2a29eee8f0b", - "metadata": {}, - "outputs": [], - "source": [ - "# set the green value directly\n", - "scatter_graphic.colors[n_points:n_points * 2, 1] = 0.3" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd", - "metadata": {}, - "outputs": [], - "source": [ - "# set color values directly using an array\n", - "scatter_graphic.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1", - "metadata": {}, - "outputs": [], - "source": [ - "# change the data, change y-values\n", - "scatter_graphic.data[n_points:n_points * 2, 1] += 15" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed", - "metadata": {}, - "outputs": [], - "source": [ - "# set x values directly but using an array\n", - "scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)" - ] - }, - { - "cell_type": "markdown", - "id": "5f3e206d-97af-4e07-9969-94f2fdb41004", - "metadata": {}, - "source": [ - "**Switch to a fly controller to move around the plot in 3D!**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c67944ca-52e7-4213-b820-6572cc3f76f0", - "metadata": {}, - "outputs": [], - "source": [ - "plot_s.camera = \"3d\"\n", - "plot_s.controller = \"fly\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a9ffdde4-4b8e-4ff7-98b3-464cf5462d20", - "metadata": {}, - "outputs": [], - "source": [ - "# close plot\n", - "plot_s.close()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "370d5837-aecf-4e52-9323-b899ac458bbf", - "metadata": {}, - "outputs": [], - "source": [ - "# for testing, ignore\n", - "notebook_finished()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "52b6c281-ab27-4984-9a6e-f1e27f609e44", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/notebooks/gridplot.ipynb b/examples/notebooks/subplots.ipynb similarity index 70% rename from examples/notebooks/gridplot.ipynb rename to examples/notebooks/subplots.ipynb index f1ceb2180..c9774029f 100644 --- a/examples/notebooks/gridplot.ipynb +++ b/examples/notebooks/subplots.ipynb @@ -5,7 +5,7 @@ "id": "3cfc2d9f-6a09-42f4-a47c-3ba51f1a1801", "metadata": {}, "source": [ - "### More in-depth on `GridPlot`" + "### More in-depth on subplots with a Figure" ] }, { @@ -16,7 +16,7 @@ "outputs": [], "source": [ "import numpy as np\n", - "from fastplotlib import GridPlot" + "import fastplotlib as fpl" ] }, { @@ -27,10 +27,10 @@ "outputs": [], "source": [ "# grid with 2 rows and 3 columns\n", - "grid_shape = (2, 3)\n", + "shape = (2, 3)\n", "\n", - "# pan-zoom controllers for each view\n", - "# views are synced if they have the \n", + "# pan-zoom controllers for each subplot\n", + "# subplots are synced if they have the\n", "# same controller ID\n", "controller_ids = [\n", " [0, -3, 1], # id each controller with an integer\n", @@ -44,22 +44,22 @@ "]\n", "\n", "\n", - "# you can give string names for each subplot within the gridplot\n", + "# you can give string names for each subplot within the figure\n", "names = [\n", " [\"subplot0\", \"subplot1\", \"subplot2\"],\n", " [\"subplot3\", \"subplot4\", \"subplot5\"]\n", "]\n", "\n", - "# Create the grid plot\n", - "grid_plot = GridPlot(\n", - " shape=grid_shape,\n", + "# Create the figure\n", + "fig = fpl.Figure(\n", + " shape=shape,\n", " controller_ids=controller_ids,\n", " names=names,\n", ")\n", "\n", "\n", "# Make a random image graphic for each subplot\n", - "for subplot in grid_plot:\n", + "for subplot in fig:\n", " data = np.random.rand(512, 512)\n", " # create and add an ImageGraphic\n", " subplot.add_image(data=data, name=\"rand-image\")\n", @@ -73,8 +73,8 @@ " subplot[\"rand-image\"].data = new_data\n", "\n", "# add the animation\n", - "grid_plot.add_animations(set_random_frame)\n", - "grid_plot.show()" + "fig.add_animations(set_random_frame)\n", + "fig.show()" ] }, { @@ -82,18 +82,20 @@ "id": "2867bcd6-7691-4073-91d9-9c33e8fdb896", "metadata": {}, "source": [ - "### Indexing the gridplot to access subplots" + "### Accessing subplots" ] }, { "cell_type": "code", "execution_count": null, "id": "2a6f7eb5-776e-42a6-b6c2-c26009a26795", - "metadata": {}, + "metadata": { + "is_executing": true + }, "outputs": [], "source": [ - "# can access subplot by name\n", - "grid_plot[\"subplot0\"]" + "# by name\n", + "fig[\"subplot0\"]" ] }, { @@ -103,8 +105,8 @@ "metadata": {}, "outputs": [], "source": [ - "# can access subplot by index\n", - "grid_plot[0, 0]" + "# by index\n", + "fig[0, 0]" ] }, { @@ -112,7 +114,7 @@ "id": "3272b8b3-3063-47a4-94c8-15ceeeaecc69", "metadata": {}, "source": [ - "## subplots also support indexing!\n", + "## getting graphics within subplots!\n", "this can be used to get graphics if they are named" ] }, @@ -124,7 +126,7 @@ "outputs": [], "source": [ "# can access graphic directly via name\n", - "grid_plot[\"subplot0\"][\"rand-image\"]" + "fig[\"subplot0\"][\"rand-image\"]" ] }, { @@ -134,36 +136,62 @@ "metadata": {}, "outputs": [], "source": [ - "grid_plot[\"subplot0\"][\"rand-image\"].cmap.vmin = 0.6\n", - "grid_plot[\"subplot0\"][\"rand-image\"].cmap.vmax = 0.8" + "fig[\"subplot0\"][\"rand-image\"].vmin = 0.6\n", + "fig[\"subplot0\"][\"rand-image\"].vmax = 0.8" ] }, { "cell_type": "markdown", - "id": "516a46e1-cc53-4137-b49b-d5fb94e212d7", - "metadata": {}, + "id": "39c8a5acbad7980b", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ - "### positional indexing also works event if subplots have string names" + "If they are not named use .graphics" ] }, { "cell_type": "code", "execution_count": null, - "id": "2fafe992-4783-40f2-b044-26a2835dd50a", - "metadata": {}, + "id": "d27af25002237db5", + "metadata": { + "collapsed": false, + "is_executing": true, + "jupyter": { + "outputs_hidden": false + } + }, "outputs": [], "source": [ - "grid_plot[1, 0][\"rand-image\"].cmap.vim = 0.1\n", - "grid_plot[1, 0][\"rand-image\"].cmap.vmax = 0.3" + "fig[\"subplot0\"].graphics" + ] + }, + { + "cell_type": "markdown", + "id": "2299a8ae23e39c37", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "source": [ + "### positional indexing also works" ] }, { "cell_type": "code", "execution_count": null, - "id": "a61e34a5-ee1b-4abb-8718-ec4715ffaa52", + "id": "2fafe992-4783-40f2-b044-26a2835dd50a", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "fig[1, 0][\"rand-image\"].vim = 0.1\n", + "fig[1, 0][\"rand-image\"].vmax = 0.3" + ] } ], "metadata": { diff --git a/examples/notebooks/gridplot_simple.ipynb b/examples/notebooks/subplots_simple.ipynb similarity index 81% rename from examples/notebooks/gridplot_simple.ipynb rename to examples/notebooks/subplots_simple.ipynb index 74807f55a..9ff4e4284 100644 --- a/examples/notebooks/gridplot_simple.ipynb +++ b/examples/notebooks/subplots_simple.ipynb @@ -5,7 +5,7 @@ "id": "0e42f03b-9cdf-484f-b158-78b07fdf524d", "metadata": {}, "source": [ - "## This notebook shows how you can use more of the `fastplotlib` API to create `Graphic` objects and add them to a `GridPlot`" + "## This notebook shows how you can use more of the `fastplotlib` API to create `Graphic` objects and add them to subplots" ] }, { @@ -22,7 +22,7 @@ "outputs": [], "source": [ "import numpy as np\n", - "from fastplotlib import GridPlot" + "import fastplotlib as fpl" ] }, { @@ -38,29 +38,29 @@ }, "outputs": [], "source": [ - "# GridPlot of shape 2 x 3 with all controllers synced\n", - "grid_plot = GridPlot(shape=(2, 3), controller_ids=\"sync\")\n", + "# Figure of shape 2 x 3 with all controllers synced\n", + "fig = fpl.Figure(shape=(2, 3), controller_ids=\"sync\")\n", "\n", "# Make a random image graphic for each subplot\n", - "for subplot in grid_plot:\n", + "for subplot in fig:\n", " # create image data\n", " data = np.random.rand(512, 512)\n", " # add an image to the subplot\n", " subplot.add_image(data, name=\"rand-img\")\n", "\n", "# Define a function to update the image graphics with new data\n", - "# add_animations will pass the gridplot to the animation function\n", - "def update_data(gp):\n", - " for sp in gp:\n", + "# add_animations will pass the figure to the animation function\n", + "def update_data(f):\n", + " for sp in f:\n", " new_data = np.random.rand(512, 512)\n", " # index the image graphic by name and set the data\n", " sp[\"rand-img\"].data = new_data\n", " \n", "# add the animation function\n", - "grid_plot.add_animations(update_data)\n", + "fig.add_animations(update_data)\n", "\n", - "# show the gridplot \n", - "grid_plot.show()" + "# show the figure\n", + "fig.show()" ] }, { @@ -68,7 +68,7 @@ "id": "e7801781-c3e9-490f-ab12-1cd2f480d3e9", "metadata": {}, "source": [ - "## Accessing subplots within `GridPlot`" + "## Accessing subplots within `Figure`" ] }, { @@ -78,7 +78,7 @@ "metadata": {}, "outputs": [], "source": [ - "grid_plot" + "fig" ] }, { @@ -92,7 +92,7 @@ "source": [ "# positional indexing\n", "# row 0 and col 0\n", - "grid_plot[0, 0]" + "fig[0, 0]" ] }, { @@ -100,7 +100,7 @@ "id": "276dfede-e9bc-4488-b9e6-3ca5cf91e4dc", "metadata": {}, "source": [ - "### You can get the graphics within a subplot, just like with simple `Plot`" + "### You can get the graphics within a subplot" ] }, { @@ -112,7 +112,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics" + "fig[0, 1].graphics" ] }, { @@ -132,7 +132,7 @@ }, "outputs": [], "source": [ - "grid_plot[0, 1].graphics[0].cmap.vmax = 0.5" + "fig[0, 1].graphics[0].vmax = 0.5" ] }, { @@ -140,7 +140,7 @@ "id": "00506fa1-2dc0-4435-96a0-e50667d3174f", "metadata": {}, "source": [ - "### more indexing with `GridPlot`" + "### more indexing" ] }, { @@ -153,7 +153,7 @@ "outputs": [], "source": [ "# you can give subplots human-readable string names\n", - "grid_plot[0, 2].name = \"top-right-plot\"" + "fig[0, 2].name = \"top-right-plot\"" ] }, { @@ -165,7 +165,7 @@ }, "outputs": [], "source": [ - "grid_plot[\"top-right-plot\"]" + "fig[\"top-right-plot\"]" ] }, { @@ -178,7 +178,7 @@ "outputs": [], "source": [ "# view its position\n", - "grid_plot[\"top-right-plot\"].position" + "fig[\"top-right-plot\"].position" ] }, { @@ -191,7 +191,7 @@ "outputs": [], "source": [ "# these are really the same\n", - "grid_plot[\"top-right-plot\"] is grid_plot[0, 2]" + "fig[\"top-right-plot\"] is fig[0, 2]" ] }, { @@ -211,7 +211,7 @@ }, "outputs": [], "source": [ - "grid_plot[\"top-right-plot\"][\"rand-img\"].cmap.vmin = 0.5" + "fig[\"top-right-plot\"][\"rand-img\"].vmin = 0.5" ] }, { @@ -223,7 +223,7 @@ }, "outputs": [], "source": [ - "grid_plot.close()" + "fig.close()" ] }, { diff --git a/examples/notebooks/test_gc.ipynb b/examples/notebooks/test_gc.ipynb new file mode 100644 index 000000000..57d7bb576 --- /dev/null +++ b/examples/notebooks/test_gc.ipynb @@ -0,0 +1,206 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "9dfba6cf-38af-4003-90b9-463c0cb1063f", + "metadata": {}, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "import numpy as np\n", + "import pytest" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7552eedc-3b9b-4682-8e3b-7d44e0e5510d", + "metadata": {}, + "outputs": [], + "source": [ + "def test_references(plot_objects):\n", + " for i in range(len(plot_objects)):\n", + " with pytest.raises(ReferenceError) as failure:\n", + " plot_objects[i]\n", + " pytest.fail(f\"GC failed for object: {objects[i]}\")" + ] + }, + { + "cell_type": "markdown", + "id": "948108e8-a4fa-4dc7-9953-a956428128cf", + "metadata": {}, + "source": [ + "# Add graphics and selectors, add feature event handlers, test gc occurs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3d96bf14-b484-455e-bcd7-5b2fe7b45fb4", + "metadata": {}, + "outputs": [], + "source": [ + "xs = np.linspace(0, 20 * np.pi, 1_000)\n", + "ys = np.sin(xs)\n", + "zs = np.zeros(xs.size)\n", + "\n", + "points_data = np.column_stack([xs, ys, zs])\n", + "\n", + "line_collection_data = [points_data[:, 1].copy() for i in range(10)]\n", + "\n", + "img_data = np.random.rand(2_000, 2_000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "284b83e1-8cfc-4105-b7c2-6214137dab31", + "metadata": {}, + "outputs": [], + "source": [ + "fig = fpl.Figure((2, 2))\n", + "\n", + "line = fig[0, 0].add_line(points_data, name=\"line\")\n", + "scatter = fig[0, 1].add_scatter(points_data.copy(), name=\"scatter\")\n", + "line_stack = fig[1, 0].add_line_stack(line_collection_data, name=\"line-stack\")\n", + "image = fig[1, 1].add_image(img_data, name=\"image\")\n", + "\n", + "linear_sel = line.add_linear_selector(name=\"line_linear_sel\")\n", + "linear_region_sel = line.add_linear_region_selector(name=\"line_region_sel\")\n", + "\n", + "linear_sel2 = line_stack.add_linear_selector(name=\"line-stack_linear_sel\")\n", + "linear_region_sel2 = line_stack.add_linear_region_selector(name=\"line-stack_region_sel\")\n", + "\n", + "linear_sel_img = image.add_linear_selector(name=\"image_linear_sel\")\n", + "linear_region_sel_img = image.add_linear_region_selector(name=\"image_linear_region_sel\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb2083c1-f6b7-417c-86b8-9980819917db", + "metadata": {}, + "outputs": [], + "source": [ + "def feature_changed_handler(ev):\n", + " pass\n", + "\n", + "\n", + "objects = list()\n", + "for subplot in fig:\n", + " objects += subplot.objects\n", + "\n", + "\n", + "for g in objects:\n", + " for feature in g._features:\n", + " # if isinstance(g, fpl.LineCollection):?\n", + " # continue # skip collections for now\n", + " \n", + " g.add_event_handler(feature_changed_handler, feature)\n", + "\n", + "fig.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba9fffeb-45bd-4a0c-a941-e7c7e68f2e55", + "metadata": {}, + "outputs": [], + "source": [ + "fig.clear()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e33bf32d-b13a-474b-92ca-1d1e1c7b820b", + "metadata": {}, + "outputs": [], + "source": [ + "test_references(objects)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8078a7d2-9bc6-48a1-896c-7e169c5bbdcf", + "metadata": {}, + "outputs": [], + "source": [ + "movies = [np.random.rand(100, 100, 100) for i in range(6)]\n", + "\n", + "iw = fpl.ImageWidget(movies)\n", + "\n", + "# add some events onto all the image graphics\n", + "for g in iw.managed_graphics:\n", + " for f in g._features:\n", + " g.add_event_handler(feature_changed_handler, f)\n", + "\n", + "iw.show()" + ] + }, + { + "cell_type": "markdown", + "id": "189bcd7a-40a2-4e84-abcf-c334e50f5544", + "metadata": {}, + "source": [ + "# Test that setting new data with different dims clears old ImageGraphics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38557b63-997f-433a-b744-e562e30be6ae", + "metadata": {}, + "outputs": [], + "source": [ + "old_graphics = iw.managed_graphics\n", + "\n", + "new_movies = [np.random.rand(100, 200, 200) for i in range(6)]\n", + "\n", + "iw.set_data(new_movies)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59e3c193-5672-4a66-bdca-12f1dd675d32", + "metadata": {}, + "outputs": [], + "source": [ + "test_references(old_graphics)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "712bb6ea-7244-4e03-8dfa-9419daa34915", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/qt/video.py b/examples/qt/embed.py similarity index 54% rename from examples/qt/video.py rename to examples/qt/embed.py index 9fd77a999..a3b156021 100644 --- a/examples/qt/video.py +++ b/examples/qt/embed.py @@ -5,28 +5,22 @@ 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") +# fastplotlib and wgpu will auto-detect if Qt is imported and then use the Qt canvas and output context +fig = fpl.Figure() -plot.add_image(video[0], name="video") -plot.camera.local.scale *= -1 +fig[0, 0].add_image(video[0], name="video") def update_frame(ix): - plot["video"].data = video[ix] - # you can also do plot.graphics[0].data = video[ix] + fig[0, 0]["video"].data = video[ix] + # you can also do fig[0, 0].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 +# create a QMainWindow main_window = QtWidgets.QMainWindow() -main_window.setCentralWidget(plot.canvas) # Create a QSlider for updating frames slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) @@ -44,8 +38,11 @@ def update_frame(ix): dock ) -# calling plot.show() is required to start the rendering loop -plot.show() +# calling fig.show() is required to start the rendering loop +qwidget = fig.show() + +# set the qwidget as the central widget +main_window.setCentralWidget(qwidget) # set window size from width and height of video main_window.resize(video.shape[2], video.shape[1]) @@ -54,4 +51,4 @@ def update_frame(ix): main_window.show() # execute Qt app -app.exec() +fpl.run() diff --git a/examples/qt/imagewidget.py b/examples/qt/imagewidget.py index ab1a055f1..f82d082a0 100644 --- a/examples/qt/imagewidget.py +++ b/examples/qt/imagewidget.py @@ -4,14 +4,13 @@ import numpy as np 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([]) 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"}) +# fastplotlib and wgpu will auto-detect if Qt is imported and then use the Qt canvas +iw = fpl.ImageWidget(images) iw.show() iw.widget.resize(800, 800) @@ -20,10 +19,14 @@ iw_mult = fpl.ImageWidget( images_list, - grid_plot_kwargs={"canvas": "qt"}, cmap="viridis" ) iw_mult.show() iw_mult.widget.resize(800, 800) -app.exec() +# image widget with rgb data +rgb_video = iio.imread("imageio:cockatoo.mp4") +iw_rgb = fpl.ImageWidget(rgb_video, rgb=[True]) +iw_rgb.show() + +fpl.run() diff --git a/examples/qt/minimal.py b/examples/qt/minimal.py index e4e5f6c2f..0d9009ba7 100644 --- a/examples/qt/minimal.py +++ b/examples/qt/minimal.py @@ -1,35 +1,24 @@ """ Minimal PyQt example that displays an image. Press "r" key to autoscale """ +# import Qt or PySide 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") +# fastplotlib and wgpu will auto-detect if Qt is imported and then use the Qt canvas and Qt output context +fig = fpl.Figure() -plot.add_image(img) -plot.camera.local.scale *= -1 +fig[0, 0].add_image(img) -# must call plot.show() to start rendering loop -plot.show() +# must call fig.show() to start rendering loop and show the QWidget containing the fastplotlib figure +qwidget = fig.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") +qwidget.resize(*img.shape[:2]) # execute Qt app -app.exec() +# if this is part of a larger Qt QApplication, you can also call app.exec() where app is the QApplication instance +fpl.run() diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py index 876533fa6..c08df9005 100644 --- a/examples/tests/test_examples.py +++ b/examples/tests/test_examples.py @@ -1,6 +1,7 @@ """ Test that examples run without error. """ + import importlib import runpy import pytest @@ -8,6 +9,7 @@ import numpy as np import imageio.v3 as iio + from .testutils import ( ROOT, examples_dir, @@ -15,7 +17,11 @@ find_examples, wgpu_backend, is_lavapipe, - diffs_dir + diffs_dir, + generate_diff, + image_similarity, + normalize_image, + prep_for_write, ) # run all tests unless they opt-out @@ -52,13 +58,18 @@ def test_that_we_are_on_lavapipe(): def test_example_screenshots(module, force_offscreen): """Make sure that every example marked outputs the expected.""" # (relative) module name from project root - module_name = module.relative_to(ROOT/"examples").with_suffix("").as_posix().replace("/", ".") + module_name = ( + module.relative_to(ROOT / "examples") + .with_suffix("") + .as_posix() + .replace("/", ".") + ) # import the example module example = importlib.import_module(module_name) # render a frame - img = np.asarray(example.plot.renderer.target.draw()) + img = np.asarray(example.figure.renderer.target.draw()) # check if _something_ was rendered assert img is not None and img.size > 0 @@ -69,20 +80,35 @@ def test_example_screenshots(module, force_offscreen): screenshot_path = screenshots_dir / f"{module.stem}.png" + black = np.zeros(img.shape).astype(np.uint8) + black[:, :, -1] = 255 + + img_alpha = img[..., -1] / 255 + + rgb = img[..., :-1] * img_alpha[..., None] + black[..., :-1] * np.ones( + img_alpha.shape + )[..., None] * (1 - img_alpha[..., None]) + + rgb = rgb.round().astype(np.uint8) + if "REGENERATE_SCREENSHOTS" in os.environ.keys(): if os.environ["REGENERATE_SCREENSHOTS"] == "1": - iio.imwrite(screenshot_path, img) - #np.save(screenshot_path, img) + iio.imwrite(screenshot_path, rgb) assert ( screenshot_path.exists() ), "found # test_example = true but no reference screenshot available" - #stored_img = np.load(screenshot_path) - stored_img = iio.imread(screenshot_path) - is_similar = np.allclose(img, stored_img, atol=1) - update_diffs(module.stem, is_similar, img, stored_img) - assert is_similar, ( - f"rendered image for example {module.stem} changed, see " + + ref_img = iio.imread(screenshot_path) + + rgb = normalize_image(rgb) + ref_img = normalize_image(ref_img) + + similar, rmse = image_similarity(rgb, ref_img, threshold=0.025) + + update_diffs(module.stem, similar, rgb, ref_img) + assert similar, ( + f"diff {rmse} above threshold for {module.stem}, see " f"the {diffs_dir.relative_to(ROOT).as_posix()} folder" " for visual diffs (you can download this folder from" " CI build artifacts as well)" @@ -110,7 +136,6 @@ def get_diffs_rgba(slicer): # split into an rgb and an alpha diff diffs = { diffs_dir / f"diff-{module}-rgb.png": slice(0, 3), - diffs_dir / f"diff-{module}-alpha.png": 3, } for path, slicer in diffs.items(): diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 5f6772fb7..22747ce08 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -7,6 +7,8 @@ import sys from itertools import chain +import numpy as np + ROOT = Path(__file__).parents[2] # repo root examples_dir = ROOT / "examples" / "desktop" @@ -20,7 +22,8 @@ "scatter/*.py", "line/*.py", "line_collection/*.py", - "gridplot/*.py" + "gridplot/*.py", + "misc/*.py" ] @@ -65,3 +68,189 @@ def find_examples(query=None, negative_query=None, return_stems=False): result = [r.stem for r in result] return result + +# image comparison functions from: https://github.com/pygfx/image-comparison +def image_similarity(src, target, threshold=0.2): + """Compute normalized RMSE 0..1 and decide if similar based on threshold. + + For every pixel, the euclidian distance between RGB values is computed, + and normalized by the maximum possible distance (between black and white). + The RMSE is then computed from those errors. + + The normalized RMSE is used to compute the + similarity metric, so larger errors (euclidian distance + between two RGB colors) will have a disproportionately + larger effect on the score than smaller errors. + + In other words, lots of small errors will lead to a good score + (closer to 0) whereas a few large errors will lead to a bad score + (closer to 1). + """ + float_type = np.float64 + src = np.asarray(src, dtype=float_type) + target = np.asarray(target, dtype=float_type) + denom = np.sqrt(np.mean(src * src)) + mse = np.mean((src - target) ** 2) + rmse = np.sqrt(mse) / denom + + similar = bool(rmse < threshold) + return similar, rmse + + +def normalize_image(img): + """Discard the alpha channel and convert from 0..255 uint8 to 0..1 float.""" + assert len(img.shape) == 3 + + # normalize to 0..1 range + if img.dtype == "u1" or np.max(img) > 1: + img = img / 255 + assert np.min(img) >= 0 and np.max(img) <= 1 + + # discard alpha channel + # unsupported if it's not fully opaque + if img.shape[-1] == 4: + assert np.max(img[..., 3]) == 1 + img = img[..., :-1] + + return img + + +def prep_for_write(img): + """Convert 0..1 float back to 0..255 uint8.""" + assert len(img.shape) == 3 + assert np.min(img) >= 0 and np.max(img) <= 1 + if img.dtype != "u1": + img = np.round(img * 255).astype("u1") + return img + + +def rescale_arr(arr, min, max): + """ + histogram rescale utility function + e.g. if the values are 0.3..0.7 + they are rescaled to min..max + """ + return np.interp(arr, (arr.min(), arr.max()), (min, max)) + + +def rgb_to_hls(rgb): + """ + convert rgb to hls + assumes input ranges are 0..1 + returns values in range 0..1 + + vectorized version of colorsys.rgb_to_hls + """ + maxc = np.max(rgb, axis=-1) + minc = np.min(rgb, axis=-1) + hls = np.empty_like(rgb) + l = (minc + maxc) / 2.0 # noqa: E741 + + with np.errstate(invalid="ignore"): + mask = l <= 0.5 + idx = np.where(mask) + hls[(*idx, 2)] = (maxc[idx] - minc[idx]) / (maxc[idx] + minc[idx]) + + idx = np.where(~mask) + hls[(*idx, 2)] = (maxc[idx] - minc[idx]) / (2.0 - maxc[idx] - minc[idx]) + + maxc_minc = maxc - minc + rc = (maxc - rgb[..., 0]) / maxc_minc + gc = (maxc - rgb[..., 1]) / maxc_minc + bc = (maxc - rgb[..., 2]) / maxc_minc + + mask1 = rgb[..., 0] == maxc + idx = np.where(mask1) + hls[(*idx, 0)] = bc[idx] - gc[idx] + + mask2 = rgb[..., 1] == maxc + idx = np.where(~mask1 & mask2) + hls[(*idx, 0)] = 2.0 + rc[idx] - bc[idx] + + idx = np.where(~mask1 & ~mask2) + hls[(*idx, 0)] = 4.0 + gc[idx] - rc[idx] + + hls[..., 0] = (hls[..., 0] / 6.0) % 1.0 + + idx = np.where(minc == maxc) + hls[idx] = 0.0 + hls[..., 1] = l + + return hls + + +def hls_to_rgb(hls): + """ + convert hls to rgb + assumes input ranges are 0..1 + returns values in range 0..1 + + vectorized version of colorsys.hls_to_rgb + """ + rgb = np.empty_like(hls) + + m2 = np.empty_like(hls[..., 1]) + mask = hls[..., 1] <= 0.5 + idx = np.where(mask) + m2[idx] = hls[(*idx, 1)] * (1.0 + hls[(*idx, 2)]) + idx = np.where(~mask) + m2[idx] = hls[(*idx, 1)] + hls[(*idx, 2)] - (hls[(*idx, 1)] * hls[(*idx, 2)]) + m1 = 2.0 * hls[..., 1] - m2 + + h1 = (hls[..., 0] + 1 / 3) % 1.0 + h2 = hls[..., 0] % 1.0 + h3 = (hls[..., 0] - 1 / 3) % 1.0 + + for i, h in enumerate([h1, h2, h3]): + mask1 = h < 1 / 6 + idx = np.where(mask1) + rgb[(*idx, i)] = m1[idx] + (m2[idx] - m1[idx]) * h[idx] * 6.0 + + mask2 = h < 0.5 + idx = np.where(~mask1 & mask2) + rgb[(*idx, i)] = m2[idx] + + mask3 = h < 2 / 3 + idx = np.where(~mask1 & ~mask2 & mask3) + rgb[(*idx, i)] = m1[idx] + (m2[idx] - m1[idx]) * ((2 / 3) - h[idx]) * 6.0 + + idx = np.where(~mask1 & ~mask2 & ~mask3) + rgb[(*idx, i)] = m1[idx] + + return rgb + + +def generate_diff(src, target, fuzz=0.05): + """ + Generate an image that + highlights the differences between src and target image + any pixels with a euclidian color distance < fuzz will be ignored + fuzz is expressed as a percentage of the maximum possible distance + which is the distance between (0,0,0) and (1,1,1) = sqrt(3). + """ + # compute euclidian distance between pixels + # and normalize to 0..1 + max_dist = np.linalg.norm([1, 1, 1], axis=-1) + error = np.linalg.norm(np.abs(target - src), axis=-1) / max_dist + # apply fuzz + error_idx = np.where(error > fuzz) + + diff_img_hls = rgb_to_hls(target) + # lighten the whole image + diff_img_hls[..., 1] = rescale_arr(diff_img_hls[..., 1], 0.25, 1.0) + diff_img_hls[..., 1] **= 0.2 + # reduce the color saturation + diff_img_hls[..., 2] = rescale_arr(diff_img_hls[..., 2], 0.0, 0.75) + diff_img_hls[..., 2] **= 2 + + # make the diff pixels red + diff_img_hls[(*error_idx, 0)] = 0 + # give them the same lighting level + diff_img_hls[(*error_idx, 1)] = 0.5 + # saturate based on the error + diff_img_hls[(*error_idx, 2)] = 0.5 + error[error_idx] * 0.5 + + # convert back to rgb + diff_img = hls_to_rgb(diff_img_hls) + + return diff_img diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION index d9958b371..0ea3a944b 100644 --- a/fastplotlib/VERSION +++ b/fastplotlib/VERSION @@ -1 +1 @@ -0.1.0.a16 +0.2.0 diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 301412aff..8b46dcc0b 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -1,25 +1,29 @@ from pathlib import Path -from .layouts import Plot, GridPlot +from .utils.gui import run # noqa from .graphics import * from .graphics.selectors import * +from .legends import * +from .layouts import Figure -from wgpu.gui.auto import run - -try: - import ipywidgets -except (ModuleNotFoundError, ImportError): - pass -else: - from .widgets import ImageWidget +from .widgets import ImageWidget +from .utils import config, enumerate_adapters, select_adapter, print_wgpu_report with open(Path(__file__).parent.joinpath("VERSION"), "r") as f: __version__ = f.read().split("\n")[0] -__all__ = [ - "Plot", - "GridPlot", - "run", - "ImageWidget" -] +if len(enumerate_adapters()) < 1: + raise IndexError( + f"WGPU could not enumerate any adapters, fastplotlib will not work.\n" + f"This is caused by one of the following:\n" + f"1. You do not have a hardware GPU installed and you do not have " + f"software rendering (ex. lavapipe) installed either\n" + f"2. Your GPU drivers are not installed or something is wrong with your GPU driver installation, " + f"re-installing the latest drivers from your hardware vendor (probably Nvidia or AMD) may help.\n" + f"3. You are missing system libraries that are required for WGPU to access GPU(s), this is " + f"common in cloud computing environments.\n" + f"These two links can help you troubleshoot:\n" + f"https://wgpu-py.readthedocs.io/en/stable/start.html#platform-requirements\n" + f"https://fastplotlib.readthedocs.io/en/latest/user_guide/gpu.html\n" + ) diff --git a/fastplotlib/assets/egg.gif b/fastplotlib/assets/egg.gif new file mode 100644 index 000000000..0ff189075 --- /dev/null +++ b/fastplotlib/assets/egg.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7251090fd17fa81eae8a5ad176608755b4a68d620b125ebd571acc6d68daa017 +size 4589 diff --git a/fastplotlib/assets/fastplotlib_face_logo.png b/fastplotlib/assets/fastplotlib_face_logo.png new file mode 100644 index 000000000..a5a8bd90e --- /dev/null +++ b/fastplotlib/assets/fastplotlib_face_logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84976200545d143f559b6840a332f3121d58f8d7de8dad7cb8a6d027d854c153 +size 10239 diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 2a008015e..ff96baa4c 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,15 +1,15 @@ from .line import LineGraphic from .scatter import ScatterGraphic -from .image import ImageGraphic, HeatmapGraphic +from .image import ImageGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack + __all__ = [ + "LineGraphic", "ImageGraphic", "ScatterGraphic", - "LineGraphic", - "HeatmapGraphic", + "TextGraphic", "LineCollection", "LineStack", - "TextGraphic", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index a0b4881fb..cab941894 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,18 +1,28 @@ -from typing import * +from collections import defaultdict +from functools import partial +from typing import Any, Literal, TypeAlias import weakref -from warnings import warn -from abc import ABC, abstractmethod -from dataclasses import dataclass import numpy as np +import pylinalg as la +from wgpu.gui.base import log_exception -from pygfx import WorldObject +import pygfx -from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable +from ._features import ( + BufferManager, + Deleted, + Name, + Offset, + Rotation, + Visible, +) + +HexStr: TypeAlias = str # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects -WORLD_OBJECTS: Dict[str, WorldObject] = dict() #: {hex id str: WorldObject} +WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict() #: {hex id str: WorldObject} PYGFX_EVENTS = [ @@ -31,9 +41,11 @@ ] -class BaseGraphic: +class Graphic: + _features = {} + def __init_subclass__(cls, **kwargs): - """set the type of the graphic in lower case like "image", "line_collection", etc.""" + # set the type of the graphic in lower case like "image", "line_collection", etc. cls.type = ( cls.__name__.lower() .replace("graphic", "") @@ -41,570 +53,373 @@ def __init_subclass__(cls, **kwargs): .replace("stack", "_stack") ) + # set of all features + cls._features = { + *cls._features, + "name", + "offset", + "rotation", + "visible", + "deleted", + } super().__init_subclass__(**kwargs) - -class Graphic(BaseGraphic): def __init__( self, name: str = None, + offset: np.ndarray | list | tuple = (0.0, 0.0, 0.0), + rotation: np.ndarray | list | tuple = (0.0, 0.0, 0.0, 1.0), + visible: bool = True, metadata: Any = None, - collection_index: int = None, ): """ Parameters ---------- name: str, optional - name this graphic, makes it indexable within plots + name this graphic to use it as a key to access from the plot + + offset: (float, float, float), default (0., 0., 0.) + (x, y, z) vector to offset this graphic from the origin + + rotation: (float, float, float, float), default (0, 0, 0, 1) + rotation quaternion metadata: Any, optional metadata attached to this Graphic, this is for the user to manage """ + if (name is not None) and (not isinstance(name, str)): + raise TypeError("Graphic `name` must be of type ") - self.name = name self.metadata = metadata - self.collection_index = collection_index self.registered_callbacks = dict() - self.present = PresentFeature(parent=self) # store hex id str of Graphic instance mem location - self.loc: str = hex(id(self)) + self._fpl_address: HexStr = hex(id(self)) - @property - def world_object(self) -> WorldObject: - """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" - # We use weakref to simplify garbage collection - return weakref.proxy(WORLD_OBJECTS[hex(id(self))]) + self._plot_area = None - def _set_world_object(self, wo: WorldObject): - WORLD_OBJECTS[hex(id(self))] = wo + # event handlers + self._event_handlers = defaultdict(set) - @property - def position(self) -> np.ndarray: - """position of the graphic, [x, y, z]""" - return self.world_object.world.position + # maps callbacks to their partials + self._event_handler_wrappers = defaultdict(set) - @property - def position_x(self) -> float: - """x-axis position of the graphic""" - return self.world_object.world.x + # all the common features + self._name = Name(name) + self._deleted = Deleted(False) + self._rotation = Rotation(rotation) + self._offset = Offset(offset) + self._visible = Visible(visible) + self._block_events = False @property - def position_y(self) -> float: - """y-axis position of the graphic""" - return self.world_object.world.y + def supported_events(self) -> tuple[str]: + """events supported by this graphic""" + return (*tuple(self._features), *PYGFX_EVENTS) @property - def position_z(self) -> float: - """z-axis position of the graphic""" - return self.world_object.world.z + def name(self) -> str | None: + """Graphic name""" + return self._name.value - @position.setter - def position(self, val): - self.world_object.world.position = val + @name.setter + def name(self, value: str): + self._name.set_value(self, value) - @position_x.setter - def position_x(self, val): - self.world_object.world.x = val + @property + def offset(self) -> np.ndarray: + """Offset position of the graphic, array: [x, y, z]""" + return self._offset.value + + @offset.setter + def offset(self, value: np.ndarray | list | tuple): + self._offset.set_value(self, value) - @position_y.setter - def position_y(self, val): - self.world_object.world.y = val + @property + def rotation(self) -> np.ndarray: + """Orientation of the graphic as a quaternion""" + return self._rotation.value - @position_z.setter - def position_z(self, val): - self.world_object.world.z = val + @rotation.setter + def rotation(self, value: np.ndarray | list | tuple): + self._rotation.set_value(self, value) @property def visible(self) -> bool: - """Access or change the visibility.""" - return self.world_object.visible + """Whether the graphic is visible""" + return self._visible.value @visible.setter - def visible(self, v: bool): - """Access or change the visibility.""" - self.world_object.visible = v + def visible(self, value: bool): + self._visible.set_value(self, value) @property - def children(self) -> List[WorldObject]: - """Return the children of the WorldObject.""" - return self.world_object.children + def deleted(self) -> bool: + """used to emit an event after the graphic is deleted""" + return self._deleted.value - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, GraphicFeature): - attr._set(value) - return + @deleted.setter + def deleted(self, value: bool): + self._deleted.set_value(self, value) - super().__setattr__(key, value) - - def __repr__(self): - rval = f"{self.__class__.__name__} @ {hex(id(self))}" - if self.name is not None: - return f"'{self.name}': {rval}" - else: - return rval - - def __eq__(self, other): - # This is necessary because we use Graphics as weakref proxies - if not isinstance(other, Graphic): - raise TypeError("`==` operator is only valid between two Graphics") + @property + def block_events(self) -> bool: + """Used to block events for a graphic and prevent recursion.""" + return self._block_events - if self.loc == other.loc: - return True + @block_events.setter + def block_events(self, value: bool): + self._block_events = value - return False + @property + def world_object(self) -> pygfx.WorldObject: + """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" + # We use weakref to simplify garbage collection + return weakref.proxy(WORLD_OBJECTS[self._fpl_address]) - def _cleanup(self): - """ - Cleans up the graphic in preparation for __del__(), such as removing event handlers from - plot renderer, feature event handlers, etc. + def _set_world_object(self, wo: pygfx.WorldObject): + WORLD_OBJECTS[self._fpl_address] = wo - Optionally implemented in subclasses - """ - pass + self.world_object.visible = self.visible - def __del__(self): - del WORLD_OBJECTS[self.loc] + # set offset if it's not (0., 0., 0.) + if not all(self.world_object.world.position == self.offset): + self.offset = self.offset + # set rotation if it's not (0., 0., 0., 1.) + if not all(self.world_object.world.rotation == self.rotation): + self.rotation = self.rotation -class Interaction(ABC): - """Mixin class that makes graphics interactive""" + def unshare_property(self, feature: str): + raise NotImplementedError - @abstractmethod - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + def share_property(self, feature: BufferManager): + raise NotImplementedError - @abstractmethod - def reset_feature(self, feature: str): - pass + @property + def event_handlers(self) -> list[tuple[str, callable, ...]]: + """ + Registered event handlers. Read-only use ``add_event_handler()`` + and ``remove_event_handler()`` to manage callbacks + """ + return list(self._event_handlers.items()) - def link( - self, - event_type: str, - target: Any, - feature: str, - new_data: Any, - callback: callable = None, - bidirectional: bool = False, - ): + def add_event_handler(self, *args): """ - Link this graphic to another graphic upon an ``event_type`` to change the ``feature`` - of a ``target`` graphic. + Register an event handler. Parameters ---------- - event_type: str - can be a pygfx event ("key_down", "key_up","pointer_down", "pointer_move", "pointer_up", - "pointer_enter", "pointer_leave", "click", "double_click", "wheel", "close", "resize") - or appropriate feature event (ex. colors, data, etc.) associated with the graphic (can use - ``graphic_instance.feature_events`` to get a tuple of the valid feature events for the - graphic) - - target: Any - graphic to be linked to - - feature: str - feature (ex. colors, data, etc.) of the target graphic that will change following - the event - - new_data: Any - appropriate data that will be changed in the feature of the target graphic after - the event occurs - - callback: callable, optional - user-specified callable that will handle event, - the callable must take the following four arguments - | ''source'' - this graphic instance - | ''target'' - the graphic to be changed following the event - | ''event'' - the ''pygfx event'' or ''feature event'' that occurs - | ''new_data'' - the appropriate data of the ''target'' that will be changed - - bidirectional: bool, default False - if True, the target graphic is also linked back to this graphic instance using the - same arguments - - For example: - .. code-block::python - - Returns - ------- - None + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" - """ - if event_type in PYGFX_EVENTS: - self.world_object.add_event_handler(self._event_handler, event_type) + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html - # make sure event is valid - elif event_type in self.feature_events: - if isinstance(self, GraphicCollection): - feature_instance = getattr(self[:], event_type) - else: - feature_instance = getattr(self, event_type) + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented - feature_instance.add_event_handler(self._event_handler) + Can also be used as a decorator. - else: - raise ValueError( - f"Invalid event, valid events are: {PYGFX_EVENTS + self.feature_events}" - ) + Example + ------- - # make sure target feature is valid - if feature is not None: - if feature not in target.feature_events: - raise ValueError( - f"Invalid feature for target, valid features are: {target.feature_events}" - ) + .. code-block:: py - if event_type not in self.registered_callbacks.keys(): - self.registered_callbacks[event_type] = list() + def my_handler(event): + print(event) - callback_data = CallbackData( - target=target, - feature=feature, - new_data=new_data, - callback_function=callback, - ) + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") - for existing_callback_data in self.registered_callbacks[event_type]: - if existing_callback_data == callback_data: - warn( - "linkage already exists for given event, target, and data, skipping" - ) - return - - self.registered_callbacks[event_type].append(callback_data) - - if bidirectional: - if event_type in PYGFX_EVENTS: - warn("cannot use bidirectional link for pygfx events") - return - - target.link( - event_type=event_type, - target=self, - feature=feature, - new_data=new_data, - callback=callback, - bidirectional=False # else infinite recursion, otherwise target will call - # this instance .link(), and then it will happen again etc. - ) + Decorator usage example: - def _event_handler(self, event): - """Handles the event after it occurs when two graphic have been linked together.""" - if event.type in self.registered_callbacks.keys(): - for target_info in self.registered_callbacks[event.type]: - if target_info.callback_function is not None: - # if callback_function is not None, then callback function should handle the entire event - target_info.callback_function( - source=self, - target=target_info.target, - event=event, - new_data=target_info.new_data, - ) - - elif isinstance(self, GraphicCollection): - # if target is a GraphicCollection, then indices will be stored in collection_index - if event.type in self.feature_events: - indices = event.pick_info["collection-index"] - - # for now we only have line collections so this works - else: - # get index of world object that made this event - for i, item in enumerate(self.graphics): - wo = WORLD_OBJECTS[item.loc] - # we only store hex id of worldobject, but worldobject `pick_info` is always the real object - # so if pygfx worldobject triggers an event by itself, such as `click`, etc., this will be - # the real world object in the pick_info and not the proxy - if wo is event.pick_info["world_object"]: - indices = i - target_info.target.set_feature( - feature=target_info.feature, - new_data=target_info.new_data, - indices=indices, - ) - else: - # if target is a single graphic, then indices do not matter - target_info.target.set_feature( - feature=target_info.feature, - new_data=target_info.new_data, - indices=None, - ) + .. code-block:: py + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ -@dataclass -class CallbackData: - """Class for keeping track of the info necessary for interactivity after event occurs.""" + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] - target: Any - feature: str - new_data: Any - callback_function: callable = None + unsupported_events = [t for t in types if t not in self.supported_events] - def __eq__(self, other): - if not isinstance(other, CallbackData): - raise TypeError("Can only compare against other types") + if len(unsupported_events) > 0: + raise TypeError( + f"unsupported events passed: {unsupported_events} for {self.__class__.__name__}\n" + f"`graphic.events` will return a tuple of supported events" + ) - if other.target is not self.target: - return False + def decorator(_callback): + _callback_wrapper = partial( + self._handle_event, _callback + ) # adds graphic instance as attribute and other things - if not other.feature == self.feature: - return False + for t in types: + # add to our record + self._event_handlers[t].add(_callback) - if not other.new_data == self.new_data: - return False + if t in self._features: + # fpl feature event + feature = getattr(self, f"_{t}") + feature.add_event_handler(_callback_wrapper) + else: + # wrap pygfx event + self.world_object._event_handlers[t].add(_callback_wrapper) + + # keep track of the partial too + self._event_handler_wrappers[t].add((_callback, _callback_wrapper)) + return _callback + + if decorating: + return decorator + + return decorator(callback) + + def clear_event_handlers(self): + """clear all event handlers added to this graphic""" + for ev, handlers in self.event_handlers: + handlers = list(handlers) + for h in handlers: + self.remove_event_handler(h, ev) + + def _handle_event(self, callback, event: pygfx.Event): + """Wrap pygfx event to add graphic to pick_info""" + event.graphic = self + + if self.block_events: + return + + if event.type in self._features: + # for feature events + event._target = self.world_object + + if isinstance(event, pygfx.PointerEvent): + # map from screen to world space and data space + world_xy = self._plot_area.map_screen_to_world(event) + + # subtract offset to map to data + data_xy = world_xy - self.offset + + # append attributes + event.x_world, event.y_world = world_xy[:2] + event.x_data, event.y_data = data_xy[:2] + + with log_exception(f"Error during handling {event.type} event"): + callback(event) + + def remove_event_handler(self, callback, *types): + # remove from our record first + for t in types: + for wrapper_map in self._event_handler_wrappers[t]: + # TODO: not sure if we can handle this mapping in a better way + if wrapper_map[0] == callback: + wrapper = wrapper_map[1] + self._event_handler_wrappers[t].remove(wrapper_map) + break + else: + raise KeyError( + f"event type: {t} with callback: {callback} is not registered" + ) - if (self.callback_function is None) and (other.callback_function is None): - return True + self._event_handlers[t].remove(callback) + # remove callback wrapper from world object if pygfx event + if t in PYGFX_EVENTS: + print("pygfx event") + print(wrapper) + self.world_object.remove_event_handler(wrapper, t) + else: + feature = getattr(self, f"_{t}") + feature.remove_event_handler(wrapper) - if other.callback_function is self.callback_function: - return True + def _fpl_add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + def __repr__(self): + rval = f"{self.__class__.__name__} @ {hex(id(self))}" + if self.name is not None: + return f"'{self.name}': {rval}" else: - return False - - -@dataclass -class PreviouslyModifiedData: - """Class for keeping track of previously modified data at indices""" - - data: Any - indices: Any - - -COLLECTION_GRAPHICS: Dict[str, Graphic] = dict() - - -class GraphicCollection(Graphic): - """Graphic Collection base class""" - - def __init__(self, name: str = None): - super(GraphicCollection, self).__init__(name) - self._graphics: List[str] = list() - - self._graphics_changed: bool = True - self._graphics_array: np.ndarray[Graphic] = None + return rval - @property - def graphics(self) -> np.ndarray[Graphic]: - """The Graphics within this collection. Always returns a proxy to the Graphics.""" - if self._graphics_changed: - proxies = [ - weakref.proxy(COLLECTION_GRAPHICS[loc]) for loc in self._graphics - ] - self._graphics_array = np.array(proxies) - self._graphics_array.flags["WRITEABLE"] = False - self._graphics_changed = False - - return self._graphics_array - - def add_graphic(self, graphic: Graphic, reset_index: False): - """ - Add a graphic to the collection. + def __eq__(self, other): + # This is necessary because we use Graphics as weakref proxies + if not isinstance(other, Graphic): + raise TypeError("`==` operator is only valid between two Graphics") - Parameters - ---------- - graphic: Graphic - graphic to add, must be a real ``Graphic`` not a proxy + if self._fpl_address == other._fpl_address: + return True - reset_index: bool, default ``False`` - reset the collection index + return False + def _fpl_cleanup(self): """ + Cleans up the graphic in preparation for __del__(), such as removing event handlers from + plot renderer, feature event handlers, etc. - if not type(graphic).__name__ == self.child_type: - raise TypeError( - f"Can only add graphics of the same type to a collection, " - f"You can only add {self.child_type} to a {self.__class__.__name__}, " - f"you are trying to add a {graphic.__class__.__name__}." - ) - - loc = hex(id(graphic)) - COLLECTION_GRAPHICS[loc] = graphic - - self._graphics.append(loc) - - if reset_index: - self._reset_index() - elif graphic.collection_index is None: - graphic.collection_index = len(self) - - self.world_object.add(graphic.world_object) - - self._graphics_changed = True - - def remove_graphic(self, graphic: Graphic, reset_index: True): + Optionally implemented in subclasses """ - Remove a graphic from the collection. - - Parameters - ---------- - graphic: Graphic - graphic to remove + # remove event handlers + self.clear_event_handlers() - reset_index: bool, default ``False`` - reset the collection index + # clear any attached event handlers and animation functions + for attr in dir(self): + try: + method = getattr(self, attr) + except: + continue - """ + if not callable(method): + continue - self._graphics.remove(graphic.loc) + for ev_type in PYGFX_EVENTS: + try: + self._plot_area.renderer.remove_event_handler(method, ev_type) + except (KeyError, TypeError): + pass - if reset_index: - self._reset_index() + try: + self._plot_area.remove_animation(method) + except KeyError: + pass - self.world_object.remove(graphic.world_object) + for child in self.world_object.children: + child._event_handlers.clear() - self._graphics_changed = True + self.world_object._event_handlers.clear() - def __getitem__(self, key): - return CollectionIndexer( - parent=self, - selection=self.graphics[key], - ) + for n in self._features: + fea = getattr(self, f"_{n}") + fea.clear_event_handlers() def __del__(self): - self.world_object.clear() - - for loc in self._graphics: - del COLLECTION_GRAPHICS[loc] - - super().__del__() + self.deleted = True + del WORLD_OBJECTS[self._fpl_address] - def _reset_index(self): - for new_index, graphic in enumerate(self._graphics): - graphic.collection_index = new_index - - def __len__(self): - return len(self._graphics) - - def __repr__(self): - rval = super().__repr__() - return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" - - -class CollectionIndexer: - """Collection Indexer""" - - def __init__( - self, - parent: GraphicCollection, - selection: List[Graphic], - ): - """ + def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): + """Rotate the Graphic with respect to the world. Parameters ---------- - parent: GraphicCollection - the GraphicCollection object that is being indexed - - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - """ - - self._parent = weakref.proxy(parent) - self._selection = selection - - # we use parent.graphics[0] instead of selection[0] - # because the selection can be empty - for attr_name in self._parent.graphics[0].__dict__.keys(): - attr = getattr(self._parent.graphics[0], attr_name) - if isinstance(attr, GraphicFeature): - collection_feature = CollectionFeature( - self._selection, feature=attr_name - ) - collection_feature.__doc__ = ( - f"indexable <{attr_name}> feature for collection" - ) - setattr(self, attr_name, collection_feature) - - @property - def graphics(self) -> np.ndarray[Graphic]: - """Returns an array of the selected graphics. Always returns a proxy to the Graphic""" - return tuple(self._selection) - - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, CollectionFeature): - attr._set(value) - return - - super().__setattr__(key, value) - - def __len__(self): - return len(self._selection) - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" - ) - - -class CollectionFeature: - """Collection Feature""" - - def __init__(self, selection: List[Graphic], feature: str): + alpha : + Rotation angle in radians. + axis : + Rotation axis label. """ - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - feature: str - feature of Graphics in the GraphicCollection being indexed - - """ - - self._selection = selection - self._feature = feature - - self._feature_instances: List[GraphicFeature] = list() - - if len(self._selection) > 0: - for graphic in self._selection: - fi = getattr(graphic, self._feature) - self._feature_instances.append(fi) - - if isinstance(fi, GraphicFeatureIndexable): - self._indexable = True - else: - self._indexable = False - else: # it's an empty selection so it doesn't really matter - self._indexable = False - - def _set(self, value): - self[:] = value - - def __getitem__(self, item): - # only for indexable graphic features - return [fi[item] for fi in self._feature_instances] - - def __setitem__(self, key, value): - if self._indexable: - for fi in self._feature_instances: - fi[key] = value - + if axis == "x": + rot = la.quat_from_euler((alpha, 0), order="XY") + elif axis == "y": + rot = la.quat_from_euler((0, alpha), order="XY") + elif axis == "z": + rot = la.quat_from_euler((0, alpha), order="XZ") else: - for fi in self._feature_instances: - fi._set(value) - - def add_event_handler(self, handler: callable): - """Adds an event handler to each of the selected Graphics from the parent GraphicCollection""" - for fi in self._feature_instances: - fi.add_event_handler(handler) - - def remove_event_handler(self, handler: callable): - """Removes an event handler from each of the selected Graphics of the parent GraphicCollection""" - for fi in self._feature_instances: - fi.remove_event_handler(handler) - - def block_events(self, b: bool): - """Blocks event handling from occurring.""" - for fi in self._feature_instances: - fi.block_events(b) - - def __repr__(self): - return f"Collection feature for: <{self._feature}>" + raise ValueError( + f"`axis` must be either `x`, `y`, or `z`. `{axis}` provided instead!" + ) + self.rotation = la.quat_mul(rot, self.rotation) diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py new file mode 100644 index 000000000..2805c684d --- /dev/null +++ b/fastplotlib/graphics/_collection_base.py @@ -0,0 +1,388 @@ +from typing import Any +import weakref + +import numpy as np + +from ._base import HexStr, Graphic + +# Dict that holds all collection graphics in one python instance +COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() + + +class CollectionProperties: + """ + Properties common to all Graphic Collections + + Allows getting and setting the common properties of the individual graphics in the collection + """ + + def _set_feature(self, feature, values): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self, values): + setattr(g, feature, v) + + @property + def names(self) -> np.ndarray[str | None]: + """get or set the name of the individual graphics in the collection""" + return np.asarray([g.name for g in self]) + + @names.setter + def names(self, values: np.ndarray[str] | list[str]): + self._set_feature("name", values) + + @property + def metadatas(self) -> np.ndarray[str | None]: + """get or set the metadata of the individual graphics in the collection""" + return np.asarray([g.metadata for g in self]) + + @metadatas.setter + def metadatas(self, values: np.ndarray[str] | list[str]): + self._set_feature("metadata", values) + + @property + def offsets(self) -> np.ndarray: + """get or set the offset of the individual graphics in the collection""" + return np.stack([g.offset for g in self]) + + @offsets.setter + def offsets(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("offset", values) + + @property + def rotations(self) -> np.ndarray: + """get or set the rotation of the individual graphics in the collection""" + return np.stack([g.rotation for g in self]) + + @rotations.setter + def rotations(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("rotation", values) + + # TODO: how to work with deleted feature in a collection + + @property + def visibles(self) -> np.ndarray[bool]: + """get or set the offsets of the individual graphics in the collection""" + return np.asarray([g.visible for g in self]) + + @visibles.setter + def visibles(self, values: np.ndarray[bool] | list[bool]): + self._set_feature("visible", values) + + +class CollectionIndexer(CollectionProperties): + """Collection Indexer""" + + def __init__(self, selection: np.ndarray[Graphic], features: set[str]): + """ + + Parameters + ---------- + + selection: np.ndarray of Graphics + array of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + + """ + + if isinstance(selection, Graphic): + selection = np.asarray([selection]) + + self._selection = selection + self.features = features + + @property + def graphics(self) -> np.ndarray[Graphic]: + """Returns an array of the selected graphics""" + return tuple(self._selection) + + def add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + types = args if decorating else args[1:] + + if decorating: + + def decorator(_callback): + for g in self: + g.add_event_handler(_callback, *types) + return _callback + + return decorator + + for g in self: + g.add_event_handler(*args) + + def remove_event_handler(self, callback, *types): + for g in self: + g.remove_event_handler(callback, *types) + + def clear_event_handlers(self): + for g in self: + g.clear_event_handlers() + + def __getitem__(self, item): + return self.graphics[item] + + def __len__(self): + return len(self._selection) + + def __iter__(self): + self._iter = iter(range(len(self))) + return self + + def __next__(self) -> Graphic: + index = next(self._iter) + + return self.graphics[index] + + def __repr__(self): + return ( + f"{self.__class__.__name__} @ {hex(id(self))}\n" + f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" + ) + + +class GraphicCollection(Graphic, CollectionProperties): + """Graphic Collection base class""" + + _child_type: type + _indexer: type + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._features = cls._child_type._features + + def __init__(self, name: str = None, metadata: Any = None, **kwargs): + super().__init__(name=name, metadata=metadata, **kwargs) + + # list of mem locations of the graphics + self._graphics: list[str] = list() + + self._graphics_changed: bool = True + self._graphics_array: np.ndarray[Graphic] = None + + self._iter = None + + @property + def graphics(self) -> np.ndarray[Graphic]: + """The Graphics within this collection. Always returns a proxy to the Graphics.""" + if self._graphics_changed: + proxies = [ + weakref.proxy(COLLECTION_GRAPHICS[addr]) for addr in self._graphics + ] + self._graphics_array = np.array(proxies) + self._graphics_array.flags["WRITEABLE"] = False + self._graphics_changed = False + + return self._graphics_array + + def add_graphic(self, graphic: Graphic): + """ + Add a graphic to the collection. + + Parameters + ---------- + graphic: Graphic + graphic to add, must be a real ``Graphic`` not a proxy + + """ + + if not type(graphic) == self._child_type: + raise TypeError( + f"Can only add graphics of the same type to a collection.\n" + f"You can only add {self._child_type.__name__} to a {self.__class__.__name__}, " + f"you are trying to add a {graphic.__class__.__name__}." + ) + + addr = graphic._fpl_address + COLLECTION_GRAPHICS[addr] = graphic + + self._graphics.append(addr) + + self.world_object.add(graphic.world_object) + + self._graphics_changed = True + + def remove_graphic(self, graphic: Graphic): + """ + Remove a graphic from the collection. + + Note: Only removes the graphic from the collection. Does not remove + the graphic from the scene, and does not delete the graphic. + + Parameters + ---------- + graphic: Graphic + graphic to remove + + """ + + self._graphics.remove(graphic._fpl_address) + + self.world_object.remove(graphic.world_object) + + self._graphics_changed = True + + def add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + return self[:].add_event_handler(*args) + + def remove_event_handler(self, callback, *types): + """remove an event handler""" + self[:].remove_event_handler(callback, *types) + + def clear_event_handlers(self): + self[:].clear_event_handlers() + + def _fpl_add_plot_area_hook(self, plot_area): + super()._fpl_add_plot_area_hook(plot_area) + + for g in self: + g._fpl_add_plot_area_hook(plot_area) + + def _fpl_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 + """ + # clear any attached event handlers and animation functions + self.world_object._event_handlers.clear() + + for g in self: + g._fpl_cleanup() + + def __getitem__(self, key) -> CollectionIndexer: + if np.issubdtype(type(key), np.integer): + addr = self._graphics[key] + return weakref.proxy(COLLECTION_GRAPHICS[addr]) + + return self._indexer(selection=self.graphics[key], features=self._features) + + def __del__(self): + self.world_object.clear() + + for addr in self._graphics: + del COLLECTION_GRAPHICS[addr] + + super().__del__() + + def __len__(self): + return len(self._graphics) + + def __iter__(self): + self._iter = iter(range(len(self))) + return self + + def __next__(self) -> Graphic: + index = next(self._iter) + addr = self._graphics[index] + + return weakref.proxy(COLLECTION_GRAPHICS[addr]) + + def __repr__(self): + rval = super().__repr__() + return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" + + +class CollectionFeature: + """Collection Feature""" + + def __init__(self, selection: np.ndarray[Graphic], feature: str): + """ + selection: list of Graphics + a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + + feature: str + feature of Graphics in the GraphicCollection being indexed + + """ + + self._selection = selection + self._feature = feature + + self._feature_instances = [getattr(g, feature) for g in self._selection] + + def __getitem__(self, item): + return np.stack([fi[item] for fi in self._feature_instances]) + + def __setitem__(self, key, value): + for fi in self._feature_instances: + fi[key] = value + + def __repr__(self): + return f"Collection feature for: <{self._feature}>" diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index a6ce9c3a3..e36de089e 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,26 +1,64 @@ -from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature -from ._sizes import PointsSizesFeature -from ._present import PresentFeature -from ._thickness import ThicknessFeature -from ._base import GraphicFeature, GraphicFeatureIndexable, FeatureEvent, to_gpu_supported_dtype +from ._positions_graphics import ( + VertexColors, + UniformColor, + UniformSize, + Thickness, + VertexPositions, + PointsSizesFeature, + VertexCmap, +) +from ._image import ( + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, + WGPU_MAX_TEXTURE_SIZE, +) +from ._base import ( + GraphicFeature, + BufferManager, + FeatureEvent, + to_gpu_supported_dtype, +) + +from ._text import ( + TextData, + FontSize, + TextFaceColor, + TextOutlineColor, + TextOutlineThickness, +) + from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature +from ._common import Name, Offset, Rotation, Visible, Deleted + __all__ = [ - "ColorFeature", - "CmapFeature", - "ImageCmapFeature", - "HeatmapCmapFeature", - "PointsDataFeature", + "VertexColors", + "UniformColor", + "UniformSize", + "Thickness", + "VertexPositions", "PointsSizesFeature", - "ImageDataFeature", - "HeatmapDataFeature", - "PresentFeature", - "ThicknessFeature", - "GraphicFeature", - "GraphicFeatureIndexable", - "FeatureEvent", - "to_gpu_supported_dtype", + "VertexCmap", + "TextureArray", + "ImageCmap", + "ImageVmin", + "ImageVmax", + "ImageInterpolation", + "ImageCmapInterpolation", + "TextData", + "FontSize", + "TextFaceColor", + "TextOutlineColor", + "TextOutlineThickness", "LinearSelectionFeature", "LinearRegionSelectionFeature", + "Name", + "Offset", + "Rotation", + "Visible", + "Deleted", ] diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 99ebbf436..a57f8a453 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -1,104 +1,69 @@ -from abc import ABC, abstractmethod -from inspect import getfullargspec from warnings import warn -from typing import * -import weakref +from typing import Any, Literal import numpy as np +from numpy.typing import NDArray + +from wgpu.gui.base import log_exception import pygfx -supported_dtypes = [ - np.uint8, - np.uint16, - np.uint32, - np.int8, - np.int16, - np.int32, - np.float16, - np.float32, -] +WGPU_MAX_TEXTURE_SIZE = 8192 def to_gpu_supported_dtype(array): """ - If ``array`` is a numpy array, converts it to a supported type. GPUs don't support 64 bit dtypes. + convert input array to float32 numpy array """ if isinstance(array, np.ndarray): - if array.dtype not in supported_dtypes: - if np.issubdtype(array.dtype, np.integer): - warn(f"converting {array.dtype} array to int32") - return array.astype(np.int32) - elif np.issubdtype(array.dtype, np.floating): - warn(f"converting {array.dtype} array to float32") - return array.astype(np.float32, copy=False) - else: - raise TypeError( - "Unsupported type, supported array types must be int or float dtypes" - ) + if not array.dtype == np.float32: + warn(f"casting {array.dtype} array to float32") + return array.astype(np.float32) + return array - return array + # try to make a numpy array from it, should not copy, tested with jax arrays + return np.asarray(array).astype(np.float32) -class FeatureEvent: +class FeatureEvent(pygfx.Event): """ - Dataclass that holds feature event information. Has ``type`` and ``pick_info`` attributes. - - Attributes - ---------- - type: str, example "colors" - - pick_info: dict: - - ============== ============================================================================= - key value - ============== ============================================================================= - "index" indices where feature data was changed, ``range`` object or ``List[int]`` - "world_object" world object the feature belongs to - "new_data: the new data for this feature - ============== ============================================================================= - - .. note:: - pick info varies between features, this is just the general structure + **All event instances have the following attributes** + + +------------+-------------+-----------------------------------------------+ + | attribute | type | description | + +============+=============+===============================================+ + | type | str | "colors" - name of the event | + +------------+-------------+-----------------------------------------------+ + | graphic | Graphic | graphic instance that the event is from | + +------------+-------------+-----------------------------------------------+ + | info | dict | event info dictionary (see below) | + +------------+-------------+-----------------------------------------------+ + | target | WorldObject | pygfx rendering engine object for the graphic | + +------------+-------------+-----------------------------------------------+ + | time_stamp | float | time when the event occured, in ms | + +------------+-------------+-----------------------------------------------+ """ - def __init__(self, type: str, pick_info: dict): - self.type = type - self.pick_info = pick_info - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"type: {self.type}\n" - f"pick_info: {self.pick_info}\n" - ) - - -class GraphicFeature(ABC): - def __init__(self, parent, data: Any, collection_index: int = None): - # not shown as a docstring so it doesn't show up in the docs - # - # Parameters - # ---------- - # parent - # - # data: Any - # - # collection_index: int - # if part of a collection, index of this graphic within the collection - - self._parent = weakref.proxy(parent) + def __init__(self, type: str, info: dict): + super().__init__(type=type) + self.info = info - self._data = to_gpu_supported_dtype(data) - self._collection_index = collection_index +class GraphicFeature: + def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False - def __call__(self, *args, **kwargs): - return self._data + @property + def value(self) -> Any: + """Graphic Feature value, must be implemented in subclass""" + raise NotImplemented + + def set_value(self, graphic, value: float): + """Graphic Feature value setter, must be implemented in subclass""" + raise NotImplementedError def block_events(self, val: bool): """ @@ -112,23 +77,14 @@ def block_events(self, val: bool): """ self._block_events = val - @abstractmethod - def _set(self, value): - pass - - def _parse_set_value(self, value): - if isinstance(value, GraphicFeature): - return value() - - return value - def add_event_handler(self, handler: callable): """ Add an event handler. All added event handlers are called when this feature changes. - The ``handler`` can optionally accept a :class:`.FeatureEvent` as the first and only argument. - The ``FeatureEvent`` only has two attributes, ``type`` which denotes the type of event - as a ``str`` in the form of "", such as "color". And ``pick_info`` which contains - information about the event and Graphic that triggered it. + + Used by `Graphic` classes to add to their event handlers, not meant for users. Users + add handlers to Graphic instances only. + + The ``handler`` must accept a :class:`.FeatureEvent` as the first and only argument. Parameters ---------- @@ -164,196 +120,202 @@ def clear_event_handlers(self): """Clear all event handlers""" self._event_handlers.clear() - # TODO: maybe this can be implemented right here in the base class - @abstractmethod - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - """Called whenever a feature changes, and it calls all funcs in self._event_handlers""" - pass - def _call_event_handlers(self, event_data: FeatureEvent): if self._block_events: return for func in self._event_handlers: - try: - args = getfullargspec(func).args - - if len(args) > 0: - if args[0] == "self" and not len(args) > 1: - func() - else: - func(event_data) - else: - func() - except TypeError: - warn( - f"Event handler {func} has an unresolvable argspec, calling it without arguments" - ) - func() - - @abstractmethod - def __repr__(self) -> str: - pass - - -def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: - """ - - If the key in an `int`, it just returns it. Otherwise, - it parses it and removes the `None` vals and replaces - them with corresponding values that can be used to - create a `range`, get `len` etc. - - Parameters - ---------- - key - upper_bound - - Returns - ------- - - """ - if isinstance(key, int): - return key - - if isinstance(key, np.ndarray): - return cleanup_array_slice(key, upper_bound) - - if isinstance(key, tuple): - # if tuple of slice we only need the first obj - # since the first obj is the datapoint indices - if isinstance(key[0], slice): - key = key[0] + with log_exception( + f"Error during handling {self.__class__.__name__} event" + ): + func(event_data) + + +class BufferManager(GraphicFeature): + """Smaller wrapper for pygfx.Buffer""" + + def __init__( + self, + data: NDArray | pygfx.Buffer, + buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", + isolated_buffer: bool = True, + texture_dim: int = 2, + **kwargs, + ): + super().__init__() + if isolated_buffer and not isinstance(data, pygfx.Resource): + # useful if data is read-only, example: memmaps + bdata = np.zeros(data.shape, dtype=data.dtype) + bdata[:] = data[:] else: - raise TypeError("Tuple slicing must have slice object in first position") - - if not isinstance(key, slice): - raise TypeError("Must pass slice or int object") - - start = key.start - stop = key.stop - step = key.step - for attr in [start, stop, step]: - if attr is None: - continue - if attr < 0: - raise IndexError("Negative indexing not supported.") - - if start is None: - start = 0 - - if stop is None: - stop = upper_bound - - elif stop > upper_bound: - raise IndexError( - f"Index: `{stop}` out of bounds for feature array of size: `{upper_bound}`" - ) - - step = key.step - if step is None: - step = 1 + # user's input array is used as the buffer + bdata = data + + if isinstance(data, pygfx.Resource): + # already a buffer, probably used for + # managing another BufferManager, example: VertexCmap manages VertexColors + self._buffer = data + elif buffer_type == "buffer": + self._buffer = pygfx.Buffer(bdata) + elif buffer_type == "texture": + # TODO: placeholder, not currently used since TextureArray is used specifically for Image graphics + self._buffer = pygfx.Texture(bdata, dim=texture_dim) + else: + raise ValueError( + "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" + ) - return slice(start, stop, step) + self._event_handlers: list[callable] = list() + self._shared: int = 0 -def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None]: - """ - Cleanup numpy array used for fancy indexing, make sure key[-1] <= upper_bound. + @property + def value(self) -> np.ndarray: + """numpy array object representing the data managed by this buffer""" + return self.buffer.data - Returns None if nothing to change. + def set_value(self, graphic, value): + """Sets values on entire array""" + self[:] = value - Parameters - ---------- - key: np.ndarray - integer or boolean array + @property + def buffer(self) -> pygfx.Buffer | pygfx.Texture: + """managed buffer""" + return self._buffer - upper_bound + @property + def shared(self) -> int: + """Number of graphics that share this buffer""" + return self._shared - Returns - ------- - np.ndarray - integer indexing array + @property + def __array_interface__(self): + raise BufferError( + f"Cannot use graphic feature buffer as an array, use .value instead.\n" + f"Examples: line.data.value, line.colors.value, scatter.data.value, scatter.sizes.value" + ) - """ + def __getitem__(self, item): + return self.buffer.data[item] - if key.ndim > 1: - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + def __setitem__(self, key, value): + raise NotImplementedError - # if boolean array convert to integer array of indices - if key.dtype == bool: - key = np.nonzero(key)[0] + def _parse_offset_size( + self, + key: int | slice | np.ndarray[int | bool] | list[bool | int], + upper_bound: int, + ): + """ + parse offset and size for first, i.e. n_datapoints, dimension + """ + if np.issubdtype(type(key), np.integer): + # simplest case, just an int + offset = key + size = 1 + + elif isinstance(key, slice): + # TODO: off-by-one sometimes when step is used + # the offset can be one to the left or the size + # is one extra so it's not really an issue for now + # parse slice + start, stop, step = key.indices(upper_bound) + + # account for backwards indexing + if (start > stop) and step < 0: + offset = stop + else: + offset = start - if key.size < 1: - return None + # slice.indices will give -1 if None is passed + # which just means 0 here since buffers do not + # use negative indexing + offset = max(0, offset) - # make sure indices within bounds of feature buffer range - if key[-1] > upper_bound: - raise IndexError( - f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`" - ) + # number of elements to upload + # this is indexing so do not add 1 + size = abs(stop - start) - # make sure indices are integers - if np.issubdtype(key.dtype, np.integer): - return key + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + if not key.ndim == 1: + raise TypeError( + f"can only use 1D arrays for fancy indexing, you have passed a data with: {key.ndim} dimensions" + ) + if key.dtype == bool: + # convert bool mask to integer indices + key = np.nonzero(key)[0] -class GraphicFeatureIndexable(GraphicFeature): - """An indexable Graphic Feature, colors, data, sizes etc.""" + if not np.issubdtype(key.dtype, np.integer): + # fancy indexing doesn't make sense with non-integer types + raise TypeError( + f"can only using integer or booleans arrays for fancy indexing, your array is of type: {key.dtype}" + ) - def _set(self, value): - value = self._parse_set_value(value) - self[:] = value + if key.size < 1: + # nothing to update + return - @abstractmethod - def __getitem__(self, item): - pass + # convert any negative integer indices to positive indices + key %= upper_bound - @abstractmethod - def __setitem__(self, key, value): - pass + # index of first element to upload + offset = key.min() - @abstractmethod - def _update_range(self, key): - pass + # size range to upload + # add 1 because this is direct + # passing of indices, not a start:stop + size = np.ptp(key) + 1 - @property - @abstractmethod - def buffer(self) -> Union[pygfx.Buffer, pygfx.Texture]: - """Underlying buffer for this feature""" - pass + else: + raise TypeError( + f"invalid key for indexing buffer: {key}\n" + f"valid ways to index buffers are using integers, slices, or fancy indexing with integers or bool" + ) + + return offset, size + + def _update_range( + self, + key: ( + int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...] + ), + ): + """ + Uses key from slicing to determine the offset and + size of the buffer to mark for upload to the GPU + """ + upper_bound = self.value.shape[0] - @property - def _upper_bound(self) -> int: - return self._data.shape[0] + if isinstance(key, tuple): + if any([k is Ellipsis for k in key]): + # let's worry about ellipsis later + raise TypeError("ellipses not supported for indexing buffers") + # if multiple dims are sliced, we only need the key for + # the first dimension corresponding to n_datapoints + key: int | np.ndarray[int | bool] | slice = key[0] - def _update_range_indices(self, key): - """Currently used by colors and positions data""" - if not isinstance(key, np.ndarray): - key = cleanup_slice(key, self._upper_bound) + offset, size = self._parse_offset_size(key, upper_bound) + self.buffer.update_range(offset=offset, size=size) - if isinstance(key, int): - self.buffer.update_range(key, size=1) + def _emit_event(self, type: str, key, value): + if len(self._event_handlers) < 1: return - # else if it's a slice obj - if isinstance(key, slice): - if key.step == 1: # we cleaned up the slice obj so step of None becomes 1 - # update range according to size using the offset - self.buffer.update_range(offset=key.start, size=key.stop - key.start) + event_info = { + "key": key, + "value": value, + } + event = FeatureEvent(type, info=event_info) - else: - step = key.step - # convert slice to indices - ixs = range(key.start, key.stop, step) - for ix in ixs: - self.buffer.update_range(ix, size=1) + self._call_event_handlers(event) - # TODO: See how efficient this is with large indexing - elif isinstance(key, np.ndarray): - self.buffer.update_range() + def __len__(self): + raise NotImplementedError - else: - raise TypeError("must pass int or slice to update range") + def __repr__(self): + return f"{self.__class__.__name__} buffer data:\n" f"{self.value.__repr__()}" diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py deleted file mode 100644 index b6723b34b..000000000 --- a/fastplotlib/graphics/_features/_colors.py +++ /dev/null @@ -1,429 +0,0 @@ -import numpy as np -import pygfx - -from ...utils import make_colors, get_cmap_texture, make_pygfx_colors, parse_cmap_values, quick_min_max -from ._base import ( - GraphicFeature, - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - cleanup_array_slice, -) - - -class ColorFeature(GraphicFeatureIndexable): - """ - Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` - - **event pick info:** - - ==================== =============================== ========================================================================= - key type description - ==================== =============================== ========================================================================= - "index" ``numpy.ndarray`` or ``None`` changed indices in the buffer - "new_data" ``numpy.ndarray`` or ``None`` new buffer data at the changed indices - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== =============================== ========================================================================= - - """ - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.colors - - def __getitem__(self, item): - return self.buffer.data[item] - - def __init__( - self, - parent, - colors, - n_colors: int, - alpha: float = 1.0, - collection_index: int = None, - ): - """ - ColorFeature - - Parameters - ---------- - parent: Graphic or GraphicCollection - - colors: str, array, or iterable - specify colors as a single human readable string, RGBA array, - or an iterable of strings or RGBA arrays - - n_colors: int - number of colors to hold, if passing in a single str or single RGBA array - - alpha: float - alpha value for the colors - - """ - # if provided as a numpy array of str - if isinstance(colors, np.ndarray): - if colors.dtype.kind in ["U", "S"]: - colors = colors.tolist() - # if the color is provided as a numpy array - if isinstance(colors, np.ndarray): - if colors.shape == (4,): # single RGBA array - data = np.repeat(np.array([colors]), n_colors, axis=0) - # else assume it's already a stack of RGBA arrays, keep this directly as the data - elif colors.ndim == 2: - if colors.shape[1] != 4 and colors.shape[0] != n_colors: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - data = colors - else: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - - # if the color is provided as an iterable - elif isinstance(colors, (list, tuple, np.ndarray)): - # if iterable of str - if all([isinstance(val, str) for val in colors]): - if not len(colors) == n_colors: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` of `str` " - f"where the length of the iterable is the same as the number of datapoints." - ) - - data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) - - # if it's a single RGBA array as a tuple/list - elif len(colors) == 4: - c = pygfx.Color(colors) - data = np.repeat(np.array([c]), n_colors, axis=0) - - else: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " - f"an iterable of `str` with the same length as the number of datapoints." - ) - elif isinstance(colors, str): - if colors == "random": - data = np.random.rand(n_colors, 4) - data[:, -1] = alpha - else: - data = make_pygfx_colors(colors, n_colors) - else: - # assume it's a single color, use pygfx.Color to parse it - data = make_pygfx_colors(colors, n_colors) - - if alpha != 1.0: - data[:, -1] = alpha - - super(ColorFeature, self).__init__( - parent, data, collection_index=collection_index - ) - - def __setitem__(self, key, value): - # parse numerical slice indices - if isinstance(key, slice): - _key = cleanup_slice(key, self._upper_bound) - indices = range(_key.start, _key.stop, _key.step) - - # or single numerical index - elif isinstance(key, (int, np.integer)): - key = cleanup_slice(key, self._upper_bound) - indices = [key] - - elif isinstance(key, tuple): - if not isinstance(value, (float, int, np.ndarray)): - raise ValueError( - "If using multiple-fancy indexing for color, you can only set numerical" - "values since this sets the RGBA array data directly." - ) - - if len(key) != 2: - raise ValueError( - "fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]" - ) - - # set the user passed data directly - self.buffer.data[key] = value - - # update range - # first slice obj is going to be the indexing so use key[0] - # key[1] is going to be RGBA so get rid of it to pass to _update_range - # _key = cleanup_slice(key[0], self._upper_bound) - self._update_range(key) - self._feature_changed(key, value) - return - - elif isinstance(key, np.ndarray): - key = cleanup_array_slice(key, self._upper_bound) - if key is None: - return - - indices = key - - else: - raise TypeError( - "Graphic features only support integer and numerical fancy indexing" - ) - - new_data_size = len(indices) - - if not isinstance(value, np.ndarray): - color = np.array(pygfx.Color(value)) # pygfx color parser - # make it of shape [n_colors_modify, 4] - new_colors = np.repeat( - np.array([color]).astype(np.float32), new_data_size, axis=0 - ) - - # if already a numpy array - elif isinstance(value, np.ndarray): - # if a single color provided as numpy array - if value.shape == (4,): - new_colors = value.astype(np.float32) - # if there are more than 1 datapoint color to modify - if new_data_size > 1: - new_colors = np.repeat( - np.array([new_colors]).astype(np.float32), new_data_size, axis=0 - ) - - elif value.ndim == 2: - if value.shape[1] != 4 and value.shape[0] != new_data_size: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) - # if there is a single datapoint to change color of but user has provided shape [1, 4] - if new_data_size == 1: - new_colors = value.ravel().astype(np.float32) - else: - new_colors = value.astype(np.float32) - - else: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) - - self.buffer.data[key] = new_colors - - self._update_range(key) - self._feature_changed(key, new_colors) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - else: - raise TypeError("feature changed key must be slice or int") - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="colors", pick_info=pick_info) - - 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): - """ - Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. - - Same event pick info as :class:`ColorFeature` - """ - - def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): - super(ColorFeature, self).__init__(parent, colors) - - self._cmap_name = cmap_name - self._cmap_values = cmap_values - - def __setitem__(self, key, cmap_name): - key = cleanup_slice(key, self._upper_bound) - if not isinstance(key, (slice, np.ndarray)): - raise TypeError( - "Cannot set cmap on single indices, must pass a slice object, " - "numpy.ndarray or set it on the entire data." - ) - - if isinstance(key, slice): - n_colors = len(range(key.start, key.stop, key.step)) - - else: - # numpy array - n_colors = key.size - - colors = parse_cmap_values( - n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values - ) - - 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 - - @values.setter - def values(self, values: np.ndarray): - if not isinstance(values, np.ndarray): - values = np.array(values) - - colors = parse_cmap_values( - n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values - ) - - self._cmap_values = values - - 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`. - - .cmap() returns the Texture buffer for the cmap. - - .cmap.name returns the cmap name as a str. - - **event pick info:** - - ================ =================== =============== - key type description - ================ =================== =============== - "index" ``None`` not used - "name" ``str`` colormap name - "world_object" pygfx.WorldObject world object - "vmin" ``float`` minimum value - "vmax" ``float`` maximum value - ================ =================== =============== - - """ - - def __init__(self, parent, cmap: str): - cmap_texture_view = get_cmap_texture(cmap) - super(ImageCmapFeature, self).__init__(parent, cmap_texture_view) - self._name = cmap - - def _set(self, cmap_name: str): - if self._parent.data().ndim > 2: - return - - self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) - self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self._name) - - @property - def name(self) -> str: - return self._name - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent.world_object.material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent.world_object.material.clim = ( - value, - self._parent.world_object.material.clim[1], - ) - self._feature_changed(key=None, new_data=None) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent.world_object.material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent.world_object.material.clim = ( - self._parent.world_object.material.clim[0], - value, - ) - self._feature_changed(key=None, new_data=None) - - def reset_vmin_vmax(self): - """Reset vmin vmax values based on current data""" - self.vmin, self.vmax = quick_min_max(self._parent.data()) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "world_object": self._parent.world_object, - "name": self._name, - "vmin": self.vmin, - "vmax": self.vmax, - } - - event_data = FeatureEvent(type="cmap", pick_info=pick_info) - - 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): - """ - Colormap for :class:`HeatmapGraphic` - - Same event pick info as :class:`ImageCmapFeature` - """ - - def _set(self, cmap_name: str): - # in heatmap we use one material for all ImageTiles - self._parent._material.map.data[:] = make_colors(256, cmap_name) - self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self.name) - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent._material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent._material.clim = (value, self._parent._material.clim[1]) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent._material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent._material.clim = (self._parent._material.clim[0], value) \ No newline at end of file diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py new file mode 100644 index 000000000..fe32a485f --- /dev/null +++ b/fastplotlib/graphics/_features/_common.py @@ -0,0 +1,123 @@ +import numpy as np + +from ._base import GraphicFeature, FeatureEvent + + +class Name(GraphicFeature): + """Graphic name""" + + def __init__(self, value: str): + self._value = value + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + if not isinstance(value, str): + raise TypeError("`Graphic` name must be of type ") + + if graphic._plot_area is not None: + graphic._plot_area._check_graphic_name_exists(value) + + self._value = value + + event = FeatureEvent(type="name", info={"value": value}) + self._call_event_handlers(event) + + +class Offset(GraphicFeature): + """Offset position of the graphic, [x, y, z]""" + + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False + super().__init__() + + def _validate(self, value): + if not len(value) == 3: + raise ValueError("offset must be a list, tuple, or array of 3 float values") + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) + + graphic.world_object.world.position = value + self._value = graphic.world_object.world.position.copy() + self._value.flags.writeable = False + + event = FeatureEvent(type="offset", info={"value": value}) + self._call_event_handlers(event) + + +class Rotation(GraphicFeature): + """Graphic rotation quaternion""" + + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False + super().__init__() + + def _validate(self, value): + if not len(value) == 4: + raise ValueError( + "rotation quaternion must be a list, tuple, or array of 4 float values" + ) + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) + + graphic.world_object.world.rotation = value + self._value = graphic.world_object.world.rotation.copy() + self._value.flags.writeable = False + + event = FeatureEvent(type="rotation", info={"value": value}) + self._call_event_handlers(event) + + +class Visible(GraphicFeature): + """Access or change the visibility.""" + + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + graphic.world_object.visible = value + self._value = value + + event = FeatureEvent(type="visible", info={"value": value}) + self._call_event_handlers(event) + + +class Deleted(GraphicFeature): + """ + Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted + """ + + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + self._value = value + event = FeatureEvent(type="deleted", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py deleted file mode 100644 index 23e80b470..000000000 --- a/fastplotlib/graphics/_features/_data.py +++ /dev/null @@ -1,221 +0,0 @@ -from typing import * - -import numpy as np - -import pygfx - -from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - to_gpu_supported_dtype, - cleanup_array_slice, -) - - -class PointsDataFeature(GraphicFeatureIndexable): - """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. - """ - - def __init__(self, parent, data: Any, collection_index: int = None): - data = self._fix_data(data, parent) - super(PointsDataFeature, self).__init__( - parent, data, collection_index=collection_index - ) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.positions - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_data(self, data, parent): - graphic_type = parent.__class__.__name__ - - data = to_gpu_supported_dtype(data) - - if data.ndim == 1: - # for scatter if we receive just 3 points in a 1d array, treat it as just a single datapoint - # this is different from fix_data for LineGraphic since there we assume that a 1d array - # is just y-values - if graphic_type == "ScatterGraphic": - data = np.array([data]) - elif graphic_type == "LineGraphic": - data = np.dstack([np.arange(data.size, dtype=data.dtype), data])[0] - - if data.shape[1] != 3: - if data.shape[1] != 2: - raise ValueError(f"Must pass 1D, 2D or 3D data to {graphic_type}") - - # zeros for z - zs = np.zeros(data.shape[0], dtype=data.dtype) - - data = np.dstack([data[:, 0], data[:, 1], zs])[0] - - return data - - def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put data into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_data(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - self.buffer.data[key] = value - self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - 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): - """ - Access to the Texture buffer shown in an ImageGraphic. - """ - - def __init__(self, parent, data: Any): - if data.ndim not in (2, 3): - raise ValueError( - "`data.ndim` must be 2 or 3, ImageGraphic data shape must be " - "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" - ) - - super(ImageDataFeature, self).__init__(parent, data) - - @property - def buffer(self) -> pygfx.Texture: - """Texture buffer for the image data""" - return self._parent.world_object.geometry.grid - - def update_gpu(self): - """Update the GPU with the buffer""" - self._update_range(None) - - def __call__(self, *args, **kwargs): - return self.buffer.data - - def __getitem__(self, item): - return self.buffer.data[item] - - def __setitem__(self, key, value): - # make sure float32 - value = to_gpu_supported_dtype(value) - - self.buffer.data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self.buffer.update_range((0, 0, 0), size=self.buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - 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 - def buffer(self) -> List[pygfx.Texture]: - """list of Texture buffer for the image data""" - return [img.geometry.grid for img in self._parent.world_object.children] - - def __getitem__(self, item): - return self._data[item] - - def __call__(self, *args, **kwargs): - return self._data - - def __setitem__(self, key, value): - # make sure supported type, not float64 etc. - value = to_gpu_supported_dtype(value) - - self._data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - for buffer in self.buffer: - buffer.update_range((0, 0, 0), size=buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py new file mode 100644 index 000000000..2d93745bf --- /dev/null +++ b/fastplotlib/graphics/_features/_image.py @@ -0,0 +1,262 @@ +from itertools import product + +from math import ceil + +import numpy as np + +import pygfx +from ._base import GraphicFeature, FeatureEvent, WGPU_MAX_TEXTURE_SIZE + +from ...utils import ( + make_colors, + get_cmap_texture, +) + + +# manages an array of 8192x8192 Textures representing chunks of an image +class TextureArray(GraphicFeature): + def __init__(self, data, isolated_buffer: bool = True): + super().__init__() + + data = self._fix_data(data) + + if isolated_buffer: + # useful if data is read-only, example: memmaps + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] + else: + # user's input array is used as the buffer + self._value = data + + # data start indices for each Texture + self._row_indices = np.arange( + 0, + ceil(self.value.shape[0] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, + WGPU_MAX_TEXTURE_SIZE, + ) + self._col_indices = np.arange( + 0, + ceil(self.value.shape[1] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, + WGPU_MAX_TEXTURE_SIZE, + ) + + # buffer will be an array of textures + self._buffer: np.ndarray[pygfx.Texture] = np.empty( + shape=(self.row_indices.size, self.col_indices.size), dtype=object + ) + + self._iter = None + + # iterate through each chunk of passed `data` + # create a pygfx.Texture from this chunk + for _, buffer_index, data_slice in self: + texture = pygfx.Texture(self.value[data_slice], dim=2) + + self.buffer[buffer_index] = texture + + self._shared: int = 0 + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value): + self[:] = value + + @property + def buffer(self) -> np.ndarray[pygfx.Texture]: + return self._buffer + + @property + def row_indices(self) -> np.ndarray: + """ + row indices that are used to chunk the big data array + into individual Textures on the GPU + """ + return self._row_indices + + @property + def col_indices(self) -> np.ndarray: + """ + column indices that are used to chunk the big data array + into individual Textures on the GPU + """ + return self._col_indices + + @property + def shared(self) -> int: + return self._shared + + def _fix_data(self, data): + if data.ndim not in (2, 3): + raise ValueError( + "image data must be 2D with or without an RGB(A) dimension, i.e. " + "it must be of shape [x, y], [x, y, 3] or [x, y, 4]" + ) + + # let's just cast to float32 always + return data.astype(np.float32) + + def __iter__(self): + self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) + return self + + def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]]: + """ + Iterate through each Texture within the texture array + + Returns + ------- + Texture, tuple[int, int], tuple[slice, slice] + | Texture: pygfx.Texture + | tuple[int, int]: chunk index, i.e corresponding index of ``self.buffer`` array + | tuple[slice, slice]: data slice of big array in this chunk and Texture + """ + (chunk_row, data_row_start), (chunk_col, data_col_start) = next(self._iter) + + # indices for to self.buffer for this chunk + chunk_index = (chunk_row, chunk_col) + + # stop indices of big data array for this chunk + row_stop = min(self.value.shape[0], data_row_start + WGPU_MAX_TEXTURE_SIZE) + col_stop = min(self.value.shape[1], data_col_start + WGPU_MAX_TEXTURE_SIZE) + + # row and column slices that slice the data for this chunk from the big data array + data_slice = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) + + # texture for this chunk + texture = self.buffer[chunk_index] + + return texture, chunk_index, data_slice + + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + self.value[key] = value + + for texture in self.buffer.ravel(): + texture.update_range((0, 0, 0), texture.size) + + event = FeatureEvent("data", info={"key": key, "value": value}) + self._call_event_handlers(event) + + def __len__(self): + return self.buffer.size + + +class ImageVmin(GraphicFeature): + """lower contrast limit""" + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmax = graphic._material.clim[1] + graphic._material.clim = (value, vmax) + self._value = value + + event = FeatureEvent(type="vmin", info={"value": value}) + self._call_event_handlers(event) + + +class ImageVmax(GraphicFeature): + """upper contrast limit""" + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmin = graphic._material.clim[0] + graphic._material.clim = (vmin, value) + self._value = value + + event = FeatureEvent(type="vmax", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmap(GraphicFeature): + """colormap for texture""" + + def __init__(self, value: str): + self._value = value + self.texture = get_cmap_texture(value) + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + new_colors = make_colors(256, value) + graphic._material.map.data[:] = new_colors + graphic._material.map.update_range((0, 0, 0), size=(256, 1, 1)) + + self._value = value + event = FeatureEvent(type="cmap", info={"value": value}) + self._call_event_handlers(event) + + +class ImageInterpolation(GraphicFeature): + """Image interpolation method""" + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError("`interpolation` must be one of 'nearest' or 'linear'") + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + graphic._material.interpolation = value + + self._value = value + event = FeatureEvent(type="interpolation", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmapInterpolation(GraphicFeature): + """Image cmap interpolation method""" + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError( + "`cmap_interpolation` must be one of 'nearest' or 'linear'" + ) + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + # common material for all image tiles + graphic._material.map_interpolation = value + + self._value = value + event = FeatureEvent(type="cmap_interpolation", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py new file mode 100644 index 000000000..ee7927a36 --- /dev/null +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -0,0 +1,458 @@ +from typing import Any, List + +import numpy as np +import pygfx + +from ...utils import ( + parse_cmap_values, +) +from ._base import ( + GraphicFeature, + BufferManager, + FeatureEvent, + to_gpu_supported_dtype, +) +from .utils import parse_colors + + +class VertexColors(BufferManager): + """ + + **info dict** + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | dict key | value type | value description | + +============+===========================================================+==================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which colors were indexed/sliced | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | user_value | str | np.ndarray | tuple[float] | list[float] | list[str] | user input value that was parsed into the RGBA array | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + + """ + + def __init__( + self, + colors: str | np.ndarray | tuple[float] | list[float] | list[str], + n_colors: int, + alpha: float = None, + isolated_buffer: bool = True, + ): + """ + Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` + + Parameters + ---------- + colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float + specify colors as a single human-readable string, RGBA array, + or an iterable of strings or RGBA arrays + + n_colors: int + number of colors, if passing in a single str or single RGBA array + + alpha: float, optional + alpha value for the colors + + """ + data = parse_colors(colors, n_colors, alpha) + + super().__init__(data=data, isolated_buffer=isolated_buffer) + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], + user_value: str | np.ndarray | tuple[float] | list[float] | list[str], + ): + user_key = key + + if isinstance(key, tuple): + # directly setting RGBA values for points, we do no parsing + if not isinstance(user_value, (int, float, np.ndarray)): + raise TypeError( + "Can only set from int, float, or array to set colors directly by slicing the entire array" + ) + value = user_value + + elif isinstance(key, int): + # set color of one point + n_colors = 1 + value = parse_colors(user_value, n_colors) + + elif isinstance(key, slice): + # find n_colors by converting slice to range and then parse colors + start, stop, step = key.indices(self.value.shape[0]) + + n_colors = len(range(start, stop, step)) + + value = parse_colors(user_value, n_colors) + + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) + + # make sure it's 1D + if not key.ndim == 1: + raise TypeError( + "If slicing colors with an array, it must be a 1D bool or int array" + ) + + if key.dtype == bool: + # make sure len is same + if not key.size == self.buffer.data.shape[0]: + raise IndexError( + f"Length of array for fancy indexing must match number of datapoints.\n" + f"There are {len(self.buffer.data.shape[0])} datapoints and you have passed {key.size} indices" + ) + n_colors = np.count_nonzero(key) + + elif np.issubdtype(key.dtype, np.integer): + n_colors = key.size + + else: + raise TypeError( + "If slicing colors with an array, it must be a 1D bool or int array" + ) + + value = parse_colors(user_value, n_colors) + + else: + raise TypeError( + f"invalid key for setting colors, you may set colors using integer indices, slices, or " + f"fancy indexing using an array of integers or bool" + ) + + self.buffer.data[key] = value + + self._update_range(key) + + if len(self._event_handlers) < 1: + return + + event_info = { + "key": user_key, + "value": value, + "user_value": user_value, + } + + event = FeatureEvent("colors", info=event_info) + self._call_event_handlers(event) + + def __len__(self): + return len(self.buffer.data) + + +# Manages uniform color for line or scatter material +class UniformColor(GraphicFeature): + def __init__( + self, value: str | np.ndarray | tuple | list | pygfx.Color, alpha: float = 1.0 + ): + v = (*tuple(pygfx.Color(value))[:-1], alpha) # apply alpha + self._value = pygfx.Color(v) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = value + + event = FeatureEvent(type="colors", info={"value": value}) + self._call_event_handlers(event) + + +# manages uniform size for scatter material +class UniformSize(GraphicFeature): + def __init__(self, value: int | float): + self._value = float(value) + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float | int): + graphic.world_object.material.size = float(value) + self._value = value + + event = FeatureEvent(type="sizes", info={"value": value}) + self._call_event_handlers(event) + + +class VertexPositions(BufferManager): + """ + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | dict key | value type | value description | + +==========+==========================================================+==========================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + + """ + + def __init__(self, data: Any, isolated_buffer: bool = True): + """ + Manages the vertex positions buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + data = self._fix_data(data) + super().__init__(data, isolated_buffer=isolated_buffer) + + def _fix_data(self, data): + # data = to_gpu_supported_dtype(data) + + if data.ndim == 1: + # if user provides a 1D array, assume these are y-values + data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) + + if data.shape[1] != 3: + if data.shape[1] != 2: + raise ValueError(f"Must pass 1D, 2D or 3D data") + + # zeros for z + zs = np.zeros(data.shape[0], dtype=data.dtype) + + # column stack [x, y, z] to make data of shape [n_points, 3] + data = np.column_stack([data[:, 0], data[:, 1], zs]) + + return to_gpu_supported_dtype(data) + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], + value: np.ndarray | float | list[float], + ): + # directly use the key to slice the buffer + self.buffer.data[key] = value + + # _update_range handles parsing the key to + # determine offset and size for GPU upload + self._update_range(key) + + self._emit_event("data", key, value) + + def __len__(self): + return len(self.buffer.data) + + +class PointsSizesFeature(BufferManager): + """ + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | dict key | value type | value description | + +==========+===================================================================+==============================================+ + | key | int | slice | np.ndarray[int | bool] | list[int | bool] | key at which point sizes indexed/sliced | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | value | int | float | np.ndarray | list[int | float] | tuple[int | float] | new size values for points that were changed | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + """ + + def __init__( + self, + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + isolated_buffer: bool = True, + ): + """ + Manages sizes buffer of scatter points. + """ + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__(data=sizes, isolated_buffer=isolated_buffer) + + def _fix_sizes( + self, + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + ): + if np.issubdtype(type(sizes), np.number): + # single value given + sizes = np.full( + n_datapoints, sizes, dtype=np.float32 + ) # force it into a float to avoid weird gpu errors + + elif isinstance( + sizes, (np.ndarray, tuple, list) + ): # if it's not a ndarray already, make it one + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): + raise ValueError( + f"sequence of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError( + "sizes must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints" + ) + + if np.count_nonzero(sizes < 0) > 1: + raise ValueError( + "All sizes must be positive numbers greater than or equal to 0.0." + ) + + return sizes + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | list[int | bool], + value: int | float | np.ndarray | list[int | float] | tuple[int | float], + ): + # this is a very simple 1D buffer, no parsing required, directly set buffer + self.buffer.data[key] = value + self._update_range(key) + + self._emit_event("sizes", key, value) + + def __len__(self): + return len(self.buffer.data) + + +class Thickness(GraphicFeature): + """line thickness""" + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + graphic.world_object.material.thickness = value + self._value = value + + event = FeatureEvent(type="thickness", info={"value": value}) + self._call_event_handlers(event) + + +class VertexCmap(BufferManager): + """ + Sliceable colormap feature, manages a VertexColors instance and + provides a way to set colormaps with arbitrary transforms + """ + + def __init__( + self, + vertex_colors: VertexColors, + cmap_name: str | None, + transform: np.ndarray | None, + alpha: float = 1.0, + ): + super().__init__(data=vertex_colors.buffer) + + self._vertex_colors = vertex_colors + self._cmap_name = cmap_name + self._transform = transform + self._alpha = alpha + + if self._cmap_name is not None: + if not isinstance(self._cmap_name, str): + raise TypeError( + f"cmap name must be of type , you have passed: {self._cmap_name} of type: {type(self._cmap_name)}" + ) + + if self._transform is not None: + self._transform = np.asarray(self._transform) + + n_datapoints = vertex_colors.value.shape[0] + + colors = parse_cmap_values( + n_colors=n_datapoints, + cmap_name=self._cmap_name, + transform=self._transform, + ) + colors[:, -1] = alpha + # set vertex colors from cmap + self._vertex_colors[:] = colors + + def __setitem__(self, key: slice, cmap_name): + if not isinstance(key, slice): + raise TypeError( + "fancy indexing not supported for VertexCmap, only slices " + "of a continuous are supported for apply a cmap" + ) + if key.step is not None: + raise TypeError( + "step sized indexing not currently supported for setting VertexCmap, " + "slices must be a continuous region" + ) + + # parse slice + start, stop, step = key.indices(self.value.shape[0]) + n_elements = len(range(start, stop, step)) + + colors = parse_cmap_values( + n_colors=n_elements, cmap_name=cmap_name, transform=self._transform + ) + colors[:, -1] = self.alpha + + self._cmap_name = cmap_name + self._vertex_colors[key] = colors + + # TODO: should we block vertex_colors from emitting an event? + # Because currently this will result in 2 emitted events, one + # for cmap and another from the colors + self._emit_event("cmap", key, cmap_name) + + @property + def name(self) -> str: + return self._cmap_name + + @property + def transform(self) -> np.ndarray | None: + """Get or set the cmap transform. Maps values from the transform array to the cmap colors""" + return self._transform + + @transform.setter + def transform( + self, + values: np.ndarray | list[float | int], + indices: slice | list | np.ndarray = None, + ): + if self._cmap_name is None: + raise AttributeError( + "cmap name is not set, set the cmap name before setting the transform" + ) + + values = np.asarray(values) + + colors = parse_cmap_values( + n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values + ) + + colors[:, -1] = self.alpha + + self._transform = values + + if indices is None: + indices = slice(None) + + self._vertex_colors[indices] = colors + + self._emit_event("cmap.transform", indices, values) + + @property + def alpha(self) -> float: + """Get or set the alpha level""" + return self._alpha + + @alpha.setter + def alpha(self, value: float, indices: slice | list | np.ndarray = None): + self._vertex_colors[indices, -1] = value + self._alpha = value + + self._emit_event("cmap.alpha", indices, value) + + def __len__(self): + raise NotImplementedError( + "len not implemented for `cmap`, use len(colors) instead" + ) + + def __repr__(self): + return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}" diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py deleted file mode 100644 index 6fbf93b48..000000000 --- a/fastplotlib/graphics/_features/_present.py +++ /dev/null @@ -1,72 +0,0 @@ -from pygfx import Scene, Group - -from ._base import GraphicFeature, FeatureEvent - - -class PresentFeature(GraphicFeature): - """ - Toggles if the object is present in the scene, different from visible. - Useful for computing bounding boxes from the Scene to only include graphics - that are present. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "new_data" ``bool`` new data, ``True`` or ``False`` - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, present: bool = True, collection_index: int = False): - self._scene = None - super(PresentFeature, self).__init__(parent, present, collection_index) - - def _set(self, present: bool): - present = self._parse_set_value(present) - - i = 0 - wo = self._parent.world_object - while not isinstance(self._scene, (Group, Scene)): - wo_parent = wo.parent - self._scene = wo_parent - wo = wo_parent - i += 1 - - if i > 100: - raise RecursionError( - "Exceeded scene graph depth threshold, cannot find Scene associated with" - "this graphic." - ) - - if present: - if self._parent.world_object not in self._scene.children: - self._scene.add(self._parent.world_object) - - else: - if self._parent.world_object in self._scene.children: - self._scene.remove(self._parent.world_object) - - self._data = present - self._feature_changed(key=None, new_data=present) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": 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 9a2696f7c..71ba53425 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,323 +1,192 @@ -from typing import Tuple, Union, Any +from typing import Sequence import numpy as np +from ...utils import mesh_masks from ._base import GraphicFeature, FeatureEvent -""" -positions for indexing the BoxGeometry to set the "width" and "size" of the box -hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 -""" - -x_right = np.array( - [ - True, - True, - True, - True, - False, - False, - False, - False, - False, - True, - False, - True, - True, - False, - True, - False, - False, - True, - False, - True, - True, - False, - True, - False, - ] -) - -x_left = np.array( - [ - False, - False, - False, - False, - True, - True, - True, - True, - True, - False, - True, - False, - False, - True, - False, - True, - True, - False, - True, - False, - False, - True, - False, - True, - ] -) - -y_top = np.array( - [ - False, - True, - False, - True, - False, - True, - False, - True, - True, - True, - True, - True, - False, - False, - False, - False, - False, - False, - True, - True, - False, - False, - True, - True, - ] -) - -y_bottom = np.array( - [ - True, - False, - True, - False, - True, - False, - True, - False, - False, - False, - False, - False, - True, - True, - True, - True, - True, - True, - False, - False, - True, - True, - False, - False, - ] -) - - class LinearSelectionFeature(GraphicFeature): - # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - Manages the linear selection and callbacks + **additional event attributes:** + + +--------------------+----------+------------------------------------+ + | attribute | type | description | + +====================+==========+====================================+ + | get_selected_index | callable | returns indices under the selector | + +--------------------+----------+------------------------------------+ - **event pick info** + **info dict:** - =================== =============================== ================================================================================================= - key type selection - =================== =============================== ================================================================================================= - "selected_index" ``int`` the graphic data index that corresponds to the selector position - "world_object" ``pygfx.WorldObject`` pygfx WorldObject - "new_data" ``numpy.ndarray`` or ``None`` the new selector position in world coordinates, not necessarily the same as "selected_index" - "graphic" ``Graphic`` the selector graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - =================== =============================== ================================================================================================= + +----------+------------+-------------------------------+ + | dict key | value type | value description | + +==========+============+===============================+ + | value | np.ndarray | new x or y value of selection | + +----------+------------+-------------------------------+ """ - def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): - super(LinearSelectionFeature, self).__init__(parent, data=value) + def __init__(self, axis: str, value: float, limits: tuple[float, float]): + """ - self._axis = axis - self._limits = limits + Parameters + ---------- + axis: "x" | "y" + axis the selector is restricted to - def _set(self, value: float): - if not (self._limits[0] <= value <= self._limits[1]): - return + value: float + position of the slider in world space, NOT data space + limits: (float, float) + min, max limits of the selector - if self._axis == "x": - self._parent.position_x = value - else: - self._parent.position_y = value + """ - self._data = value - self._feature_changed(key=None, new_data=value) + super().__init__() - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return + self._axis = axis + self._limits = limits + self._value = value + + @property + def value(self) -> float: + """ + selection, data x or y value + """ + return self._value - if self._parent.parent is not None: - g_ix = self._parent.get_selected_index() - else: - g_ix = None + def set_value(self, selector, value: float): + # clip value between limits + value = np.clip(value, self._limits[0], self._limits[1]) - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None + # set position + if self._axis == "x": + dim = 0 + elif self._axis == "y": + dim = 1 - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_index": g_ix, - "graphic": self._parent, - "pygfx_event": pygfx_ev, - "delta": self._parent.delta, - } + for edge in selector._edges: + edge.geometry.positions.data[:, dim] = value + edge.geometry.positions.update_range() - event_data = FeatureEvent(type="selection", pick_info=pick_info) + self._value = value - self._call_event_handlers(event_data) + event = FeatureEvent("selection", {"value": value}) + event.get_selected_index = selector.get_selected_index - def __repr__(self) -> str: - s = f"LinearSelectionFeature for {self._parent}" - return s + self._call_event_handlers(event) class LinearRegionSelectionFeature(GraphicFeature): """ - Feature for a linearly bounding region - - **event pick info** - - ===================== =============================== ======================================================================================= - key type description - ===================== =============================== ======================================================================================= - "selected_indices" ``numpy.ndarray`` or ``None`` selected graphic data indices - "world_object" ``pygfx.WorldObject`` pygfx World Object - "new_data" ``(float, float)`` current bounds in world coordinates, NOT necessarily the same as "selected_indices". - "graphic" ``Graphic`` the selection graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - "selected_data" ``numpy.ndarray`` or ``None`` selected graphic data - "move_info" ``MoveInfo`` last position and event source (pygfx.Mesh or pygfx.Line) - ===================== =============================== ======================================================================================= + **additional event attributes:** + + +----------------------+----------+------------------------------------+ + | attribute | type | description | + +======================+==========+====================================+ + | get_selected_indices | callable | returns indices under the selector | + +----------------------+----------+------------------------------------+ + | get_selected_data | callable | returns data under the selector | + +----------------------+----------+------------------------------------+ + + **info dict:** + + +----------+------------+-----------------------------+ + | dict key | value type | value description | + +==========+============+=============================+ + | value | np.ndarray | new [min, max] of selection | + +----------+------------+-----------------------------+ """ - def __init__( - self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] - ): - super(LinearRegionSelectionFeature, self).__init__(parent, data=selection) + def __init__(self, value: tuple[int, int], axis: str, limits: tuple[float, float]): + super().__init__() self._axis = axis self._limits = limits + self._value = tuple(int(v) for v in value) - self._set(selection) + @property + def value(self) -> np.ndarray[float]: + """ + (min, max) of the selection, in data space + """ + return self._value @property def axis(self) -> str: """one of "x" | "y" """ return self._axis - def _set(self, value: Tuple[float, float]): - # sets new bounds - if not isinstance(value, tuple): + def set_value(self, selector, value: Sequence[float]): + """ + Set start, stop range of selector + + Parameters + ---------- + selector: LinearRegionSelector + + value: (float, float) + (min, max) values in data space + + """ + if not len(value) == 2: raise TypeError( - "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " - "where `min_bound` and `max_bound` are numeric values." + "selection must be a array, tuple, list, or sequence in the form of `(min, max)`, " + "where `min` and `max` are numeric values." ) - # make sure bounds not exceeded - for v in value: - if not (self._limits[0] <= v <= self._limits[1]): - return + # convert to array, clip values if they are beyond the limits + value = np.asarray(value, dtype=np.float32).clip(*self._limits) # make sure `selector width >= 2`, left edge must not move past right edge! # or bottom edge must not move past top edge! - # has to be at least 2 otherwise can't join datapoints for lines - if not (value[1] - value[0]) >= 2: + if not (value[1] - value[0]) >= 0: return if self.axis == "x": # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + selector.fill.geometry.positions.data[mesh_masks.x_left] = value[0] # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + selector.fill.geometry.positions.data[mesh_masks.x_right] = value[1] # change x position of the left edge line - self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + selector.edges[0].geometry.positions.data[:, 0] = value[0] # change x position of the right edge line - self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + selector.edges[1].geometry.positions.data[:, 0] = value[1] elif self.axis == "y": # change bottom y position of the fill mesh - self._parent.fill.geometry.positions.data[y_bottom, 1] = value[0] + selector.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] # change top position of the fill mesh - self._parent.fill.geometry.positions.data[y_top, 1] = value[1] + selector.fill.geometry.positions.data[mesh_masks.y_top] = value[1] # change y position of the bottom edge line - self._parent.edges[0].geometry.positions.data[:, 1] = value[0] + selector.edges[0].geometry.positions.data[:, 1] = value[0] # change y position of the top edge line - self._parent.edges[1].geometry.positions.data[:, 1] = value[1] + selector.edges[1].geometry.positions.data[:, 1] = value[1] - self._data = value # (value[0], value[1]) + self._value = value # send changes to GPU - self._parent.fill.geometry.positions.update_range() + selector.fill.geometry.positions.update_range() - self._parent.edges[0].geometry.positions.update_range() - self._parent.edges[1].geometry.positions.update_range() + selector.edges[0].geometry.positions.update_range() + selector.edges[1].geometry.positions.update_range() - # calls any events - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + # send event if len(self._event_handlers) < 1: return - if self._parent.parent is not None: - selected_ixs = self._parent.get_selected_indices() - selected_data = self._parent.get_selected_data() - else: - selected_ixs = None - selected_data = None - - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None - - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_indices": selected_ixs, - "selected_data": selected_data, - "graphic": self._parent, - "delta": self._parent.delta, - "pygfx_event": pygfx_ev, - "move_info": self._parent._move_info, - } - - 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 + event = FeatureEvent("selection", {"value": self.value}) + + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + + self._call_event_handlers(event) + # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, + # and event.graphic.get_selected_data() to get the data under the selection + # this is probably a good idea so that the data isn't sliced until it's actually necessary diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py deleted file mode 100644 index e951064e4..000000000 --- a/fastplotlib/graphics/_features/_sizes.py +++ /dev/null @@ -1,112 +0,0 @@ -from typing import Any - -import numpy as np - -import pygfx - -from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - to_gpu_supported_dtype, - cleanup_array_slice, -) - - -class PointsSizesFeature(GraphicFeatureIndexable): - """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. - """ - - def __init__(self, parent, sizes: Any, collection_index: int = None): - sizes = self._fix_sizes(sizes, parent) - super(PointsSizesFeature, self).__init__( - parent, sizes, collection_index=collection_index - ) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.sizes - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_sizes(self, sizes, parent): - graphic_type = parent.__class__.__name__ - - n_datapoints = parent.data().shape[0] - if not isinstance(sizes, (list, tuple, np.ndarray)): - sizes = np.full(n_datapoints, sizes, dtype=np.float32) # force it into a float to avoid weird gpu errors - elif not isinstance(sizes, np.ndarray): # if it's not a ndarray already, make it one - sizes = np.array(sizes, dtype=np.float32) # read it in as a numpy.float32 - if (sizes.ndim != 1) or (sizes.size != parent.data().shape[0]): - raise ValueError( - f"sequence of `sizes` must be 1 dimensional with " - f"the same length as the number of datapoints" - ) - - sizes = to_gpu_supported_dtype(sizes) - - if any(s < 0 for s in sizes): - raise ValueError("All sizes must be positive numbers greater than or equal to 0.0.") - - if sizes.ndim == 1: - if graphic_type == "ScatterGraphic": - sizes = np.array(sizes) - else: - raise ValueError(f"Sizes must be an array of shape (n,) where n == the number of data points provided.\ - Received shape={sizes.shape}.") - - return np.array(sizes) - - def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put sizes into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_sizes(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - if value.size != self.buffer.data[key].size: - raise ValueError(f"{value.size} is not equal to buffer size {self.buffer.data[key].size}.\ - If you want to set size to a non-scalar value, make sure it's the right length!") - - self.buffer.data[key] = value - self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="sizes", pick_info=pick_info) - - 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/_text.py b/fastplotlib/graphics/_features/_text.py new file mode 100644 index 000000000..baa2734d5 --- /dev/null +++ b/fastplotlib/graphics/_features/_text.py @@ -0,0 +1,92 @@ +import numpy as np + +import pygfx + +from ._base import GraphicFeature, FeatureEvent + + +class TextData(GraphicFeature): + def __init__(self, value: str): + self._value = value + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + graphic.world_object.geometry.set_text(value) + self._value = value + + event = FeatureEvent(type="text", info={"value": value}) + self._call_event_handlers(event) + + +class FontSize(GraphicFeature): + def __init__(self, value: float | int): + self._value = value + super().__init__() + + @property + def value(self) -> float | int: + return self._value + + def set_value(self, graphic, value: float | int): + graphic.world_object.geometry.font_size = value + self._value = graphic.world_object.geometry.font_size + + event = FeatureEvent(type="font_size", info={"value": value}) + self._call_event_handlers(event) + + +class TextFaceColor(GraphicFeature): + def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = graphic.world_object.material.color + + event = FeatureEvent(type="face_color", info={"value": value}) + self._call_event_handlers(event) + + +class TextOutlineColor(GraphicFeature): + def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]): + value = pygfx.Color(value) + graphic.world_object.material.outline_color = value + self._value = graphic.world_object.material.outline_color + + event = FeatureEvent(type="outline_color", info={"value": value}) + self._call_event_handlers(event) + + +class TextOutlineThickness(GraphicFeature): + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + graphic.world_object.material.outline_thickness = value + self._value = graphic.world_object.material.outline_thickness + + event = FeatureEvent(type="outline_thickness", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py deleted file mode 100644 index f9190f0b1..000000000 --- a/fastplotlib/graphics/_features/_thickness.py +++ /dev/null @@ -1,46 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class ThicknessFeature(GraphicFeature): - """ - Used by Line graphics for line material thickness. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "new_data" ``float`` new thickness value - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, thickness: float): - self._scene = None - super(ThicknessFeature, self).__init__(parent, thickness) - - def _set(self, value: float): - value = self._parse_set_value(value) - - self._parent.world_object.material.thickness = value - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": 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/_features/utils.py b/fastplotlib/graphics/_features/utils.py new file mode 100644 index 000000000..e2f6e3428 --- /dev/null +++ b/fastplotlib/graphics/_features/utils.py @@ -0,0 +1,87 @@ +import pygfx +import numpy as np + +from ._base import to_gpu_supported_dtype +from ...utils import make_pygfx_colors + + +def parse_colors( + colors: str | np.ndarray | list[str] | tuple[str], + n_colors: int | None, + alpha: float | None = None, +): + """ + + Parameters + ---------- + colors + n_colors + alpha + key + + Returns + ------- + + """ + + # if provided as a numpy array of str + if isinstance(colors, np.ndarray): + if colors.dtype.kind in ["U", "S"]: + colors = colors.tolist() + # if the color is provided as a numpy array + if isinstance(colors, np.ndarray): + if colors.shape == (4,): # single RGBA array + data = np.repeat(np.array([colors]), n_colors, axis=0) + # else assume it's already a stack of RGBA arrays, keep this directly as the data + elif colors.ndim == 2: + if colors.shape[1] != 4 and colors.shape[0] != n_colors: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + data = colors + else: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + + # if the color is provided as list or tuple + elif isinstance(colors, (list, tuple)): + # if iterable of str + if all([isinstance(val, str) for val in colors]): + if not len(colors) == n_colors: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` of `str` " + f"where the length of the iterable is the same as the number of datapoints." + ) + + data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) + + # if it's a single RGBA array as a tuple/list + elif len(colors) == 4: + c = pygfx.Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + else: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " + f"an iterable of `str` with the same length as the number of datapoints." + ) + elif isinstance(colors, str): + if colors == "random": + data = np.random.rand(n_colors, 4) + data[:, -1] = alpha + else: + data = make_pygfx_colors(colors, n_colors) + else: + # assume it's a single color, use pygfx.Color to parse it + data = make_pygfx_colors(colors, n_colors) + + if alpha is not None: + if isinstance(alpha, float): + data[:, -1] = alpha + else: + raise TypeError("if alpha is provided it must be of type `float`") + + return to_gpu_supported_dtype(data) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py new file mode 100644 index 000000000..3727087cc --- /dev/null +++ b/fastplotlib/graphics/_positions_base.py @@ -0,0 +1,185 @@ +from typing import Any + +import numpy as np + +import pygfx +from ._base import Graphic +from ._features import ( + VertexPositions, + VertexColors, + UniformColor, + VertexCmap, + PointsSizesFeature, +) + + +class PositionsGraphic(Graphic): + """Base class for LineGraphic and ScatterGraphic""" + + @property + def data(self) -> VertexPositions: + """Get or set the vertex positions data""" + return self._data + + @data.setter + def data(self, value): + self._data[:] = value + + @property + def colors(self) -> VertexColors | pygfx.Color: + """Get or set the colors data""" + if isinstance(self._colors, VertexColors): + return self._colors + + elif isinstance(self._colors, UniformColor): + return self._colors.value + + @colors.setter + def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(self._colors, VertexColors): + self._colors[:] = value + + elif isinstance(self._colors, UniformColor): + self._colors.set_value(self, value) + + @property + def cmap(self) -> VertexCmap: + """Control the cmap, cmap transform, or cmap alpha""" + return self._cmap + + @cmap.setter + def cmap(self, name: str): + if self._cmap is None: + raise BufferError("Cannot use cmap with uniform_colors=True") + + self._cmap[:] = name + + def __init__( + self, + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_color: bool = False, + alpha: float = 1.0, + cmap: str | VertexCmap = None, + cmap_transform: np.ndarray = None, + isolated_buffer: bool = True, + *args, + **kwargs, + ): + if isinstance(data, VertexPositions): + self._data = data + else: + self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + + if cmap_transform is not None and cmap is None: + raise ValueError("must pass `cmap` if passing `cmap_transform`") + + if cmap is not None: + # if a cmap is specified it overrides colors argument + if uniform_color: + raise TypeError("Cannot use cmap if uniform_color=True") + + if isinstance(cmap, str): + # make colors from cmap + if isinstance(colors, VertexColors): + # share buffer with existing colors instance for the cmap + self._colors = colors + self._colors._shared += 1 + else: + # create vertex colors buffer + self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) + # make cmap using vertex colors buffer + self._cmap = VertexCmap( + self._colors, + cmap_name=cmap, + transform=cmap_transform, + alpha=alpha, + ) + elif isinstance(cmap, VertexCmap): + # use existing cmap instance + self._cmap = cmap + self._colors = cmap._vertex_colors + else: + raise TypeError( + "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" + ) + else: + # no cmap given + if isinstance(colors, VertexColors): + # share buffer with existing colors instance + self._colors = colors + self._colors._shared += 1 + # blank colormap instance + self._cmap = VertexCmap( + self._colors, cmap_name=None, transform=None, alpha=alpha + ) + else: + if uniform_color: + if not isinstance(colors, str): # not a single color + if not len(colors) in [3, 4]: # not an RGB(A) array + raise TypeError( + "must pass a single color if using `uniform_colors=True`" + ) + self._colors = UniformColor(colors, alpha=alpha) + self._cmap = None + else: + self._colors = VertexColors( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) + self._cmap = VertexCmap( + self._colors, cmap_name=None, transform=None, alpha=alpha + ) + + super().__init__(*args, **kwargs) + + def unshare_property(self, property: str): + """unshare a shared property. Experimental and untested!""" + if not isinstance(property, str): + raise TypeError + + f = getattr(self, property) + if f.shared == 0: + raise BufferError("Cannot detach an independent buffer") + + if property == "colors" and isinstance(property, VertexColors): + self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) + self.world_object.geometry.colors = self._colors.buffer + self._colors._shared -= 1 + + elif property == "data": + self._data._buffer = pygfx.Buffer(self._data.value.copy()) + self.world_object.geometry.positions = self._data.buffer + self._data._shared -= 1 + + elif property == "sizes": + self._sizes._buffer = pygfx.Buffer(self._sizes.value.copy()) + self.world_object.geometry.positions = self._sizes.buffer + self._sizes._shared -= 1 + + def share_property( + self, property: VertexPositions | VertexColors | PointsSizesFeature + ): + """share a property from another graphic. Experimental and untested!""" + if isinstance(property, VertexPositions): + # TODO: check if this causes a memory leak + self._data._shared -= 1 + + self._data = property + self._data._shared += 1 + self.world_object.geometry.positions = self._data.buffer + + elif isinstance(property, VertexColors): + self._colors._shared -= 1 + + self._colors = property + self._colors._shared += 1 + self.world_object.geometry.colors = self._colors.buffer + + elif isinstance(property, PointsSizesFeature): + self._sizes._shared -= 1 + + self._sizes = property + self._sizes._shared += 1 + self.world_object.geometry.sizes = self._sizes.buffer diff --git a/fastplotlib/graphics/histogram.py b/fastplotlib/graphics/histogram.py deleted file mode 100644 index 6efd83a96..000000000 --- a/fastplotlib/graphics/histogram.py +++ /dev/null @@ -1,116 +0,0 @@ -from warnings import warn -from typing import Union, Dict - -import numpy as np - -import pygfx - -from ._base import Graphic - - -class _HistogramBin(pygfx.Mesh): - def __int__(self, *args, **kwargs): - super(_HistogramBin, self).__init__(*args, **kwargs) - self.bin_center: float = None - self.frequency: Union[int, float] = None - - -class HistogramGraphic(Graphic): - def __init__( - self, - data: np.ndarray = None, - bins: Union[int, str] = "auto", - pre_computed: Dict[str, np.ndarray] = None, - colors: np.ndarray = "w", - draw_scale_factor: float = 100.0, - draw_bin_width_scale: float = 1.0, - **kwargs, - ): - """ - Create a Histogram Graphic - - Parameters - ---------- - data: np.ndarray or None, optional - data to create a histogram from, can be ``None`` if pre-computed values are provided to ``pre_computed`` - - bins: int or str, default is "auto", optional - this is directly just passed to ``numpy.histogram`` - - pre_computed: dict in the form {"hist": vals, "bin_edges" : vals}, optional - pre-computed histogram values - - colors: np.ndarray, optional - - draw_scale_factor: float, default ``100.0``, optional - scale the drawing of the entire Graphic - - draw_bin_width_scale: float, default ``1.0`` - scale the drawing of the bin widths - - kwargs - passed to Graphic - """ - - if pre_computed is None: - self.hist, self.bin_edges = np.histogram(data, bins) - else: - if not set(pre_computed.keys()) == {"hist", "bin_edges"}: - raise ValueError( - "argument to `pre_computed` must be a `dict` with keys 'hist' and 'bin_edges'" - ) - if not all(isinstance(v, np.ndarray) for v in pre_computed.values()): - raise ValueError( - "argument to `pre_computed` must be a `dict` where the values are numpy.ndarray" - ) - self.hist, self.bin_edges = pre_computed["hist"], pre_computed["bin_edges"] - - self.bin_interval = (self.bin_edges[1] - self.bin_edges[0]) / 2 - self.bin_centers = (self.bin_edges + self.bin_interval)[:-1] - - # scale between 0 - draw_scale_factor - scaled_bin_edges = ( - (self.bin_edges - self.bin_edges.min()) / (np.ptp(self.bin_edges)) - ) * draw_scale_factor - - bin_interval_scaled = scaled_bin_edges[1] / 2 - # get the centers of the bins from the edges - x_positions_bins = (scaled_bin_edges + bin_interval_scaled)[:-1].astype( - np.float32 - ) - - n_bins = x_positions_bins.shape[0] - bin_width = (draw_scale_factor / n_bins) * draw_bin_width_scale - - self.hist = self.hist.astype(np.float32) - - for bad_val in [np.nan, np.inf, -np.inf]: - if bad_val in self.hist: - warn( - f"Problematic value <{bad_val}> found in histogram, replacing with zero" - ) - self.hist[self.hist == bad_val] = 0 - - data = np.vstack([x_positions_bins, self.hist]) - - super(HistogramGraphic, self).__init__( - data=data, colors=colors, n_colors=n_bins, **kwargs - ) - - self._world_object: pygfx.Group = pygfx.Group() - - for x_val, y_val, bin_center in zip( - x_positions_bins, self.hist, self.bin_centers - ): - geometry = pygfx.plane_geometry( - width=bin_width, - height=y_val, - ) - - material = pygfx.MeshBasicMaterial() - hist_bin_graphic = _HistogramBin(geometry, material) - hist_bin_graphic.position.set(x_val, (y_val) / 2, 0) - hist_bin_graphic.bin_center = bin_center - hist_bin_graphic.frequency = y_val - - self.world_object.add(hist_bin_graphic) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 10f09eefb..5805804c7 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,202 +1,77 @@ from typing import * -from math import ceil -from itertools import product import weakref -import numpy as np - import pygfx from ..utils import quick_min_max -from ._base import Graphic, Interaction +from ._base import Graphic from .selectors import LinearSelector, LinearRegionSelector from ._features import ( - ImageCmapFeature, - ImageDataFeature, - HeatmapDataFeature, - HeatmapCmapFeature, - to_gpu_supported_dtype, + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, ) -class _AddSelectorsMixin: - def add_linear_selector( - self, selection: int = None, padding: float = None, **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Parameters - ---------- - selection: int, optional - initial position of the selector - - padding: float, optional - pad the length of the selector - - kwargs: - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - # default padding is 15% the height or width of the image - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) - - if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) - - selector = LinearSelector( - selection=selection, - limits=limits, - end_points=end_points, - parent=weakref.proxy(self), - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 - - return weakref.proxy(selector) - - def add_linear_region_selector( - self, padding: float = None, **kwargs - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. - - Parameters - ---------- - padding: float, optional - Extends the linear selector along the y-axis to make it easier to interact with. - - kwargs: optional - passed to ``LinearRegionSelector`` +class _ImageTile(pygfx.Image): + """ + Similar to pygfx.Image, only difference is that it modifies the pick_info + by adding the data row start indices that correspond to this chunk of the big image + """ - Returns - ------- - LinearRegionSelector - linear selection graphic + def __init__( + self, + geometry, + material, + data_slice: tuple[slice, slice], + chunk_index: tuple[int, int], + **kwargs, + ): + super().__init__(geometry, material, **kwargs) - """ + self._data_slice = data_slice + self._chunk_index = chunk_index - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + def _wgpu_get_pick_info(self, pick_value): + pick_info = super()._wgpu_get_pick_info(pick_value) - # create selector - selector = LinearRegionSelector( - bounds=bounds_init, - limits=limits, - size=size, - origin=origin, - parent=weakref.proxy(self), - fill_color=(0, 0, 0.35, 0.2), - **kwargs, + data_row_start, data_col_start = ( + self.data_slice[0].start, + self.data_slice[1].start, ) - self._plot_area.add_graphic(selector, center=False) - # so that it is above this graphic - selector.position_z = self.position_z + 3 - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return weakref.proxy(selector) - - # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args(self, padding: float, **kwargs): - # computes initial bounds, limits, size and origin of linear selectors - data = self.data() - - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - if padding is None: - if axis == "x": - # based on number of rows - padding = int(data.shape[0] * 0.15) - elif axis == "y": - # based on number of columns - padding = int(data.shape[1] * 0.15) - - if axis == "x": - offset = self.position_x - # x limits, number of columns - limits = (offset, data.shape[1] - 1) - - # size is number of rows + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[0] + padding - - # initial position of the selector - # center row - position_y = data.shape[0] / 2 - - # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) - - # endpoints of the data range - # used by linear selector but not linear region - # padding, n_rows + padding - end_points = (0 - padding, data.shape[0] + padding) - else: - offset = self.position_y - # y limits - limits = (offset, data.shape[0] - 1) - - # width + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[1] + padding - - # initial position of the selector - position_x = data.shape[1] / 2 + # add the actual data row and col start indices + x, y = pick_info["index"] + x += data_col_start + y += data_row_start + pick_info["index"] = (x, y) - # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) + xp, yp = pick_info["pixel_coord"] + xp += data_col_start + yp += data_row_start + pick_info["pixel_coord"] = (xp, yp) - # endpoints of the data range - # used by linear selector but not linear region - end_points = (0 - padding, data.shape[1] + padding) - - # initial bounds are 20% of the limits range - # used by LinearRegionSelector but not LinearSelector - bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) + # add row chunk and col chunk index to pick_info dict + return { + **pick_info, + "data_slice": self.data_slice, + "chunk_index": self.chunk_index, + } - return bounds_init, limits, size, origin, axis, end_points + @property + def data_slice(self) -> tuple[slice, slice]: + return self._data_slice - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area + @property + def chunk_index(self) -> tuple[int, int]: + return self._chunk_index -class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = ("data", "cmap", "present") +class ImageGraphic(Graphic): + _features = {"data", "cmap", "vmin", "vmax", "interpolation", "cmap_interpolation"} def __init__( self, @@ -204,9 +79,9 @@ def __init__( vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs, ): """ @@ -216,8 +91,7 @@ def __init__( ---------- data: array-like array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` + | shape must be ``[x_dim, y_dim]`` vmin: int, optional minimum value for color scaling, calculated from data if not provided @@ -226,262 +100,297 @@ def __init__( maximum value for color scaling, calculated from data if not provided cmap: str, optional, default "plasma" - colormap to use to display the image data, ignored if data is RGB + colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + isolated_buffer: bool, default True If True, initialize a buffer with the same shape as the input data and then set the data, useful if the data arrays are ready-only such as memmaps. If False, the input array is itself used as the buffer. - args: - additional arguments passed to Graphic - kwargs: additional keyword arguments passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the data buffer displayed in the ImageGraphic - - **cmap**: :class:`.ImageCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) - data = to_gpu_supported_dtype(data) + world_object = pygfx.Group() - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + # texture array that manages the textures on the GPU for displaying this image + self._data = TextureArray(data, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - texture = pygfx.Texture(buffer_init, dim=2) + # other graphic features + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) - geometry = pygfx.Geometry(grid=texture) + # set cmap to None for RGB images + if self._data.value.ndim == 3: + self._cmap = None + else: + self._cmap = ImageCmap(cmap) - self.cmap = ImageCmapFeature(self, cmap) + self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - # if data is RGB or RGBA - if data.ndim > 2: - material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), map_interpolation=filter - ) - # if data is just 2D without color information, use colormap LUT + # use cmap if not RGB + if self._data.value.ndim == 2: + _map = self._cmap.texture else: - material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter - ) + _map = None - world_object = pygfx.Image(geometry, material) + # one common material is used for every Texture chunk + self._material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), + map=_map, + interpolation=self._interpolation.value, + map_interpolation=self._cmap_interpolation.value, + pick_write=True, + ) - self._set_world_object(world_object) + # iterate through each texture chunk and create + # an _ImageTIle, offset the tile using the data indices + for texture, chunk_index, data_slice in self._data: - self.cmap.vmin = vmin - self.cmap.vmax = vmax + # create an ImageTile using the texture for this chunk + img = _ImageTile( + geometry=pygfx.Geometry(grid=texture), + material=self._material, + data_slice=data_slice, # used to parse pick_info + chunk_index=chunk_index, + ) - self.data = ImageDataFeature(self, data) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data + # row and column start index for this chunk + data_row_start = data_slice[0].start + data_col_start = data_slice[1].start - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + # offset tile position using the indices from the big data array + # that correspond to this chunk + img.world.x = data_col_start + img.world.y = data_row_start - def reset_feature(self, feature: str): - pass + world_object.add(img) + self._set_world_object(world_object) -class _ImageTile(pygfx.Image): - """ - Similar to pygfx.Image, only difference is that it contains a few properties to keep track of - row chunk index, column chunk index - """ + @property + def data(self) -> TextureArray: + """Get or set the image data""" + return self._data - def _wgpu_get_pick_info(self, pick_value): - pick_info = super()._wgpu_get_pick_info(pick_value) + @data.setter + def data(self, data): + self._data[:] = data - # add row chunk and col chunk index to pick_info dict - return { - **pick_info, - "row_chunk_index": self.row_chunk_index, - "col_chunk_index": self.col_chunk_index, - } + @property + def cmap(self) -> str: + """colormap name""" + if self.data.value.ndim == 3: + raise AttributeError("RGB images do not have a colormap property") + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + if self.data.value.ndim == 3: + raise AttributeError("RGB images do not have a colormap property") + self._cmap.set_value(self, name) @property - def row_chunk_index(self) -> int: - return self._row_chunk_index + def vmin(self) -> float: + """lower contrast limit""" + return self._vmin.value - @row_chunk_index.setter - def row_chunk_index(self, index: int): - self._row_chunk_index = index + @vmin.setter + def vmin(self, value: float): + self._vmin.set_value(self, value) @property - def col_chunk_index(self) -> int: - return self._col_chunk_index + def vmax(self) -> float: + """upper contrast limit""" + return self._vmax.value - @col_chunk_index.setter - def col_chunk_index(self, index: int): - self._col_chunk_index = index + @vmax.setter + def vmax(self, value: float): + self._vmax.set_value(self, value) + @property + def interpolation(self) -> str: + """image data interpolation method""" + return self._interpolation.value -class HeatmapGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = ( - "data", - "cmap", - ) + @interpolation.setter + def interpolation(self, value: str): + self._interpolation.set_value(self, value) - def __init__( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = "plasma", - filter: str = "nearest", - chunk_size: int = 8192, - isolated_buffer: bool = True, - *args, - **kwargs, - ): - """ - Create an Image Graphic + @property + def cmap_interpolation(self) -> str: + """cmap interpolation method""" + return self._cmap_interpolation.value - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` + @cmap_interpolation.setter + def cmap_interpolation(self, value: str): + self._cmap_interpolation.set_value(self, value) - vmin: int, optional - minimum value for color scaling, calculated from data if not provided + def reset_vmin_vmax(self): + """ + Reset the vmin, vmax by estimating it from the data - vmax: int, optional - maximum value for color scaling, calculated from data if not provided + Returns + ------- + None - cmap: str, optional, default "plasma" - colormap to use to display the data + """ - filter: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" + vmin, vmax = quick_min_max(self._data.value) + self.vmin = vmin + self.vmax = vmax - chunk_size: int, default 8192, max 8192 - chunk size for each tile used to make up the heatmap texture + def add_linear_selector( + self, selection: int = None, axis: str = "x", padding: float = None, **kwargs + ) -> LinearSelector: + """ + Adds a :class:`.LinearSelector`. - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer. + Parameters + ---------- + selection: int, optional + initial position of the selector - args: - additional arguments passed to Graphic + padding: float, optional + pad the length of the selector kwargs: - additional keyword arguments passed to Graphic + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector - Features - -------- + """ - **data**: :class:`.HeatmapDataFeature` - Manages the data buffer displayed in the HeatmapGraphic + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError("`axis` must be one of 'x' | 'y'") - **cmap**: :class:`.HeatmapCmapFeature` - Manages the colormap + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene + if selection is None: + selection = limits[0] - """ + if selection < limits[0] or selection > limits[1]: + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) - super().__init__(*args, **kwargs) + selector = LinearSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), + **kwargs, + ) - if chunk_size > 8192: - raise ValueError("Maximum chunk size is 8192") + self._plot_area.add_graphic(selector, center=False) - data = to_gpu_supported_dtype(data) + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + return weakref.proxy(selector) - row_chunks = range(ceil(data.shape[0] / chunk_size)) - col_chunks = range(ceil(data.shape[1] / chunk_size)) + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + axis: str = "x", + padding: float = 0.0, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. - chunks = list(product(row_chunks, col_chunks)) - # chunks is the index position of each chunk + Parameters + ---------- + selection: (float, float) + initial (min, max) of the selection - start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks] - stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs] + axis: "x" | "y" + axis the selector can move along - world_object = pygfx.Group() - self._set_world_object(world_object) + padding: float, default 100.0 + Extends the linear selector along the perpendicular axis to make it easier to interact with. - if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + kwargs + passed to ``LinearRegionSelector`` - self.cmap = HeatmapCmapFeature(self, cmap) - self._material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter - ) + Returns + ------- + LinearRegionSelector + linear selection graphic - for start, stop, chunk in zip(start_ixs, stop_ixs, chunks): - row_start, col_start = start - row_stop, col_stop = stop + """ - # x and y positions of the Tile in world space coordinates - y_pos, x_pos = row_start, col_start + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError("`axis` must be one of 'x' | 'y'") - texture = pygfx.Texture( - buffer_init[row_start:row_stop, col_start:col_stop], dim=2 - ) - geometry = pygfx.Geometry(grid=texture) - # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding - img = _ImageTile(geometry, self._material) + if selection is None: + selection = limits[0], int(limits[1] * 0.25) - # row and column chunk index for this Tile - img.row_chunk_index = chunk[0] - img.col_chunk_index = chunk[1] + if padding is None: + size *= 1.25 - img.world.x = x_pos - img.world.y = y_pos + else: + size += padding - self.world_object.add(img) + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + fill_color=fill_color, + parent=weakref.proxy(self), + **kwargs, + ) - self.data = HeatmapDataFeature(self, buffer_init) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data + self._plot_area.add_graphic(selector, center=False) - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + # place above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - def reset_feature(self, feature: str): - pass + return weakref.proxy(selector) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index d6f061ab0..d0a8cc336 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,26 +5,24 @@ import pygfx -from ..utils import parse_cmap_values -from ._base import Graphic, Interaction, PreviouslyModifiedData -from ._features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from ._positions_base import PositionsGraphic from .selectors import LinearRegionSelector, LinearSelector +from ._features import Thickness -class LineGraphic(Graphic, Interaction): - feature_events = ("data", "colors", "cmap", "thickness", "present") +class LineGraphic(PositionsGraphic): + _features = {"data", "colors", "cmap", "thickness"} def __init__( self, data: Any, thickness: float = 2.0, - colors: Union[str, np.ndarray, Iterable] = "w", + colors: str | np.ndarray | Iterable = "w", + uniform_color: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: Union[np.ndarray, List] = None, - z_position: float = None, - collection_index: int = None, - *args, + cmap_transform: np.ndarray | Iterable = None, + isolated_buffer: bool = True, **kwargs, ): """ @@ -42,99 +40,90 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or an iterable of strings or RGBA arrays - cmap: str, optional - apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors" - - cmap_values: 1D array-like or list of numerical values, optional - if provided, these values are used to map the colors from the cmap + uniform_color: bool, default ``False`` + if True, uses a uniform buffer for the line color, + basically saves GPU VRAM when the entire line has a single color alpha: float, optional, default 1.0 alpha value for the colors - z_position: float, optional - z-axis position for placing the graphic + cmap: str, optional + apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors" - args - passed to Graphic + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap - kwargs + **kwargs passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. - - **thickness**: :class:`.ThicknessFeature` - Manages the thickness feature of the lines. - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` - """ - self.data = PointsDataFeature(self, data, collection_index=collection_index) - - if cmap is not None: - n_datapoints = self.data().shape[0] - - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - self.colors = ColorFeature( - self, - colors, - n_colors=self.data().shape[0], + super().__init__( + data=data, + colors=colors, + uniform_color=uniform_color, alpha=alpha, - collection_index=collection_index, - ) - - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values + cmap=cmap, + cmap_transform=cmap_transform, + isolated_buffer=isolated_buffer, + **kwargs, ) - super(LineGraphic, self).__init__(*args, **kwargs) + self._thickness = Thickness(thickness) if thickness < 1.1: - material = pygfx.LineThinMaterial + MaterialCls = pygfx.LineThinMaterial else: - material = pygfx.LineMaterial - - self.thickness = ThicknessFeature(self, thickness) + MaterialCls = pygfx.LineMaterial + + if uniform_color: + geometry = pygfx.Geometry(positions=self._data.buffer) + material = MaterialCls( + thickness=self.thickness, + color_mode="uniform", + color=self.colors, + pick_write=True, + ) + else: + material = MaterialCls( + thickness=self.thickness, color_mode="vertex", pick_write=True + ) + geometry = pygfx.Geometry( + positions=self._data.buffer, colors=self._colors.buffer + ) - world_object: pygfx.Line = pygfx.Line( - # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()), - material=material(thickness=self.thickness(), color_mode="vertex"), - ) + world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) self._set_world_object(world_object) - if z_position is not None: - self.position_z = z_position + @property + def thickness(self) -> float: + """line thickness""" + return self._thickness.value + + @thickness.setter + def thickness(self, value: float): + self._thickness.set_value(self, value) def add_linear_selector( - self, selection: int = None, padding: float = 50, **kwargs + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs ) -> LinearSelector: """ Adds a linear selector. Parameters ---------- - selection: int - initial position of the selector + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided - padding: float - pad the length of the selector + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. kwargs passed to :class:`.LinearSelector` @@ -145,38 +134,36 @@ def add_linear_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) + selection = bounds_init[0] selector = LinearSelector( selection=selection, limits=limits, - end_points=end_points, - parent=self, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 + + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, **kwargs + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -184,8 +171,14 @@ def add_linear_region_selector( Parameters ---------- - padding: float, default 100.0 - Extends the linear selector along the y-axis to make it easier to interact with. + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. kwargs passed to ``LinearRegionSelector`` @@ -197,118 +190,61 @@ def add_linear_region_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init # create selector selector = LinearRegionSelector( - bounds=bounds_init, + selection=selection, limits=limits, size=size, - origin=origin, - parent=self, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - # so that it is below this graphic - selector.position_z = self.position_z - 1 + + # place selector below this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) # PlotArea manages this for garbage collection etc. just like all other Graphics # so we should only work with a proxy on the user-end return weakref.proxy(selector) # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args(self, padding: float, **kwargs): - # computes initial bounds, limits, size and origin of linear selectors - data = self.data() + def _get_linear_selector_init_args( + self, axis: str, padding + ) -> tuple[tuple[float, float], tuple[float, float], float, float]: + # computes args to create selectors + n_datapoints = self.data.value.shape[0] + value_25p = int(n_datapoints / 4) - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] if axis == "x": - offset = self.position_x - # x limits - limits = (data[0, 0] + offset, data[-1, 0] + offset) + # xvals + axis_vals = data[:, 0] - # height + padding - size = np.ptp(data[:, 1]) + padding + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] - # initial position of the selector - position_y = (data[:, 1].min() + data[:, 1].max()) / 2 + bounds_init = axis_vals[0], axis_vals[value_25p] + limits = axis_vals[0], axis_vals[-1] - # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) - # endpoints of the data range - # used by linear selector but not linear region - end_points = ( - self.data()[:, 1].min() - padding, - self.data()[:, 1].max() + padding, - ) - else: - offset = self.position_y - # y limits - limits = (data[0, 1] + offset, data[-1, 1] + offset) - - # width + padding - size = np.ptp(data[:, 0]) + padding - - # initial position of the selector - position_x = (data[:, 0].min() + data[:, 0].max()) / 2 - - # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) - - end_points = ( - self.data()[:, 0].min() - padding, - self.data()[:, 0].max() + padding, - ) - - # initial bounds are 20% of the limits range - bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) - - return bounds_init, limits, size, origin, axis, end_points + # center of selector along the other axis + center = np.nanmean(magn_vals) - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - def set_feature(self, feature: str, new_data: Any, indices: Any = None): - if not hasattr(self, "_previous_data"): - self._previous_data = dict() - elif hasattr(self, "_previous_data"): - self.reset_feature(feature) - - feature_instance = getattr(self, feature) - if indices is not None: - previous = feature_instance[indices].copy() - feature_instance[indices] = new_data - else: - previous = feature_instance._data.copy() - feature_instance._set(new_data) - if feature in self._previous_data.keys(): - self._previous_data[feature].data = previous - self._previous_data[feature].indices = indices - else: - self._previous_data[feature] = PreviouslyModifiedData( - data=previous, indices=indices - ) - - def reset_feature(self, feature: str): - if feature not in self._previous_data.keys(): - return - - prev_ixs = self._previous_data[feature].indices - feature_instance = getattr(self, feature) - if prev_ixs is not None: - feature_instance[prev_ixs] = self._previous_data[feature].data - else: - feature_instance._set(self._previous_data[feature].data) + return bounds_init, limits, size, center diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 38597a830..01faa9164 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -1,5 +1,4 @@ from typing import * -from copy import deepcopy import weakref import numpy as np @@ -7,28 +6,137 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Interaction, PreviouslyModifiedData, GraphicCollection -from ._features import GraphicFeature +from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector -class LineCollection(GraphicCollection, Interaction): - child_type = LineGraphic.__name__ - feature_events = ("data", "colors", "cmap", "thickness", "present") +class _LineCollectionProperties: + """Mix-in class for LineCollection properties""" + + @property + def colors(self) -> CollectionFeature: + """get or set colors of lines in the collection""" + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all lines to one str color + for g in self: + g.colors = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each line + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + """get or set data of lines in the collection""" + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + for g, v in zip(self, values): + g.data = v + + @property + def cmap(self) -> CollectionFeature: + """ + Get or set a cmap along the line collection. + + Optionally set using a tuple ("cmap", , ) to set the transform and/or alpha. + Example: + + line_collection.cmap = ("jet", sine_transform_vals, 0.7) + + """ + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, args): + if isinstance(args, str): + name = args + transform, alpha = None, 1.0 + elif len(args) == 1: + name = args[0] + transform, alpha = None, None + + elif len(args) == 2: + name, transform = args + alpha = None + + elif len(args) == 3: + name, transform, alpha = args + + colors = parse_cmap_values( + n_colors=len(self), cmap_name=name, transform=transform + ) + colors[:, -1] = alpha + self.colors = colors + + @property + def thickness(self) -> np.ndarray: + """get or set the thickness of the lines""" + return np.asarray([g.thickness for g in self]) + + @thickness.setter + def thickness(self, values: np.ndarray | list[float]): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self, values): + g.thickness = v + + +class LineCollectionIndexer(CollectionIndexer, _LineCollectionProperties): + """Indexer for line collections""" + + pass + + +class LineCollection(GraphicCollection, _LineCollectionProperties): + _child_type = LineGraphic + _indexer = LineCollectionIndexer def __init__( self, - data: List[np.ndarray], - z_position: Union[List[float], float] = None, - thickness: Union[float, List[float]] = 2.0, - colors: Union[List[np.ndarray], np.ndarray] = "w", + data: np.ndarray | List[np.ndarray], + thickness: float | Sequence[float] = 2.0, + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Union[List[str], str] = None, - cmap_values: Union[np.ndarray, List] = None, + cmap: Sequence[str] | str = None, + cmap_transform: np.ndarray | List = None, name: str = None, - metadata: Union[list, tuple, np.ndarray] = None, - *args, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, **kwargs, ): """ @@ -36,78 +144,84 @@ def __init__( Parameters ---------- + data: list of array-like + List or array-like of multiple line data to plot - data: list of array-like or array - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] - - z_position: list of float or float, optional - | if ``float``, single position will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - thickness: float or list of float, default 2.0 + thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines | if ``list`` of ``float``, each value will apply to the individual lines - colors: str, RGBA array, list of RGBA array, or list of str, default "w" + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - cmap: list of str or str, optional + alpha: float, optional + alpha value for colors, if colors is a ``str`` + + cmap: Iterable of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines .. note:: ``cmap`` overrides any arguments passed to ``colors`` - cmap_values: 1D array-like or list of numerical values, optional + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap name: str, optional - name of the line collection - - metadata: list, tuple, or array - metadata associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + name of the line collection as a whole - args - passed to GraphicCollection + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - kwargs - passed to GraphicCollection + metadata: Any + meatadata associated with the collection as a whole - Features - -------- + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` - Collections support the same features as the underlying graphic. You just have to slice the selection. + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - See :class:`LineGraphic` details on the features. + kwargs_collection + kwargs for the collection, passed to GraphicCollection """ - super(LineCollection, self).__init__(name) + super().__init__(name=name, metadata=metadata, **kwargs) - if not isinstance(z_position, float) and z_position is not None: - if len(data) != len(z_position): + if not isinstance(thickness, (float, int)): + if len(thickness) != len(data): raise ValueError( - "z_position must be a single float or an iterable with same length as data" + f"len(thickness) != len(data)\n" f"{len(thickness)} != {len(data)}" ) - if not isinstance(thickness, (float, int)): - if len(thickness) != len(data): + if names is not None: + if len(names) != len(data): + raise ValueError( + f"len(names) != len(data)\n" f"{len(names)} != {len(data)}" + ) + + if metadatas is not None: + if len(metadatas) != len(data): raise ValueError( - "args must be a single float or an iterable with same length as data" + f"len(metadata) != len(data)\n" f"{len(metadatas)} != {len(data)}" ) - if metadata is not None: - if len(metadata) != len(data): + if kwargs_lines is not None: + if len(kwargs_lines) != len(data): raise ValueError( - f"len(metadata) != len(data)\n" f"{len(metadata)} != {len(data)}" + f"len(kwargs_lines) != len(data)\n" + f"{len(kwargs_lines)} != {len(data)}" ) - self._cmap_values = cmap_values + self._cmap_transform = cmap_transform self._cmap_str = cmap # cmap takes priority over colors @@ -115,7 +229,7 @@ def __init__( # cmap across lines if isinstance(cmap, str): colors = parse_cmap_values( - n_colors=len(data), cmap_name=cmap, cmap_values=cmap_values + n_colors=len(data), cmap_name=cmap, transform=cmap_transform ) single_color = False cmap = None @@ -174,14 +288,12 @@ def __init__( "or must be a tuple/list of colors represented by a string with the same length as the data" ) + if kwargs_lines is None: + kwargs_lines = dict() + self._set_world_object(pygfx.Group()) for i, d in enumerate(data): - if isinstance(z_position, list): - _z = z_position[i] - else: - _z = 1.0 - if isinstance(thickness, list): _s = thickness[i] else: @@ -198,66 +310,51 @@ def __init__( _cmap = cmap[i] _c = None - if metadata is not None: - _m = metadata[i] + if metadatas is not None: + _m = metadatas[i] else: _m = None + if names is not None: + _name = names[i] + else: + _name = None + lg = LineGraphic( data=d, thickness=_s, colors=_c, - z_position=_z, + uniform_color=uniform_colors, cmap=_cmap, - collection_index=i, + name=_name, metadata=_m, + isolated_buffer=isolated_buffer, + **kwargs_lines, ) - self.add_graphic(lg, reset_index=False) - - @property - def cmap(self) -> str: - return self._cmap_str + self.add_graphic(lg) - @cmap.setter - def cmap(self, cmap: str): - colors = parse_cmap_values( - n_colors=len(self), cmap_name=cmap, cmap_values=self.cmap_values - ) - - for i, g in enumerate(self.graphics): - g.colors = colors[i] - - self._cmap_str = cmap - - @property - def cmap_values(self) -> np.ndarray: - return self._cmap_values - - @cmap_values.setter - def cmap_values(self, values: Union[np.ndarray, list]): - colors = parse_cmap_values( - n_colors=len(self), cmap_name=self.cmap, cmap_values=values - ) - - for i, g in enumerate(self.graphics): - g.colors = colors[i] - - self._cmap_values = values + def __getitem__(self, item) -> LineCollectionIndexer: + return super().__getitem__(item) def add_linear_selector( - self, selection: int = None, padding: float = 50, **kwargs + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs ) -> LinearSelector: """ - Adds a :class:`.LinearSelector` . + Adds a linear selector. Parameters ---------- - selection: int - initial position of the selector + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided - padding: float - pad the length of the selector + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. kwargs passed to :class:`.LinearSelector` @@ -268,46 +365,51 @@ def add_linear_selector( """ - ( - bounds, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) + selection = bounds_init[0] selector = LinearSelector( selection=selection, limits=limits, - end_points=end_points, - parent=self, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 + + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, **kwargs + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, ) -> LinearRegionSelector: """ - Add a :class:`.LinearRegionSelector` + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. Parameters ---------- - padding: float, default 100.0 - Extends the linear selector along the y-axis to make it easier to interact with. + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. kwargs passed to ``LinearRegionSelector`` @@ -319,155 +421,62 @@ def add_linear_region_selector( """ - ( - bounds, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + if selection is None: + selection = bounds_init + + # create selector selector = LinearRegionSelector( - bounds=bounds, + selection=selection, limits=limits, size=size, - origin=origin, - parent=self, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z - 1 - return weakref.proxy(selector) + # place selector below this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) - def _get_linear_selector_init_args(self, padding, **kwargs): - bounds_init = list() - limits = list() - sizes = list() - origin = list() - end_points = list() - - for g in self.graphics: - ( - _bounds_init, - _limits, - _size, - _origin, - axis, - _end_points, - ) = g._get_linear_selector_init_args(padding=0, **kwargs) - - bounds_init.append(_bounds_init) - limits.append(_limits) - sizes.append(_size) - origin.append(_origin) - end_points.append(_end_points) - - # set the init bounds using the extents of the collection - b = np.vstack(bounds_init) - bounds = (b[:, 0].min(), b[:, 1].max()) - - # set the limits using the extents of the collection - limits = np.vstack(limits) - limits = (limits[:, 0].min(), limits[:, 1].max()) - - # stack endpoints - end_points = np.vstack(end_points) - # use the min endpoint for index 0, highest endpoint for index 1 - end_points = [ - end_points[:, 0].min() - padding, - end_points[:, 1].max() + padding, - ] - - # TODO: refactor this to use `LineStack.graphics[-1].position.y` - if isinstance(self, LineStack): - stack_offset = self.separation * len(sizes) - # sum them if it's a stack - size = sum(sizes) - # add the separations - size += stack_offset - - # a better way to get the max y value? - # graphics y-position + data y-max + padding - end_points[1] = ( - self.graphics[-1].position_y - + self.graphics[-1].data()[:, 1].max() - + padding - ) - - else: - # just the biggest one if not stacked - size = max(sizes) + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return weakref.proxy(selector) - size += padding + def _get_linear_selector_init_args(self, axis, padding): + # use bbox to get size and center + bbox = self.world_object.get_world_bounding_box() if axis == "x": - o = np.vstack(origin) - origin_y = (o[:, 1].min() + o[:, 1].max()) / 2 - origin = (limits[0], origin_y) - else: - o = np.vstack(origin) - origin_x = (o[:, 0].min() + o[:, 0].max()) / 2 - origin = (origin_x, limits[0]) - - return bounds, limits, size, origin, axis, end_points - - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - def set_feature(self, feature: str, new_data: Any, indices: Any): - # if single value force to be an array of size 1 - if isinstance(indices, (np.integer, int)): - indices = np.array([indices]) - if not hasattr(self, "_previous_data"): - self._previous_data = dict() - elif hasattr(self, "_previous_data"): - if feature in self._previous_data.keys(): - # for now assume same index won't be changed with diff data - # I can't think of a usecase where we'd have to check the data too - # so unless there is a bug we keep it like this - if self._previous_data[feature].indices == indices: - return # nothing to change, and this allows bidirectional linking without infinite recursion - - self.reset_feature(feature) - - # coll_feature = getattr(self[indices], feature) - - data = list() - - for graphic in self.graphics[indices]: - feature_instance: GraphicFeature = getattr(graphic, feature) - data.append(feature_instance()) + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25p = (xmax - xmin) / 4 - # later we can think about multi-index events - previous_data = deepcopy(data[0]) + bounds = (xmin, value_25p) + limits = (xmin, xmax) + # size from orthogonal axis + size = np.ptp(bbox[:, 1]) * 1.5 + # center on orthogonal axis + center = bbox[:, 1].mean() - if feature in self._previous_data.keys(): - self._previous_data[feature].data = previous_data - self._previous_data[feature].indices = indices - else: - self._previous_data[feature] = PreviouslyModifiedData( - data=previous_data, indices=indices - ) + elif axis == "y": + ydata = np.array(self.data[:, 1]) + xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) + value_25p = (xmax - xmin) / 4 - # finally set the new data - # this MUST occur after setting the previous data attribute to prevent recursion - # since calling `feature._set()` triggers all the feature callbacks - feature_instance._set(new_data) - - def reset_feature(self, feature: str): - if feature not in self._previous_data.keys(): - return + bounds = (xmin, value_25p) + limits = (xmin, xmax) - # implemented for a single index at moment - prev_ixs = self._previous_data[feature].indices - coll_feature = getattr(self[prev_ixs], feature) + size = np.ptp(bbox[:, 0]) * 1.5 + # center on orthogonal axis + center = bbox[:, 0].mean() - coll_feature.block_events(True) - coll_feature._set(self._previous_data[feature].data) - coll_feature.block_events(False) + return bounds, limits, size, center axes = {"x": 0, "y": 1, "z": 2} @@ -477,14 +486,19 @@ class LineStack(LineCollection): def __init__( self, data: List[np.ndarray], - z_position: Union[List[float], float] = None, - thickness: Union[float, List[float]] = 2.0, - colors: Union[List[np.ndarray], np.ndarray] = "w", - cmap: Union[List[str], str] = None, - separation: float = 10, - separation_axis: str = "y", + thickness: float | Iterable[float] = 2.0, + colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", + alpha: float = 1.0, + cmap: Iterable[str] | str = None, + cmap_transform: np.ndarray | List = None, name: str = None, - *args, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + separation: float = 10.0, + separation_axis: str = "y", + kwargs_lines: list[dict] = None, **kwargs, ): """ @@ -493,32 +507,46 @@ def __init__( Parameters ---------- data: list of array-like - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] + List or array-like of multiple line data to plot - z_position: list of float or float, optional - | if ``float``, single position will be used for all lines - | if ``list`` of ``float``, each value will apply to individual lines + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] - thickness: float or list of float, default 2.0 + thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines | if ``list`` of ``float``, each value will apply to the individual lines - colors: str, RGBA array, list of RGBA array, or list of str, default "w" + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | is ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``list`` of ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + alpha: float, optional + alpha value for colors, if colors is a ``str`` - cmap: list of str or str, optional + cmap: Iterable of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines .. note:: ``cmap`` overrides any arguments passed to ``colors`` + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + name: str, optional - name of the line stack + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` separation: float, default 10 space in between each line graphic in the stack @@ -526,43 +554,40 @@ def __init__( separation_axis: str, default "y" axis in which the line graphics in the stack should be separated - name: str, optional - name of the line stack - - args - passed to LineCollection - - kwargs - passed to LineCollection + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - Features - -------- - - Collections support the same features as the underlying graphic. You just have to slice the selection. - - See :class:`LineGraphic` details on the features. + kwargs_collection + kwargs for the collection, passed to GraphicCollection """ - super(LineStack, self).__init__( + super().__init__( data=data, - z_position=z_position, thickness=thickness, colors=colors, + alpha=alpha, cmap=cmap, + cmap_transform=cmap_transform, name=name, + names=names, + metadata=metadata, + metadatas=metadatas, + isolated_buffer=isolated_buffer, + kwargs_lines=kwargs_lines, **kwargs, ) axis_zero = 0 for i, line in enumerate(self.graphics): if separation_axis == "x": - line.position_x = axis_zero + line.offset = (axis_zero, *line.offset[1:]) + elif separation_axis == "y": - line.position_y = axis_zero + line.offset = (line.offset[0], axis_zero, line.offset[2]) axis_zero = ( - axis_zero + line.data()[:, axes[separation_axis]].max() + separation + axis_zero + line.data.value[:, axes[separation_axis]].max() + separation ) self.separation = separation diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 961324c23..39d815c95 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -3,24 +3,24 @@ import numpy as np import pygfx -from ..utils import parse_cmap_values -from ._base import Graphic -from ._features import PointsDataFeature, ColorFeature, CmapFeature, PointsSizesFeature +from ._positions_base import PositionsGraphic +from ._features import PointsSizesFeature, UniformSize -class ScatterGraphic(Graphic): - feature_events = ("data", "sizes", "colors", "cmap", "present") +class ScatterGraphic(PositionsGraphic): + _features = {"data", "sizes", "colors", "cmap"} def __init__( self, - data: np.ndarray, - sizes: Union[int, float, np.ndarray, list] = 1, - colors: np.ndarray = "w", + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_color: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: Union[np.ndarray, List] = None, - z_position: float = 0.0, - *args, + cmap_transform: np.ndarray = None, + isolated_buffer: bool = True, + sizes: float | np.ndarray | Iterable[float] = 1, + uniform_size: bool = False, **kwargs, ): """ @@ -31,69 +31,92 @@ def __init__( data: array-like Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] - sizes: float or iterable of float, optional, default 1.0 - size of the scatter points - colors: str, array, or iterable, default "w" specify colors as a single human readable string, a single RGBA array, or an iterable of strings or RGBA arrays + uniform_color: bool, default False + if True, uses a uniform buffer for the scatter point colors, + basically saves GPU VRAM when the entire line has a single color + + alpha: float, optional, default 1.0 + alpha value for the colors + cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors" - cmap_values: 1D array-like or list of numerical values, optional + cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap - alpha: float, optional, default 1.0 - alpha value for the colors + isolated_buffer: bool, default True + whether the buffers should be isolated from the user input array. + Generally always ``True``, ``False`` is for rare advanced use. - z_position: float, optional - z-axis position for placing the graphic + sizes: float or iterable of float, optional, default 1.0 + size of the scatter points - args - passed to Graphic + uniform_size: bool, default False + if True, uses a uniform buffer for the scatter point sizes, + basically saves GPU VRAM when all scatter points are the same size kwargs passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. + """ - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. + super().__init__( + data=data, + colors=colors, + uniform_color=uniform_color, + alpha=alpha, + cmap=cmap, + cmap_transform=cmap_transform, + isolated_buffer=isolated_buffer, + **kwargs, + ) - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. + n_datapoints = self.data.value.shape[0] - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` + geo_kwargs = {"positions": self._data.buffer} + material_kwargs = {"pick_write": True} - """ - self.data = PointsDataFeature(self, data) - n_datapoints = self.data().shape[0] + if uniform_color: + material_kwargs["color_mode"] = "uniform" + material_kwargs["color"] = self.colors + else: + material_kwargs["color_mode"] = "vertex" + geo_kwargs["colors"] = self.colors.buffer - if cmap is not None: - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - self.colors = ColorFeature(self, colors, n_colors=n_datapoints, alpha=alpha) - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values - ) - - self.sizes = PointsSizesFeature(self, sizes) - super(ScatterGraphic, self).__init__(*args, **kwargs) + if uniform_size: + material_kwargs["size_mode"] = "uniform" + self._sizes = UniformSize(sizes) + material_kwargs["size"] = self.sizes + else: + material_kwargs["size_mode"] = "vertex" + self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) + geo_kwargs["sizes"] = self.sizes.buffer world_object = pygfx.Points( - pygfx.Geometry(positions=self.data(), sizes=self.sizes(), colors=self.colors()), - material=pygfx.PointsMaterial(color_mode="vertex", vertex_sizes=True), + pygfx.Geometry(**geo_kwargs), + material=pygfx.PointsMaterial(**material_kwargs), ) self._set_world_object(world_object) - self.position_z = z_position + @property + def sizes(self) -> PointsSizesFeature | float: + """Get or set the scatter point size(s)""" + if isinstance(self._sizes, PointsSizesFeature): + return self._sizes + + elif isinstance(self._sizes, UniformSize): + return self._sizes.value + + @sizes.setter + def sizes(self, value): + if isinstance(self._sizes, PointsSizesFeature): + self._sizes[:] = value + + elif isinstance(self._sizes, UniformSize): + self._sizes.set_value(self, value) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 1fb0c453e..4f28f571c 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -2,11 +2,5 @@ from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector -from ._sync import Synchronizer -__all__ = [ - "LinearSelector", - "LinearRegionSelector", - "PolygonSelector", - "Synchronizer", -] +__all__ = ["LinearSelector", "LinearRegionSelector"] diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index e6796f270..0fc48058d 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -1,6 +1,7 @@ from typing import * from dataclasses import dataclass from functools import partial +import weakref import numpy as np @@ -34,7 +35,11 @@ class MoveInfo: # Selector base class class BaseSelector(Graphic): - feature_events = ("selection",) + _features = {"selection"} + + @property + def axis(self) -> str: + return self._axis def __init__( self, @@ -44,7 +49,8 @@ def __init__( hover_responsive: Tuple[WorldObject, ...] = None, arrow_keys_modifier: str = None, axis: str = None, - name: str = None + parent: Graphic = None, + **kwargs, ): if edges is None: edges = tuple() @@ -70,7 +76,7 @@ def __init__( for wo in self._hover_responsive: self._original_colors[wo] = wo.material.color - self.axis = axis + self._axis = axis # current delta in world coordinates self.delta: np.ndarray = None @@ -94,7 +100,9 @@ def __init__( self._pygfx_event = None - Graphic.__init__(self, name=name) + self._parent = parent + + Graphic.__init__(self, **kwargs) def get_selected_index(self): """Not implemented for this selector""" @@ -109,7 +117,7 @@ def get_selected_data(self): raise NotImplementedError def _get_source(self, graphic): - if self.parent is None and graphic is None: + if self._parent is None and graphic is None: raise AttributeError( "No Graphic to apply selector. " "You must either set a ``parent`` Graphic on the selector, or pass a graphic." @@ -119,11 +127,11 @@ def _get_source(self, graphic): if graphic is not None: source = graphic else: - source = self.parent + source = self._parent return source - def _add_plot_area_hook(self, plot_area): + def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area # when the pointer is pressed on a fill, edge or vertex @@ -136,8 +144,10 @@ def _add_plot_area_hook(self, plot_area): for fill in self._fill: if fill.material.color_is_transparent: - pfunc_fill = partial(self._check_fill_pointer_event, fill) - self._plot_area.renderer.add_event_handler(pfunc_fill, "pointer_down") + self._pfunc_fill = partial(self._check_fill_pointer_event, fill) + self._plot_area.renderer.add_event_handler( + self._pfunc_fill, "pointer_down" + ) # when the pointer moves self._plot_area.renderer.add_event_handler(self._move, "pointer_move") @@ -259,7 +269,7 @@ def _move_to_pointer(self, ev): """ Calculates delta just using current world object position and calls self._move_graphic(). """ - current_position: np.ndarray = self.position + current_position: np.ndarray = self.offset # middle mouse button clicks if ev.button != 3: @@ -345,8 +355,6 @@ def _key_down(self, ev): if ev.key not in key_bind_direction.keys(): return - # print(ev.key) - self._key_move_value = ev.key def _key_up(self, ev): @@ -356,26 +364,10 @@ def _key_up(self, ev): self._move_info = None - 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") - - self._plot_area.renderer.remove_event_handler(self._key_down, "key_down") - self._plot_area.renderer.remove_event_handler(self._key_up, "key_up") - - # 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: - fea = getattr(self, n) - fea.clear_event_handlers() + def _fpl_cleanup(self): + if hasattr(self, "_pfunc_fill"): + self._plot_area.renderer.remove_event_handler( + self._pfunc_fill, "pointer_down" + ) + del self._pfunc_fill + super()._fpl_cleanup() diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 16ccab1b4..22ba96a28 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -3,22 +3,41 @@ from numbers import Real import numpy as np - import pygfx -try: - import ipywidgets - - HAS_IPYWIDGETS = True -except (ImportError, ModuleNotFoundError): - HAS_IPYWIDGETS = False - -from .._base import Graphic, GraphicCollection +from ...utils.gui import IS_JUPYTER +from .._base import Graphic +from .._collection_base import GraphicCollection from .._features._selection_features import LinearSelectionFeature from ._base_selector import BaseSelector +if IS_JUPYTER: + # If using the jupyter backend, user has jupyter_rfb, and thus also ipywidgets + import ipywidgets + + class LinearSelector(BaseSelector): + @property + def parent(self) -> Graphic: + return self._parent + + @property + def selection(self) -> float: + """ + x or y value of selector's current position + """ + return self._selection.value + + @selection.setter + def selection(self, value: int): + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, value) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -28,23 +47,24 @@ def limits(self, values: Tuple[float, float]): # check that `values` is an iterable of two real numbers # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): - raise TypeError( - "limits must be an iterable of two numeric values" - ) - self._limits = tuple(map(round, values)) # if values are close to zero things get weird so round them + raise TypeError("limits must be an iterable of two numeric values") + self._limits = tuple( + map(round, values) + ) # if values are close to zero things get weird so round them self.selection._limits = self._limits # TODO: make `selection` arg in graphics data space not world space def __init__( self, - selection: int, - limits: Tuple[int, int], + selection: float, + limits: Sequence[float], + size: float, + center: float, axis: str = "x", parent: Graphic = None, - end_points: Tuple[int, int] = None, - arrow_keys_modifier: str = "Shift", + color: str | tuple = "w", thickness: float = 2.5, - color: Any = "w", + arrow_keys_modifier: str = "Shift", name: str = None, ): """ @@ -61,12 +81,12 @@ def __init__( axis: str, default "x" "x" | "y", the axis which the slider can move along + center: float + center offset of the selector on the orthogonal axis, by default the data mean + parent: Graphic parent graphic for this LineSelector - end_points: (int, int) - set length of slider by bounding it between two x-pos or two y-pos - arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None``. Double click on the selector first to enable the @@ -81,34 +101,23 @@ def __init__( name: str, optional name of line slider - Features - -------- - - selection: :class:`.LinearSelectionFeature` - ``selection()`` returns the current selector position in world coordinates. - Use ``get_selected_index()`` to get the currently selected index in data - space. - Use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for - event pick_info table - """ if len(limits) != 2: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") - self._limits = tuple(map(round, limits)) + self._limits = np.asarray(limits) - selection = round(selection) + end_points = [-size / 2, size / 2] if axis == "x": - xs = np.zeros(2) + xs = np.array([selection, selection]) ys = np.array(end_points) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) elif axis == "y": xs = np.array(end_points) - ys = np.zeros(2) + ys = np.array([selection, selection]) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) @@ -127,12 +136,14 @@ def __init__( line_inner = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=line_data), - material=material(thickness=thickness, color=color), + material=material(thickness=thickness, color=color, pick_write=True), ) self.line_outer = pygfx.Line( geometry=pygfx.Geometry(positions=line_data), - material=material(thickness=thickness + 6, color=self.colors_outer), + material=material( + thickness=thickness + 6, color=self.colors_outer, pick_write=True + ), ) line_inner.world.z = self.line_outer.world.z + 1 @@ -142,26 +153,17 @@ def __init__( world_object.add(self.line_outer) world_object.add(line_inner) - self._set_world_object(world_object) - - # set x or y position - if axis == "x": - self.position_x = selection - else: - self.position_y = selection - - self.selection = LinearSelectionFeature( - self, axis=axis, value=selection, limits=self._limits - ) - self._move_info: dict = None - self.parent = parent - self._block_ipywidget_call = False self._handled_widgets = list() + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # init base selector BaseSelector.__init__( self, @@ -169,12 +171,28 @@ def __init__( hover_responsive=(line_inner, self.line_outer), arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, name=name, + offset=offset, ) + self._set_world_object(world_object) + + self._selection = LinearSelectionFeature( + axis=axis, value=selection, limits=self._limits + ) + + if self._parent is not None: + self.selection = selection + else: + self._selection.set_value(self, selection) + + # update any ipywidgets + self.add_event_handler(self._update_ipywidgets, "selection") + def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = int(value) @@ -184,16 +202,13 @@ def _setup_ipywidget_slider(self, widget): # user changes widget -> linear selection changes widget.observe(self._ipywidget_callback, "value") - # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) - self._handled_widgets.append(widget) def _update_ipywidgets(self, ev): # update the ipywidget sliders when LinearSelector value changes self._block_ipywidget_call = True # prevent infinite recursion - value = ev.pick_info["new_data"] + value = ev.info["value"] # update all the handled slider widgets for widget in self._handled_widgets: if isinstance(widget, ipywidgets.IntSlider): @@ -204,14 +219,14 @@ def _update_ipywidgets(self, ev): self._block_ipywidget_call = False def _ipywidget_callback(self, change): - # update the LinearSelector if the ipywidget value changes + # update the LinearSelector when the ipywidget value changes if self._block_ipywidget_call or self._moving: return self.selection = change["new"] - def _add_plot_area_hook(self, plot_area): - super()._add_plot_area_hook(plot_area=plot_area) + def _fpl_add_plot_area_hook(self, plot_area): + super()._fpl_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") @@ -240,7 +255,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): """ - if not HAS_IPYWIDGETS: + if not IS_JUPYTER: raise ImportError( "Must installed `ipywidgets` to use `make_ipywidget_slider()`" ) @@ -253,9 +268,9 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = int(self.selection()) + value = int(self.selection) slider = cls( min=self.limits[0], @@ -267,11 +282,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): return slider - def add_ipywidget_handler( - self, - widget, - step: Union[int, float] = None - ): + def add_ipywidget_handler(self, widget, step: Union[int, float] = None): """ Bidirectionally connect events with a ipywidget slider @@ -285,7 +296,10 @@ def add_ipywidget_handler( """ - if not isinstance(widget, (ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider)): + if not isinstance( + widget, + (ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider), + ): raise TypeError( f"`widget` must be one of: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider\n" f"You have passed a: <{type(widget)}" @@ -332,34 +346,32 @@ def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: def _get_selected_index(self, graphic): # the array to search for the closest value along that axis if self.axis == "x": - geo_positions = graphic.data()[:, 0] - offset = getattr(graphic, f"position_{self.axis}") - else: - geo_positions = graphic.data()[:, 1] - offset = getattr(graphic, f"position_{self.axis}") + data = graphic.data[:, 0] + elif self.axis == "y": + data = graphic.data[:, 1] - if "Line" in graphic.__class__.__name__: - # we want to find the index of the geometry position that is closest to the slider's geometry position - find_value = self.selection() - offset + if ( + "Line" in graphic.__class__.__name__ + or "Scatter" in graphic.__class__.__name__ + ): + # we want to find the index of the data closest to the slider position + find_value = self.selection # get closest data index to the world space position of the slider - idx = np.searchsorted(geo_positions, find_value, side="left") + idx = np.searchsorted(data, find_value, side="left") if idx > 0 and ( - idx == len(geo_positions) - or math.fabs(find_value - geo_positions[idx - 1]) - < math.fabs(find_value - geo_positions[idx]) + idx == len(data) + or math.fabs(find_value - data[idx - 1]) + < math.fabs(find_value - data[idx]) ): return round(idx - 1) else: return round(idx) - if ( - "Heatmap" in graphic.__class__.__name__ - or "Image" in graphic.__class__.__name__ - ): + if "Image" in graphic.__class__.__name__: # indices map directly to grid geometry for image data buffer - index = self.selection() - offset + index = self.selection return round(index) def _move_graphic(self, delta: np.ndarray): @@ -374,14 +386,12 @@ def _move_graphic(self, delta: np.ndarray): """ if self.axis == "x": - self.selection = self.selection() + delta[0] + self.selection = self.selection + delta[0] else: - self.selection = self.selection() + delta[1] - - def _cleanup(self): - super()._cleanup() + self.selection = self.selection + delta[1] + def _fpl_cleanup(self): for widget in self._handled_widgets: widget.unobserve(self._ipywidget_callback, "value") - self._plot_area.renderer.remove_event_handler(self._set_slider_layout, "resize") + super()._fpl_cleanup() diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 2a7547d5b..ecc67b885 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -1,23 +1,51 @@ from typing import * from numbers import Real -try: - import ipywidgets - - HAS_IPYWIDGETS = True -except (ImportError, ModuleNotFoundError): - HAS_IPYWIDGETS = False - import numpy as np - import pygfx -from .._base import Graphic, GraphicCollection -from ._base_selector import BaseSelector +from ...utils.gui import IS_JUPYTER +from .._base import Graphic +from .._collection_base import GraphicCollection from .._features._selection_features import LinearRegionSelectionFeature +from ._base_selector import BaseSelector + + +if IS_JUPYTER: + # If using the jupyter backend, user has jupyter_rfb, and thus also ipywidgets + import ipywidgets class LinearRegionSelector(BaseSelector): + @property + def parent(self) -> Graphic | None: + """graphic that the selector is associated with""" + return self._parent + + @property + def selection(self) -> Sequence[float] | List[Sequence[float]]: + """ + (min, max) of data value along selector's axis + """ + # TODO: This probably does not account for rotation since world.position + # does not account for rotation, we can do this later + + return self._selection.value.copy() + + # TODO: if no parent graphic is set, this just returns world positions + # but should we change it? + # return self._selection.value + + @selection.setter + def selection(self, selection: Sequence[float]): + # set (xmin, xmax), or (ymin, ymax) of the selector in data space + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, selection) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -27,58 +55,54 @@ def limits(self, values: Tuple[float, float]): # check that `values` is an iterable of two real numbers # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): - raise TypeError( - "limits must be an iterable of two numeric values" - ) - self._limits = tuple(map(round, values)) # if values are close to zero things get weird so round them - self.selection._limits = self._limits + raise TypeError("limits must be an iterable of two numeric values") + self._limits = tuple( + map(round, values) + ) # if values are close to zero things get weird so round them + self._selection._limits = self._limits def __init__( self, - bounds: Tuple[int, int], - limits: Tuple[int, int], - size: int, - origin: Tuple[int, int], + selection: Sequence[float], + limits: Sequence[float], + size: float, + center: float, axis: str = "x", parent: Graphic = None, resizable: bool = True, fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - edge_thickness: int = 3, + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. - Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. - - bounds[0], limits[0], and position[0] must be identical. + Allows sub-selecting data from a parent ``Graphic`` or from multiple Graphics. - 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. + Assumes that the data under the selector is a function of the axis on which the selector moves + along. Example: if the selector is along the x-axis, then there must be only one y-value for each + x-value, otherwise functions such as ``get_selected_data()`` do not make sense. Parameters ---------- - bounds: (int, int) - the initial bounds of the linear selector + selection: (float, float) + initial (min, max) x or y values - limits: (int, int) - (min limit, max limit) for the selector + limits: (float, float) + (min limit, max limit) within which the selector can move size: int height or width of the selector - origin: (int, int) - initial position of the selector + center: float + center offset of the selector on the orthogonal axis, by default the data mean axis: str, default "x" - "x" | "y", axis for the selector + "x" | "y", axis the selected can move on parent: Graphic, default ``None`` - associate this selector with a parent Graphic + associate this selector with a parent Graphic from which to fetch data or indices resizable: bool if ``True``, the edges can be dragged to resize the width of the linear selection @@ -89,6 +113,9 @@ def __init__( edge_color: str, array, or tuple edge color for the selector, passed to pygfx.Color + edge_thickness: float, default 8 + edge thickness + arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None`` @@ -96,140 +123,103 @@ def __init__( name: str name for this selector graphic - Features - -------- - - selection: :class:`.LinearRegionSelectionFeature` - ``selection()`` returns the current selector bounds in world coordinates. - Use ``get_selected_indices()`` to return the selected indices in data - space, and ``get_selected_data()`` to return the selected data. - Use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for - event pick_info table. - """ # lots of very close to zero values etc. so round them, otherwise things get weird - bounds = tuple(map(round, bounds)) - self._limits = tuple(map(round, limits)) - origin = tuple(map(round, origin)) + if not len(selection) == 2: + raise ValueError + + selection = np.asarray(selection) + + if not len(limits) == 2: + raise ValueError + + self._limits = np.asarray(limits) # TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods # TODO: so we can worry about the sanity checks later - # if axis == "x": - # if limits[0] != origin[0] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[0] != bounds[0]\n" - # f"{limits[0]} != {origin[0]} != {bounds[0]}" - # ) - # - # elif axis == "y": - # # initial y-position is position[1] - # if limits[0] != origin[1] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[1] != bounds[0]\n" - # f"{limits[0]} != {origin[1]} != {bounds[0]}" - # ) - - self.parent = parent - - # world object for this will be a group - # basic mesh for the fill area of the selector - # line for each edge of the selector + group = pygfx.Group() - self._set_world_object(group) if axis == "x": mesh = pygfx.Mesh( pygfx.box_geometry(1, size, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) elif axis == "y": mesh = pygfx.Mesh( pygfx.box_geometry(size, 1, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) else: raise ValueError("`axis` must be one of 'x' or 'y'") # the fill of the selection self.fill = mesh - self.fill.world.position = (*origin, -2) + # no x, y offsets for linear region selector + # everything is done by setting the mesh data + # and line positions + self.fill.world.position = (0, 0, -2) - self.world_object.add(self.fill) + group.add(self.fill) self._resizable = resizable if axis == "x": - # position data for the left edge line - left_line_data = np.array( - [ - [origin[0], (-size / 2) + origin[1], 0.5], - [origin[0], (size / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), + # just some data to initialize the edge lines + init_line_data = np.array([[0, -size / 2, 0], [0, size / 2, 0]]).astype( + np.float32 ) - # position data for the right edge line - right_line_data = np.array( - [ - [bounds[1], (-size / 2) + origin[1], 0.5], - [bounds[1], (size / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), - ) - - self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) - elif axis == "y": - # position data for the left edge line - bottom_line_data = np.array( - [ - [(-size / 2) + origin[0], origin[1], 0.5], - [(size / 2) + origin[0], origin[1], 0.5], - ] - ).astype(np.float32) - - bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), - ) - - # position data for the right edge line - top_line_data = np.array( + # just some line data to initialize y axis edge lines + init_line_data = np.array( [ - [(-size / 2) + origin[0], bounds[1], 0.5], - [(size / 2) + origin[0], bounds[1], 0.5], + [-size / 2, 0, 0], + [size / 2, 0, 0], ] ).astype(np.float32) - top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial(thickness=edge_thickness, color=edge_color), - ) - - self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) - else: raise ValueError("axis argument must be one of 'x' or 'y'") + line0 = pygfx.Line( + pygfx.Geometry( + positions=init_line_data.copy() + ), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + line1 = pygfx.Line( + pygfx.Geometry( + positions=init_line_data.copy() + ), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + + self.edges: Tuple[pygfx.Line, pygfx.Line] = (line0, line1) + # add the edge lines for edge in self.edges: - edge.world.z = -1 - self.world_object.add(edge) + edge.world.z = -0.5 + group.add(edge) + + # TODO: if parent offset changes, we should set the selector offset too + # TODO: add check if parent is `None`, will throw error otherwise + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) # set the initial bounds of the selector - self.selection = LinearRegionSelectionFeature( - self, bounds, axis=axis, limits=self._limits + # compensate for any offset from the parent graphic + # selection feature only works in world space, not data space + self._selection = LinearRegionSelectionFeature( + selection, axis=axis, limits=self._limits ) self._handled_widgets = list() @@ -243,15 +233,22 @@ def __init__( hover_responsive=self.edges, arrow_keys_modifier=arrow_keys_modifier, axis=axis, - name=name + parent=parent, + name=name, + offset=offset, ) + self._set_world_object(group) + + self.selection = selection + def get_selected_data( self, graphic: Graphic = None - ) -> Union[np.ndarray, List[np.ndarray], None]: + ) -> Union[np.ndarray, List[np.ndarray]]: """ Get the ``Graphic`` data bounded by the current selection. - Returns a view of the full data array. + Returns a view of the data array. + If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. @@ -262,15 +259,16 @@ def get_selected_data( Parameters ---------- - graphic: Graphic, optional + graphic: Graphic, optional, default ``None`` if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` Returns ------- - np.ndarray, List[np.ndarray], or None + np.ndarray or List[np.ndarray] view or list of views of the full array, returns ``None`` if selection is empty """ + source = self._get_source(graphic) ixs = self.get_selected_indices(source) @@ -283,40 +281,47 @@ def get_selected_data( for i, g in enumerate(source.graphics): if ixs[i].size == 0: - data_selections.append(None) + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) else: - s = slice(ixs[i][0], ixs[i][-1]) - data_selections.append(g.data.buffer.data[s]) + s = slice( + ixs[i][0], ixs[i][-1] + 1 + ) # add 1 because these are direct indices + # slices n_datapoints dim + data_selections.append(g.data[s]) - return source[:].data[s] - # just for one Line graphic + return source.data[s] else: if ixs.size == 0: - return None + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) - s = slice(ixs[0], ixs[-1]) - return source.data.buffer.data[s] + s = slice( + ixs[0], ixs[-1] + 1 + ) # add 1 to end because these are direct indices + # slices n_datapoints dim + # slice with min, max is faster than using all the indices + return source.data[s] + + if "Image" in source.__class__.__name__: + s = slice(ixs[0], ixs[-1] + 1) - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): - s = slice(ixs[0], ixs[-1]) if self.axis == "x": - return source.data()[:, s] + # slice columns + return source.data[:, s] + elif self.axis == "y": - return source.data()[s] + # slice rows + return source.data[s] def get_selected_indices( self, graphic: Graphic = None ) -> Union[np.ndarray, List[np.ndarray]]: """ Returns the indices of the ``Graphic`` data bounded by the current selection. - This is useful because the ``bounds`` min and max are not necessarily the same - as the Line Geometry positions x-vals or y-vals. For example, if if you used a - np.linspace(0, 100, 1000) for xvals in your line, then you will have 1,000 - x-positions. If the selection ``bounds`` are set to ``(0, 10)``, the returned - indices would be ``(0, 100)``. + + These are the data indices along the selector's "axis" which correspond to the data under the selector. Parameters ---------- @@ -326,51 +331,45 @@ def get_selected_indices( Returns ------- Union[np.ndarray, List[np.ndarray]] - data indices of the selection, list of np.ndarray if graphic is LineCollection + data indices of the selection, list of np.ndarray if graphic is a collection """ + # we get the indices from the source graphic source = self._get_source(graphic) - # if the graphic position is not at (0, 0) then the bounds must be offset - offset = getattr(source, f"position_{self.selection.axis}") - offset_bounds = tuple(v - offset for v in self.selection()) - - # need them to be int to use as indices - offset_bounds = tuple(map(int, offset_bounds)) - - if self.selection.axis == "x": + # get the offset of the source graphic + if self.axis == "x": dim = 0 - else: + elif self.axis == "y": dim = 1 - if "Line" in source.__class__.__name__: - # now we need to map from graphic space to data space - # we can have more than 1 datapoint between two integer locations in the world space + # selector (min, max) data values along axis + bounds = self.selection + + if ( + "Line" in source.__class__.__name__ + or "Scatter" in source.__class__.__name__ + ): + # gets indices corresponding to n_datapoints dim + # data is [n_datapoints, xyz], so we return + # indices that can be used to slice `n_datapoints` if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: - # map for each graphic in the collection - g_ixs = np.where( - (g.data()[:, dim] >= offset_bounds[0]) - & (g.data()[:, dim] <= offset_bounds[1]) - )[0] + # indices for each graphic in the collection + data = g.data[:, dim] + g_ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] ixs.append(g_ixs) else: # map this only this graphic - ixs = np.where( - (source.data()[:, dim] >= offset_bounds[0]) - & (source.data()[:, dim] <= offset_bounds[1]) - )[0] + data = source.data[:, dim] + ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] return ixs - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): + if "Image" in source.__class__.__name__: # indices map directly to grid geometry for image data buffer - ixs = np.arange(*self.selection(), dtype=int) - return ixs + return np.arange(*bounds, dtype=int) def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): """ @@ -390,7 +389,7 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): """ - if not HAS_IPYWIDGETS: + if not IS_JUPYTER: raise ImportError( "Must installed `ipywidgets` to use `make_ipywidget_slider()`" ) @@ -403,9 +402,9 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = tuple(map(int, self.selection())) + value = tuple(map(int, self.selection)) slider = cls( min=self.limits[0], @@ -417,11 +416,7 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): return slider - def add_ipywidget_handler( - self, - widget, - step: Union[int, float] = None - ): + def add_ipywidget_handler(self, widget, step: Union[int, float] = None): """ Bidirectionally connect events with a ipywidget slider @@ -434,7 +429,9 @@ def add_ipywidget_handler( step size, if ``None`` 100 steps are created """ - if not isinstance(widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider)): + if not isinstance( + widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider) + ): raise TypeError( f"`widget` must be one of: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider\n" f"You have passed a: <{type(widget)}" @@ -452,7 +449,7 @@ def add_ipywidget_handler( def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = tuple(map(int, value)) @@ -463,7 +460,7 @@ def _setup_ipywidget_slider(self, widget): widget.observe(self._ipywidget_callback, "value") # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) + self.add_event_handler(self._update_ipywidgets, "selection") self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") @@ -497,43 +494,39 @@ def _set_slider_layout(self, *args): widget.layout = ipywidgets.Layout(width=f"{w}px") def _move_graphic(self, delta: np.ndarray): - # add delta to current bounds to get new positions - if self.selection.axis == "x": - # min and max of current bounds, i.e. the edges - xmin, xmax = self.selection() + # add delta to current min, max to get new positions + if self.axis == "x": + # add x value + new_min, new_max = self.selection + delta[0] - # new left bound position - bound0_new = xmin + delta[0] + elif self.axis == "y": + # add y value + new_min, new_max = self.selection + delta[1] - # new right bound position - bound1_new = xmax + delta[0] - else: - # min and max of current bounds, i.e. the edges - ymin, ymax = self.selection() - - # new bottom bound position - bound0_new = ymin + delta[1] - - # new top bound position - bound1_new = ymax + delta[1] - - # move entire selector if source was fill + # move entire selector if event source was fill if self._move_info.source == self.fill: - # set the new bounds - self.selection = (bound0_new, bound1_new) + # prevent weird shrinkage of selector if one edge is already at the limit + if self.selection[0] == self.limits[0] and new_min < self.limits[0]: + # self._move_end(None) # TODO: cancel further movement to prevent weird asynchronization with pointer + return + if self.selection[1] == self.limits[1] and new_max > self.limits[1]: + # self._move_end(None) + return + + # move entire selector + self._selection.set_value(self, (new_min, new_max)) return - # if selector is not resizable do nothing + # if selector is not resizable return if not self._resizable: return - # if resizable, move edges + # if event source was an edge and selector is resizable, + # move the edge that caused the event if self._move_info.source == self.edges[0]: # change only left or bottom bound - self.selection = (bound0_new, self.selection()[1]) + self._selection.set_value(self, (new_min, self._selection.value[1])) elif self._move_info.source == self.edges[1]: # change only right or top bound - self.selection = (self.selection()[0], bound1_new) - else: - return + self._selection.set_value(self, (self.selection[0], new_max)) diff --git a/fastplotlib/graphics/selectors/_mesh_positions.py b/fastplotlib/graphics/selectors/_mesh_positions.py deleted file mode 100644 index 07ff60498..000000000 --- a/fastplotlib/graphics/selectors/_mesh_positions.py +++ /dev/null @@ -1,2 +0,0 @@ -import numpy as np - diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index b347da0f4..a4ecd440c 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -16,7 +16,6 @@ def __init__( parent: Graphic = None, name: str = None, ): - self.parent = parent group = pygfx.Group() @@ -40,14 +39,16 @@ def get_vertices(self) -> np.ndarray: return np.vstack(vertices) - def _add_plot_area_hook(self, plot_area): + def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area # click to add new segment self._plot_area.renderer.add_event_handler(self._add_segment, "click") # pointer move to change endpoint of segment - self._plot_area.renderer.add_event_handler(self._move_segment_endpoint, "pointer_move") + self._plot_area.renderer.add_event_handler( + self._move_segment_endpoint, "pointer_move" + ) # click to finish existing segment self._plot_area.renderer.add_event_handler(self._finish_segment, "click") @@ -69,7 +70,11 @@ def _add_segment(self, ev): new_line = pygfx.Line( geometry=pygfx.Geometry(positions=data.astype(np.float32)), - material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) + material=pygfx.LineMaterial( + thickness=self.edge_width, + color=pygfx.Color(self.edge_color), + pick_write=True, + ), ) self.world_object.add(new_line) @@ -86,7 +91,9 @@ def _move_segment_endpoint(self, ev): return # change endpoint - self.world_object.children[-1].geometry.positions.data[1] = np.array([world_pos]).astype(np.float32) + self.world_object.children[-1].geometry.positions.data[1] = np.array( + [world_pos] + ).astype(np.float32) self.world_object.children[-1].geometry.positions.update_range() def _finish_segment(self, ev): @@ -114,14 +121,17 @@ def _finish_polygon(self, ev): return # make new line to connect first and last vertices - data = np.vstack([ - world_pos, - self.world_object.children[0].geometry.positions.data[0] - ]) + data = np.vstack( + [world_pos, self.world_object.children[0].geometry.positions.data[0]] + ) new_line = pygfx.Line( geometry=pygfx.Geometry(positions=data.astype(np.float32)), - material=pygfx.LineMaterial(thickness=self.edge_width, color=pygfx.Color(self.edge_color)) + material=pygfx.LineMaterial( + thickness=self.edge_width, + color=pygfx.Color(self.edge_color), + pick_write=True, + ), ) self.world_object.add(new_line) @@ -130,7 +140,7 @@ def _finish_polygon(self, ev): self._add_segment: "click", self._move_segment_endpoint: "pointer_move", self._finish_segment: "click", - self._finish_polygon: "double_click" + self._finish_polygon: "double_click", } for handler, event in handlers.items(): diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py index a5a9a31cb..bc2cad5b1 100644 --- a/fastplotlib/graphics/selectors/_rectangle_region.py +++ b/fastplotlib/graphics/selectors/_rectangle_region.py @@ -3,10 +3,10 @@ import pygfx +from ...utils import mesh_masks from .._base import Graphic from .._features import GraphicFeature from ._base_selector import BaseSelector -from ._mesh_positions import x_right, x_left, y_top, y_bottom class RectangleBoundsFeature(GraphicFeature): @@ -28,7 +28,7 @@ class RectangleBoundsFeature(GraphicFeature): def __init__( self, parent, bounds: Tuple[int, int], axis: str, limits: Tuple[int, int] ): - super(RectangleBoundsFeature, self).__init__(parent, data=bounds) + super().__init__(parent, data=bounds) self._axis = axis self.limits = limits @@ -58,16 +58,16 @@ def _set(self, value: Tuple[float, float, float, float]): # change fill mesh # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[x_left, 0] = xmin + self._parent.fill.geometry.positions.data[mesh_masks.x_left] = xmin # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[x_right, 0] = xmax + self._parent.fill.geometry.positions.data[mesh_masks.x_right] = xmax # change bottom y position of the fill mesh - self._parent.fill.geometry.positions.data[y_bottom, 1] = ymin + self._parent.fill.geometry.positions.data[mesh_masks.y_bottom] = ymin # change top position of the fill mesh - self._parent.fill.geometry.positions.data[y_top, 1] = ymax + self._parent.fill.geometry.positions.data[mesh_masks.y_top] = ymax # change the edge lines @@ -214,7 +214,7 @@ def __init__( self.fill = pygfx.Mesh( pygfx.box_geometry(width, height, 1), - pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color), pick_write=True), ) self.fill.position.set(*origin, -2) diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py deleted file mode 100644 index 9414a2e20..000000000 --- a/fastplotlib/graphics/selectors/_sync.py +++ /dev/null @@ -1,88 +0,0 @@ -from . import LinearSelector -from typing import * - - -class Synchronizer: - def __init__(self, *selectors: LinearSelector, key_bind: Union[str, None] = "Shift"): - """ - Synchronize the movement of `Selectors`. Selectors will move in sync only when the selected `"key_bind"` is - used during the mouse movement event. Valid key binds are: ``"Control"``, ``"Shift"`` and ``"Alt"``. - If ``key_bind`` is ``None`` then the selectors will always be synchronized. - - Parameters - ---------- - selectors - selectors to synchronize - - key_bind: str, default ``"Shift"`` - one of ``"Control"``, ``"Shift"`` and ``"Alt"`` or ``None`` - """ - self._selectors = list() - self.key_bind = key_bind - - for s in selectors: - self.add(s) - - self.block_event = False - - self.enabled: bool = True - - @property - def selectors(self): - """Selectors managed by the Synchronizer""" - return self._selectors - - def add(self, selector): - """add a selector""" - selector.selection.add_event_handler(self._handle_event) - self._selectors.append(selector) - - def remove(self, selector): - """remove a 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: - # because infinite recursion - return - - if not self.enabled: - return - - self.block_event = True - - source = ev.pick_info["graphic"] - delta = ev.pick_info["delta"] - pygfx_ev = ev.pick_info["pygfx_event"] - - # only moves when modifier is used - if pygfx_ev is None: - self.block_event = False - return - - if self.key_bind is not None: - if self.key_bind not in pygfx_ev.modifiers: - self.block_event = False - return - - if delta is not None: - self._move_selectors(source, delta) - - self.block_event = False - - def _move_selectors(self, source, delta): - for s in self.selectors: - # must use == and not is to compare Graphics because they are weakref proxies! - if s == source: - # if it's the source, since it has already moved - continue - - s._move_graphic(delta) - - def __del__(self): - self.clear() diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index a159d9560..fcee6129b 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -1,23 +1,36 @@ -from typing import * import pygfx import numpy as np from ._base import Graphic +from ._features import ( + TextData, + FontSize, + TextFaceColor, + TextOutlineColor, + TextOutlineThickness, +) class TextGraphic(Graphic): + _features = { + "text", + "font_size", + "face_color", + "outline_color", + "outline_thickness", + } + def __init__( self, text: str, - position: Tuple[int] = (0, 0, 0), - size: int = 14, - face_color: Union[str, np.ndarray] = "w", - outline_color: Union[str, np.ndarray] = "w", - outline_thickness=0, + font_size: float | int = 14, + face_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_thickness: float = 0.0, screen_space: bool = True, + offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - *args, - **kwargs + **kwargs, ): """ Create a text Graphic @@ -25,13 +38,10 @@ def __init__( Parameters ---------- text: str - display text + text to display - position: int tuple, default (0, 0, 0) - int tuple indicating location of text in scene - - size: int, default 10 - text size + font_size: float | int, default 10 + font size face_color: str or array, default "w" str or RGBA array to set the color of the text @@ -39,14 +49,14 @@ def __init__( outline_color: str or array, default "w" str or RGBA array to set the outline color of the text - outline_thickness: int, default 0 - text outline thickness + outline_thickness: float, default 0 + relative outline thickness, value between 0.0 - 0.5 screen_space: bool = True - whether the text is rendered in screen space, in contrast to world space + if True, text size is in screen space, if False the text size is in data space - name: str, optional - name of graphic, passed to Graphic + offset: (float, float, float), default (0, 0, 0) + places the text at this location anchor: str, default "middle-center" position of the origin of the text @@ -54,94 +64,80 @@ def __init__( * Vertical values: "top", "middle", "baseline", "bottom" * Horizontal values: "left", "center", "right" + + **kwargs + passed to Graphic + """ - super(TextGraphic, self).__init__(*args, **kwargs) - self._text = text + super().__init__(**kwargs) + + self._text = TextData(text) + self._font_size = FontSize(font_size) + self._face_color = TextFaceColor(face_color) + self._outline_color = TextOutlineColor(outline_color) + self._outline_thickness = TextOutlineThickness(outline_thickness) world_object = pygfx.Text( pygfx.TextGeometry( - text=str(text), - font_size=size, + text=self.text, + font_size=self.font_size, screen_space=screen_space, anchor=anchor, ), pygfx.TextMaterial( - color=face_color, - outline_color=outline_color, - outline_thickness=outline_thickness, + color=self.face_color, + outline_color=self.outline_color, + outline_thickness=self.outline_thickness, + pick_write=True, ), ) self._set_world_object(world_object) - self.world_object.position = position + self.offset = offset @property - def text(self): - """Returns the text of this graphic.""" - return self._text + def text(self) -> str: + """the text displayed""" + return self._text.value @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.") - - self._text = text - self.world_object.geometry.set_text(self._text) + self._text.set_value(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 font_size(self) -> float | int: + """ "text font size""" + return self._font_size.value - self.world_object.geometry.font_size = size + @font_size.setter + def font_size(self, size: float | int): + self._font_size.set_value(self, size) @property - def face_color(self): - """Returns the face color of this graphic.""" - return self.world_object.material.color + def face_color(self) -> pygfx.Color: + """text face color""" + return self._face_color.value @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 face_color(self, color: str | np.ndarray | list[float] | tuple[float]): + self._face_color.set_value(self, color) @property - def outline_size(self): - """Returns the outline size of this graphic.""" - return self.world_object.material.outline_thickness + def outline_thickness(self) -> float: + """text outline thickness""" + return self._outline_thickness.value - @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 + @outline_thickness.setter + def outline_thickness(self, thickness: float): + self._outline_thickness.set_value(self, thickness) @property - def outline_color(self): - """Returns the outline color of this graphic.""" - return self.world_object.material.outline_color + def outline_color(self) -> pygfx.Color: + """text outline color""" + return self._outline_color.value @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 outline_color(self, color: str | np.ndarray | list[float] | tuple[float]): + self._outline_color.set_value(self, color) diff --git a/fastplotlib/layouts/__init__.py b/fastplotlib/layouts/__init__.py index aaed4c5a4..60111cabc 100644 --- a/fastplotlib/layouts/__init__.py +++ b/fastplotlib/layouts/__init__.py @@ -1,4 +1,3 @@ -from ._gridplot import GridPlot -from ._plot import Plot +from ._figure import Figure -__all__ = ["Plot", "GridPlot"] +__all__ = ["Figure"] diff --git a/fastplotlib/layouts/_defaults.py b/fastplotlib/layouts/_defaults.py deleted file mode 100644 index b28b04f64..000000000 --- a/fastplotlib/layouts/_defaults.py +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py new file mode 100644 index 000000000..d330c6928 --- /dev/null +++ b/fastplotlib/layouts/_figure.py @@ -0,0 +1,809 @@ +import os +from itertools import product, chain +from multiprocessing import Queue +from pathlib import Path +from time import time + +import numpy as np +from typing import Literal, Iterable +from inspect import getfullargspec +from warnings import warn + +import pygfx + +from wgpu.gui import WgpuCanvasBase + +from ._video_writer import VideoWriterAV +from ._utils import make_canvas_and_renderer, create_controller, create_camera +from ._utils import controller_types as valid_controller_types +from ._subplot import Subplot +from .. import ImageGraphic + + +class Figure: + def __init__( + self, + shape: tuple[int, int] = (1, 1), + cameras: ( + Literal["2d", "3d"] + | Iterable[Iterable[Literal["2d", "3d"]]] + | pygfx.PerspectiveCamera + | Iterable[Iterable[pygfx.PerspectiveCamera]] + ) = "2d", + controller_types: ( + Iterable[Iterable[Literal["panzoom", "fly", "trackball", "orbit"]]] + | Iterable[Literal["panzoom", "fly", "trackball", "orbit"]] + ) = None, + controller_ids: ( + Literal["sync"] + | Iterable[int] + | Iterable[Iterable[int]] + | Iterable[Iterable[str]] + ) = None, + controllers: pygfx.Controller | Iterable[Iterable[pygfx.Controller]] = None, + canvas: str | WgpuCanvasBase | pygfx.Texture = None, + renderer: pygfx.WgpuRenderer = None, + size: tuple[int, int] = (500, 300), + names: list | np.ndarray = None, + ): + """ + A grid of subplots. + + Parameters + ---------- + shape: (int, int), default (1, 1) + (n_rows, n_cols) + + cameras: "2d", "3", list of "2d" | "3d", Iterable of camera instances, or Iterable of "2d" | "3d", optional + | if str, one of ``"2d"`` or ``"3d"`` indicating 2D or 3D cameras for all subplots + | Iterable/list/array of ``2d`` and/or ``3d`` that specifies the camera type for each subplot + | Iterable/list/array of pygfx.PerspectiveCamera instances + + controller_types: str, Iterable, optional + list/array that specifies the controller type for each subplot. + Valid controller types: "panzoom", "fly", "trackball", "orbit". + If not specified a default controller is chosen based on the camera type. + Orthographic projections, i.e. "2d" cameras, use a "panzoom" controller by default. + Perspective projections with a FOV > 0, i.e. "3d" cameras, use a "fly" controller by default. + + controller_ids: str, list of int, np.ndarray of int, or list with sublists of subplot str names, optional + | If `None` a unique controller is created for each subplot + | If "sync" all the subplots use the same controller + | If array/list it must be reshapeable to ``grid_shape``. + + This allows custom assignment of controllers + + | Example with integers: + | sync first 2 plots, and sync last 2 plots: [[0, 0, 1], [2, 3, 3]] + | Example with str subplot names: + | list of lists of subplot names, each sublist is synced: [[subplot_a, subplot_b, subplot_e], [subplot_c, subplot_d]] + | this syncs subplot_a, subplot_b and subplot_e together; syncs subplot_c and subplot_d together + + controllers: pygfx.Controller | list[pygfx.Controller] | np.ndarray[pygfx.Controller], optional + directly provide pygfx.Controller instances(s). Useful if you want to use a controller from an existing + plot/subplot. Other controller kwargs, i.e. ``controller_types`` and ``controller_ids`` are ignored if + ``controllers`` are provided. + + canvas: WgpuCanvas, optional + Canvas for drawing + + renderer: pygfx.Renderer, optional + pygfx renderer instance + + size: (int, int), optional + starting size of canvas, default (500, 300) + + names: list or array of str, optional + subplot names + """ + + self._shape = shape + + if names is not None: + if len(list(chain(*names))) != len(self): + raise ValueError( + "must provide same number of subplot `names` as specified by Figure `shape`" + ) + + subplot_names = np.asarray(names).reshape(self.shape) + else: + subplot_names = None + + canvas, renderer = make_canvas_and_renderer(canvas, renderer) + + if isinstance(cameras, str): + # create the array representing the views for each subplot in the grid + cameras = np.array([cameras] * len(self)).reshape(self.shape) + + # list -> array if necessary + cameras = np.asarray(cameras).reshape(self.shape) + + if cameras.shape != self.shape: + raise ValueError("Number of cameras does not match the number of subplots") + + # create the cameras + subplot_cameras = np.empty(self.shape, dtype=object) + for i, j in product(range(self.shape[0]), range(self.shape[1])): + subplot_cameras[i, j] = create_camera(camera_type=cameras[i, j]) + + # if controller instances have been specified for each subplot + if controllers is not None: + + # one controller for all subplots + if isinstance(controllers, pygfx.Controller): + controllers = [controllers] * len(self) + # subplot_controllers[:] = controllers + # # subplot_controllers = np.asarray([controllers] * len(self), dtype=object) + + # individual controller instance specified for each subplot + else: + # I found that this is better than list(*chain()) because chain doesn't give the right + # result we want for arrays + for item in controllers: + if isinstance(item, pygfx.Controller): + pass + elif all(isinstance(c, pygfx.Controller) for c in item): + pass + else: + raise TypeError( + "controllers argument must be a single pygfx.Controller instance, or a Iterable of " + "pygfx.Controller instances" + ) + + try: + controllers = np.asarray(controllers).reshape(shape) + except ValueError: + raise ValueError( + f"number of controllers passed must be the same as the number of subplots specified " + f"by shape: {self.shape}. You have passed: <{controllers.size}> controllers" + ) from None + + subplot_controllers: np.ndarray[pygfx.Controller] = np.empty( + self.shape, dtype=object + ) + + for i, j in product(range(self.shape[0]), range(self.shape[1])): + subplot_controllers[i, j] = controllers[i, j] + subplot_controllers[i, j].add_camera(subplot_cameras[i, j]) + + # parse controller_ids and controller_types to make desired controller for each supblot + else: + if controller_ids is None: + # individual controller for each subplot + controller_ids = np.arange(len(self)).reshape(self.shape) + + elif isinstance(controller_ids, str): + if controller_ids == "sync": + # this will eventually make one controller for all subplots + controller_ids = np.zeros(self.shape, dtype=int) + else: + raise ValueError( + f"`controller_ids` must be one of 'sync', an array/list of subplot names, or an array/list of " + f"integer ids. See the docstring for more details." + ) + + # list controller_ids + elif isinstance(controller_ids, (list, np.ndarray)): + ids_flat = list(chain(*controller_ids)) + + # list of str of subplot names, convert this to integer ids + if all([isinstance(item, str) for item in ids_flat]): + if subplot_names is None: + raise ValueError( + "must specify subplot `names` to use list of str for `controller_ids`" + ) + + # make sure each controller_id str is a subplot name + if not all([n in subplot_names for n in ids_flat]): + raise KeyError( + f"all `controller_ids` strings must be one of the subplot names" + ) + + if len(ids_flat) > len(set(ids_flat)): + raise ValueError( + "id strings must not appear twice in `controller_ids`" + ) + + # initialize controller_ids array + ids_init = np.arange(len(self)).reshape(self.shape) + + # set id based on subplot position for each synced sublist + for i, sublist in enumerate(controller_ids): + for name in sublist: + ids_init[subplot_names == name] = -( + i + 1 + ) # use negative numbers because why not + + controller_ids = ids_init + + # integer ids + elif all([isinstance(item, (int, np.integer)) for item in ids_flat]): + controller_ids = np.asarray(controller_ids).reshape(self.shape) + + else: + raise TypeError( + f"list argument to `controller_ids` must be a list of `str` or `int`, " + f"you have passed: {controller_ids}" + ) + + if controller_ids.shape != self.shape: + raise ValueError( + "Number of controller_ids does not match the number of subplots" + ) + + if controller_types is None: + # `create_controller()` will auto-determine controller for each subplot based on defaults + controller_types = np.array(["default"] * len(self)).reshape(self.shape) + + # valid controller types + if isinstance(controller_types, str): + controller_types = [[controller_types]] + + types_flat = list(chain(*controller_types)) + # str controller_type or pygfx instances + valid_str = list(valid_controller_types.keys()) + ["default"] + + # make sure each controller type is valid + for controller_type in types_flat: + if controller_type is None: + continue + + if controller_type not in valid_str: + raise ValueError( + f"You have passed the invalid `controller_type`: {controller_type}. " + f"Valid `controller_types` arguments are:\n {valid_str}" + ) + + controller_types: np.ndarray[pygfx.Controller] = np.asarray( + controller_types + ).reshape(self.shape) + + # make the real controllers for each subplot + subplot_controllers = np.empty(shape=self.shape, dtype=object) + for cid in np.unique(controller_ids): + cont_type = controller_types[controller_ids == cid] + if np.unique(cont_type).size > 1: + raise ValueError( + "Multiple controller types have been assigned to the same controller id. " + "All controllers with the same id must use the same type of controller." + ) + + cont_type = cont_type[0] + + # get all the cameras that use this controller + cams = subplot_cameras[controller_ids == cid].ravel() + + if cont_type == "default": + # hacky fix for now because of how `create_controller()` works + cont_type = None + _controller = create_controller( + controller_type=cont_type, camera=cams[0] + ) + + subplot_controllers[controller_ids == cid] = _controller + + # add the other cameras that go with this controller + if cams.size > 1: + for cam in cams[1:]: + _controller.add_camera(cam) + + self._canvas = canvas + self._renderer = renderer + + nrows, ncols = self.shape + + self._subplots: np.ndarray[Subplot] = np.ndarray( + shape=(nrows, ncols), dtype=object + ) + + for i, j in self._get_iterator(): + position = (i, j) + camera = subplot_cameras[i, j] + controller = subplot_controllers[i, j] + + if subplot_names is not None: + name = subplot_names[i, j] + else: + name = None + + self._subplots[i, j] = Subplot( + parent=self, + position=position, + parent_dims=(nrows, ncols), + camera=camera, + controller=controller, + canvas=canvas, + renderer=renderer, + name=name, + ) + + self._animate_funcs_pre: list[callable] = list() + self._animate_funcs_post: list[callable] = list() + + self._current_iter = None + + self._starting_size = size + + self._output = None + + if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": + self.recorder = FigureRecorder(self) + else: + self.recorder = None + + @property + def toolbar(self): + """ipywidget or QToolbar instance""" + return self._output.toolbar + + @property + def output(self): + """ipywidget or QWidget that contains this plot""" + return self._output + + @property + def shape(self) -> tuple[int, int]: + """[n_rows, n_cols]""" + return self._shape + + @property + def canvas(self) -> WgpuCanvasBase: + """The canvas associated to this Figure""" + return self._canvas + + @property + def renderer(self) -> pygfx.WgpuRenderer: + """The renderer associated to this Figure""" + return self._renderer + + @property + def controllers(self) -> np.ndarray[pygfx.Controller]: + """controllers, read-only array, access individual subplots to change a controller""" + controllers = np.asarray( + [subplot.controller for subplot in self], dtype=object + ).reshape(self.shape) + controllers.flags.writeable = False + return controllers + + @property + def cameras(self) -> np.ndarray[pygfx.Camera]: + """cameras, read-only array, access individual subplots to change a camera""" + cameras = np.asarray( + [subplot.camera for subplot in self], dtype=object + ).reshape(self.shape) + cameras.flags.writeable = False + return cameras + + @property + def names(self) -> np.ndarray[str]: + """subplot names, read-only array, access individual subplots to change a name""" + names = np.asarray([subplot.name for subplot in self]).reshape(self.shape) + names.flags.writeable = False + return names + + def __getitem__(self, index: tuple[int, int] | str) -> Subplot: + if isinstance(index, str): + for subplot in self._subplots.ravel(): + if subplot.name == index: + return subplot + raise IndexError(f"no subplot with given name: {index}") + else: + return self._subplots[index[0], index[1]] + + def render(self): + # call the animation functions before render + self._call_animate_functions(self._animate_funcs_pre) + + for subplot in self: + subplot.render() + + self.renderer.flush() + self.canvas.request_draw() + + # call post-render animate functions + self._call_animate_functions(self._animate_funcs_post) + + 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 + for subplot in self: + for g in subplot.graphics: + if isinstance(g, ImageGraphic): + subplot.camera.local.scale_y *= -1 + break + + 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) + + # return the appropriate OutputContext based on the current canvas + if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": + from .output.jupyter_output import ( + JupyterOutputContext, + ) # noqa - inline import + + 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": + from .output.qt_output import QOutputContext # noqa - inline import + + 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): + self.output.close() + + def _call_animate_functions(self, funcs: list[callable]): + for fn in funcs: + try: + if len(getfullargspec(fn).args) > 0: + 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: callable, + pre_render: bool = True, + post_render: bool = False, + ): + """ + Add function(s) that are called on every render cycle. + These are called at the Figure level. + + Parameters + ---------- + *funcs: callable(s) + 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 clear(self): + """Clear all Subplots""" + for subplot in self: + subplot.clear() + + def export(self, uri: str | Path | bytes, **kwargs): + """ + Use ``imageio`` for writing the current Figure to a file, or return a byte string. + Must have ``imageio`` installed. + + Parameters + ---------- + uri: str | Path | bytes + + kwargs: passed to imageio.v3.imwrite, see: https://imageio.readthedocs.io/en/stable/_autosummary/imageio.v3.imwrite.html + + Returns + ------- + None | bytes + see https://imageio.readthedocs.io/en/stable/_autosummary/imageio.v3.imwrite.html + """ + try: + import imageio.v3 as iio + except ModuleNotFoundError: + raise ImportError( + "imageio is required to use Figure.export(). Install it using pip or conda:\n" + "pip install imageio\n" + "conda install -c conda-forge imageio\n" + ) + else: + snapshot = self.renderer.snapshot() + remove_alpha = True + + # image formats that support alpha channel: + # https://en.wikipedia.org/wiki/Alpha_compositing#Image_formats_supporting_alpha_channels + alpha_support = [".png", ".exr", ".tiff", ".tif", ".gif", ".jxl", ".svg"] + + if isinstance(uri, str): + if any([uri.endswith(ext) for ext in alpha_support]): + remove_alpha = False + + elif isinstance(uri, Path): + if uri.suffix in alpha_support: + remove_alpha = False + + if remove_alpha: + # remove alpha channel if it's not supported + snapshot = snapshot[..., :-1].shape + + return iio.imwrite(uri, snapshot, **kwargs) + + def _get_iterator(self): + return product(range(self.shape[0]), range(self.shape[1])) + + def __iter__(self): + self._current_iter = self._get_iterator() + return self + + def __next__(self) -> Subplot: + pos = self._current_iter.__next__() + return self._subplots[pos] + + def __len__(self): + """number of subplots""" + return self.shape[0] * self.shape[1] + + def __str__(self): + return f"{self.__class__.__name__} @ {hex(id(self))}" + + def __repr__(self): + newline = "\n\t" + + return ( + f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}\n" + f" Subplots:\n" + f"\t{newline.join(subplot.__str__() for subplot in self)}" + f"\n" + ) + + +class FigureRecorder: + def __init__(self, figure: Figure): + self._figure = figure + self._video_writer: VideoWriterAV = None + self._video_writer_queue = Queue() + self._record_fps = 25 + self._record_timer = 0 + self._record_start_time = 0 + + def _record(self): + """ + Sends frame to VideoWriter through video writer queue + """ + # current time + t = time() + + # put frame in queue only if enough time as passed according to the desired framerate + # otherwise it tries to record EVERY frame on every rendering cycle, which just blocks the rendering + if t - self._record_timer < (1 / self._record_fps): + return + + # reset timer + self._record_timer = t + + if self._video_writer is not None: + ss = self._figure.canvas.snapshot() + # exclude alpha channel + self._video_writer_queue.put(ss.data[..., :-1]) + + def start( + self, + path: str | Path, + fps: int = 25, + codec: str = "mpeg4", + pixel_format: str = "yuv420p", + options: dict = None, + ): + """ + Start a recording, experimental. Call ``record_end()`` to end a recording. + Note: playback duration does not exactly match recording duration. + + Requires PyAV: https://github.com/PyAV-Org/PyAV + + **Do not resize canvas during a recording, the width and height must remain constant!** + + Parameters + ---------- + path: str or Path + path to save the recording + + fps: int, default ``25`` + framerate, do not use > 25 within jupyter + + codec: str, default "mpeg4" + codec to use, see ``ffmpeg`` list: https://www.ffmpeg.org/ffmpeg-codecs.html . + In general, ``"mpeg4"`` should work on most systems. ``"libx264"`` is a + better option if you have it installed. + + pixel_format: str, default "yuv420p" + pixel format + + options: dict, optional + Codec options. For example, if using ``"mpeg4"`` you can use ``{"q:v": "20"}`` to set the quality between + 1-31, where "1" is highest and "31" is lowest. If using ``"libx264"``` you can use ``{"crf": "30"}`` where + the "crf" value is between "0" (highest quality) and "50" (lowest quality). See ``ffmpeg`` docs for more + info on codec options + + Examples + -------- + + With ``"mpeg4"`` + + .. code-block:: python + + # start recording video + figure.recorder.start("./video.mp4", options={"q:v": "20"} + + # do stuff like interacting with the plot, change things, etc. + + # end recording + figure.recorder.stop() + + With ``"libx264"`` + + .. code-block:: python + + # start recording video + figure.recorder.start("./vid_x264.mp4", codec="libx264", options={"crf": "25"}) + + # do stuff like interacting with the plot, change things, etc. + + # end recording + figure.recorder.stop() + + """ + + if Path(path).exists(): + raise FileExistsError(f"File already exists at given path: {path}") + + # queue for sending frames to VideoWriterAV process + self._video_writer_queue = Queue() + + # snapshot to get canvas width height + ss = self._figure.canvas.snapshot() + + # writer process + self._video_writer = VideoWriterAV( + path=str(path), + queue=self._video_writer_queue, + fps=int(fps), + width=ss.width, + height=ss.height, + codec=codec, + pixel_format=pixel_format, + options=options, + ) + + # start writer process + self._video_writer.start() + + # 1.3 seems to work well to reduce that difference between playback time and recording time + # will properly investigate later + self._record_fps = fps * 1.3 + self._record_start_time = time() + + # record timer used to maintain desired framerate + self._record_timer = time() + + self._figure.add_animations(self._record) + + def stop(self) -> float: + """ + End a current recording. Returns the real duration of the recording + + Returns + ------- + float + recording duration + """ + + # tell video writer that recording has finished + self._video_writer_queue.put(None) + + # wait for writer to finish + self._video_writer.join(timeout=5) + + self._video_writer = None + + # so self._record() is no longer called on every render cycle + self._figure.remove_animation(self._record) + + return time() - self._record_start_time diff --git a/fastplotlib/layouts/_frame/__init__.py b/fastplotlib/layouts/_frame/__init__.py deleted file mode 100644 index c34884022..000000000 --- a/fastplotlib/layouts/_frame/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ._frame import Frame diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py deleted file mode 100644 index abd79759e..000000000 --- a/fastplotlib/layouts/_frame/_frame.py +++ /dev/null @@ -1,184 +0,0 @@ -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/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py deleted file mode 100644 index 9d4e0b48f..000000000 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ /dev/null @@ -1,235 +0,0 @@ -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/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py new file mode 100644 index 000000000..387549ade --- /dev/null +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -0,0 +1,472 @@ +# This is an auto-generated file and should not be modified directly + +from typing import * + +import numpy +import weakref + +from ..graphics import * +from ..graphics._base import Graphic + + +class GraphicMethodsMixin: + def __init__(self): + pass + + def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: + if "center" in kwargs.keys(): + center = kwargs.pop("center") + else: + center = False + + if "name" in kwargs.keys(): + self._check_graphic_name_exists(kwargs["name"]) + + graphic = graphic_class(*args, **kwargs) + self.add_graphic(graphic, center=center) + + # only return a proxy to the real graphic + return weakref.proxy(graphic) + + def add_image( + self, + data: Any, + vmin: int = None, + vmax: int = None, + cmap: str = "plasma", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + isolated_buffer: bool = True, + **kwargs + ) -> ImageGraphic: + """ + + Create an Image Graphic + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()`` + | shape must be ``[x_dim, y_dim]`` + + vmin: int, optional + minimum value for color scaling, calculated from data if not provided + + vmax: int, optional + maximum value for color scaling, calculated from data if not provided + + cmap: str, optional, default "plasma" + colormap to use to display the data + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer. + + kwargs: + additional keyword arguments passed to Graphic + + + """ + return self._create_graphic( + ImageGraphic, + data, + vmin, + vmax, + cmap, + interpolation, + cmap_interpolation, + isolated_buffer, + **kwargs + ) + + def add_line_collection( + self, + data: Union[numpy.ndarray, List[numpy.ndarray]], + thickness: Union[float, Sequence[float]] = 2.0, + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + uniform_colors: bool = False, + alpha: float = 1.0, + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, + **kwargs + ) -> LineCollection: + """ + + Create a collection of :class:`.LineGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + alpha: float, optional + alpha value for colors, if colors is a ``str`` + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + LineCollection, + data, + thickness, + colors, + uniform_colors, + alpha, + cmap, + cmap_transform, + name, + names, + metadata, + metadatas, + isolated_buffer, + kwargs_lines, + **kwargs + ) + + def add_line( + self, + data: Any, + thickness: float = 2.0, + colors: Union[str, numpy.ndarray, Iterable] = "w", + uniform_color: bool = False, + alpha: float = 1.0, + cmap: str = None, + cmap_transform: Union[numpy.ndarray, Iterable] = None, + isolated_buffer: bool = True, + **kwargs + ) -> LineGraphic: + """ + + Create a line Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Line data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] + + thickness: float, optional, default 2.0 + thickness of the line + + colors: str, array, or iterable, default "w" + specify colors as a single human-readable string, a single RGBA array, + or an iterable of strings or RGBA arrays + + uniform_color: bool, default ``False`` + if True, uses a uniform buffer for the line color, + basically saves GPU VRAM when the entire line has a single color + + alpha: float, optional, default 1.0 + alpha value for the colors + + cmap: str, optional + apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors" + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + **kwargs + passed to Graphic + + + """ + return self._create_graphic( + LineGraphic, + data, + thickness, + colors, + uniform_color, + alpha, + cmap, + cmap_transform, + isolated_buffer, + **kwargs + ) + + def add_line_stack( + self, + data: List[numpy.ndarray], + thickness: Union[float, Iterable[float]] = 2.0, + colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", + alpha: float = 1.0, + cmap: Union[Iterable[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, + separation: float = 10.0, + separation_axis: str = "y", + kwargs_lines: list[dict] = None, + **kwargs + ) -> LineStack: + """ + + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + alpha: float, optional + alpha value for colors, if colors is a ``str`` + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + separation: float, default 10 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + LineStack, + data, + thickness, + colors, + alpha, + cmap, + cmap_transform, + name, + names, + metadata, + metadatas, + isolated_buffer, + separation, + separation_axis, + kwargs_lines, + **kwargs + ) + + def add_scatter( + self, + data: Any, + colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_color: bool = False, + alpha: float = 1.0, + cmap: str = None, + cmap_transform: numpy.ndarray = None, + isolated_buffer: bool = True, + sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, + uniform_size: bool = False, + **kwargs + ) -> ScatterGraphic: + """ + + Create a Scatter Graphic, 2d or 3d + + Parameters + ---------- + data: array-like + Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] + + colors: str, array, or iterable, default "w" + specify colors as a single human readable string, a single RGBA array, + or an iterable of strings or RGBA arrays + + uniform_color: bool, default False + if True, uses a uniform buffer for the scatter point colors, + basically saves GPU VRAM when the entire line has a single color + + alpha: float, optional, default 1.0 + alpha value for the colors + + cmap: str, optional + apply a colormap to the scatter instead of assigning colors manually, this + overrides any argument passed to "colors" + + cmap_transform: 1D array-like or list of numerical values, optional + if provided, these values are used to map the colors from the cmap + + isolated_buffer: bool, default True + whether the buffers should be isolated from the user input array. + Generally always ``True``, ``False`` is for rare advanced use. + + sizes: float or iterable of float, optional, default 1.0 + size of the scatter points + + uniform_size: bool, default False + if True, uses a uniform buffer for the scatter point sizes, + basically saves GPU VRAM when all scatter points are the same size + + kwargs + passed to Graphic + + + """ + return self._create_graphic( + ScatterGraphic, + data, + colors, + uniform_color, + alpha, + cmap, + cmap_transform, + isolated_buffer, + sizes, + uniform_size, + **kwargs + ) + + def add_text( + self, + text: str, + font_size: float | int = 14, + face_color: str | numpy.ndarray | list[float] | tuple[float] = "w", + outline_color: str | numpy.ndarray | list[float] | tuple[float] = "w", + outline_thickness: float = 0.0, + screen_space: bool = True, + offset: tuple[float] = (0, 0, 0), + anchor: str = "middle-center", + **kwargs + ) -> TextGraphic: + """ + + Create a text Graphic + + Parameters + ---------- + text: str + text to display + + font_size: float | int, default 10 + font size + + face_color: str or array, default "w" + str or RGBA array to set the color of the text + + outline_color: str or array, default "w" + str or RGBA array to set the outline color of the text + + outline_thickness: float, default 0 + relative outline thickness, value between 0.0 - 0.5 + + screen_space: bool = True + if True, text size is in screen space, if False the text size is in data space + + offset: (float, float, float), default (0, 0, 0) + places the text at this location + + 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" + + **kwargs + passed to Graphic + + + """ + return self._create_graphic( + TextGraphic, + text, + font_size, + face_color, + outline_color, + outline_thickness, + screen_space, + offset, + anchor, + **kwargs + ) diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py deleted file mode 100644 index 459aca5fd..000000000 --- a/fastplotlib/layouts/_gridplot.py +++ /dev/null @@ -1,392 +0,0 @@ -from itertools import product, chain -import numpy as np -from typing import * -from inspect import getfullargspec -from warnings import warn - -import pygfx - -from wgpu.gui.auto import WgpuCanvas - -from ._frame import Frame -from ._utils import make_canvas_and_renderer, create_controller, create_camera -from ._utils import controller_types as valid_controller_types -from ._subplot import Subplot -from ._record_mixin import RecordMixin - - -def to_array(a) -> np.ndarray: - if isinstance(a, np.ndarray): - return a - - if not isinstance(a, list): - raise TypeError("must pass list or numpy array") - - return np.array(a) - - -class GridPlot(Frame, RecordMixin): - def __init__( - self, - shape: Tuple[int, int], - cameras: Union[str, list, np.ndarray] = "2d", - controller_types: Union[str, list, np.ndarray] = None, - controller_ids: Union[str, list, np.ndarray] = None, - canvas: Union[str, WgpuCanvas, pygfx.Texture] = None, - renderer: pygfx.WgpuRenderer = None, - size: Tuple[int, int] = (500, 300), - names: Union[list, np.ndarray] = None, - ): - """ - A grid of subplots. - - Parameters - ---------- - shape: (int, int) - (n_rows, n_cols) - - cameras: str, list, or np.ndarray, optional - | One of ``"2d"`` or ``"3d"`` indicating 2D or 3D cameras for all subplots - | list/array of ``2d`` and/or ``3d`` that specifies the camera type for each subplot - | list/array of pygfx.PerspectiveCamera instances - - controller_types: str, list or np.ndarray, optional - list or array that specifies the controller type for each subplot, or list/array of - pygfx.Controller instances - - controller_ids: str, list or np.ndarray of int or str ids, optional - | If `None` a unique controller is created for each subplot - | If "sync" all the subplots use the same controller - | If ``numpy.array``, its shape must be the same as ``grid_shape``. - - This allows custom assignment of controllers - - | Example with integers: - | sync first 2 plots, and sync last 2 plots: [[0, 0, 1], [2, 3, 3]] - | Example with str subplot names: - | list of lists of subplot names, each sublist is synced: [[subplot_a, subplot_b], [subplot_f, subplot_c]] - | this syncs subplot_a and subplot_b together; syncs subplot_f and subplot_c together - - canvas: WgpuCanvas, optional - Canvas for drawing - - renderer: pygfx.Renderer, optional - pygfx renderer instance - - size: (int, int), optional - starting size of canvas, default (500, 300) - - names: list or array of str, optional - subplot names - """ - - self.shape = shape - - if names is not None: - if len(list(chain(*names))) != self.shape[0] * self.shape[1]: - raise ValueError("must provide same number of subplot `names` as specified by gridplot shape") - - self.names = to_array(names).reshape(self.shape) - else: - self.names = None - - canvas, renderer = make_canvas_and_renderer(canvas, renderer) - - if isinstance(cameras, str): - # create the array representing the views for each subplot in the grid - cameras = np.array([cameras] * self.shape[0] * self.shape[1]).reshape( - self.shape - ) - - # list -> array if necessary - cameras = to_array(cameras).reshape(self.shape) - - if cameras.shape != self.shape: - raise ValueError("Number of cameras does not match the number of subplots") - - # create the cameras - self._cameras = np.empty(self.shape, dtype=object) - for i, j in product(range(self.shape[0]), range(self.shape[1])): - self._cameras[i, j] = create_camera(camera_type=cameras[i, j]) - - if controller_ids is None: - # individual controller for each subplot - controller_ids = np.arange(self.shape[0] * self.shape[1]).reshape(self.shape) - - elif isinstance(controller_ids, str): - if controller_ids == "sync": - controller_ids = np.zeros(self.shape, dtype=int) - else: - raise ValueError( - f"`controller_ids` must be one of 'sync', an array/list of subplot names, or an array/list of " - f"integer ids. See the docstring for more details." - ) - - # list controller_ids - elif isinstance(controller_ids, (list, np.ndarray)): - ids_flat = list(chain(*controller_ids)) - - # list of str of subplot names, convert this to integer ids - if all([isinstance(item, str) for item in ids_flat]): - if self.names is None: - raise ValueError("must specify subplot `names` to use list of str for `controller_ids`") - - # make sure each controller_id str is a subplot name - if not all([n in self.names for n in ids_flat]): - raise KeyError( - f"all `controller_ids` strings must be one of the subplot names" - ) - - if len(ids_flat) > len(set(ids_flat)): - raise ValueError( - "id strings must not appear twice in `controller_ids`" - ) - - # initialize controller_ids array - ids_init = np.arange(self.shape[0] * self.shape[1]).reshape(self.shape) - - # set id based on subplot position for each synced sublist - for i, sublist in enumerate(controller_ids): - for name in sublist: - ids_init[self.names == name] = -(i + 1) # use negative numbers because why not - - controller_ids = ids_init - - # integer ids - elif all([isinstance(item, (int, np.integer)) for item in ids_flat]): - controller_ids = to_array(controller_ids).reshape(self.shape) - - else: - raise TypeError( - f"list argument to `controller_ids` must be a list of `str` or `int`, " - f"you have passed: {controller_ids}" - ) - - if controller_ids.shape != self.shape: - raise ValueError("Number of controller_ids does not match the number of subplots") - - if controller_types is None: - # `create_controller()` will auto-determine controller for each subplot based on defaults - controller_types = np.array(["default"] * self.shape[0] * self.shape[1]).reshape(self.shape) - - # validate controller types - types_flat = list(chain(*controller_types)) - # str controller_type or pygfx instances - valid_str = list(valid_controller_types.keys()) + ["default"] - valid_instances = tuple(valid_controller_types.values()) - - # make sure each controller type is valid - for controller_type in types_flat: - if controller_type is None: - continue - - if (controller_type not in valid_str) and (not isinstance(controller_type, valid_instances)): - raise ValueError( - f"You have passed an invalid controller type, valid controller_types arguments are:\n" - f"{valid_str} or instances of {[c.__name__ for c in valid_instances]}" - ) - - controller_types = to_array(controller_types).reshape(self.shape) - - # make the real controllers for each subplot - self._controllers = np.empty(shape=self.shape, dtype=object) - for cid in np.unique(controller_ids): - cont_type = controller_types[controller_ids == cid] - if np.unique(cont_type).size > 1: - raise ValueError( - "Multiple controller types have been assigned to the same controller id. " - "All controllers with the same id must use the same type of controller." - ) - - cont_type = cont_type[0] - - # get all the cameras that use this controller - cams = self._cameras[controller_ids == cid].ravel() - - if cont_type == "default": - # hacky fix for now because of how `create_controller()` works - cont_type = None - _controller = create_controller(controller_type=cont_type, camera=cams[0]) - - self._controllers[controller_ids == cid] = _controller - - # add the other cameras that go with this controller - if cams.size > 1: - for cam in cams[1:]: - _controller.add_camera(cam) - - if canvas is None: - canvas = WgpuCanvas() - - if renderer is None: - renderer = pygfx.renderers.WgpuRenderer(canvas) - - self._canvas = canvas - self._renderer = renderer - - nrows, ncols = self.shape - - self._subplots: np.ndarray[Subplot] = np.ndarray( - shape=(nrows, ncols), dtype=object - ) - - for i, j in self._get_iterator(): - position = (i, j) - camera = self._cameras[i, j] - controller = self._controllers[i, j] - - if self.names is not None: - name = self.names[i, j] - else: - name = None - - self._subplots[i, j] = Subplot( - parent=self, - position=position, - parent_dims=(nrows, ncols), - camera=camera, - controller=controller, - canvas=canvas, - renderer=renderer, - name=name, - ) - - self._animate_funcs_pre: List[callable] = list() - self._animate_funcs_post: List[callable] = list() - - self._current_iter = None - - self._starting_size = size - - RecordMixin.__init__(self) - Frame.__init__(self) - - @property - def canvas(self) -> WgpuCanvas: - """The canvas associated to this GridPlot""" - return self._canvas - - @property - def renderer(self) -> pygfx.WgpuRenderer: - """The renderer associated to this GridPlot""" - return self._renderer - - def __getitem__(self, index: Union[Tuple[int, int], str]) -> Subplot: - if isinstance(index, str): - for subplot in self._subplots.ravel(): - if subplot.name == index: - return subplot - raise IndexError("no subplot with given name") - else: - return self._subplots[index[0], index[1]] - - def render(self): - # call the animation functions before render - self._call_animate_functions(self._animate_funcs_pre) - - for subplot in self: - subplot.render() - - self.renderer.flush() - self.canvas.request_draw() - - # call post-render animate functions - self._call_animate_functions(self._animate_funcs_post) - - def _call_animate_functions(self, funcs: Iterable[callable]): - for fn in funcs: - try: - if len(getfullargspec(fn).args) > 0: - 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 GridPlot 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 clear(self): - """Clear all Subplots""" - for subplot in self: - subplot.clear() - - def _get_iterator(self): - return product(range(self.shape[0]), range(self.shape[1])) - - def __iter__(self): - self._current_iter = self._get_iterator() - return self - - def __next__(self) -> Subplot: - pos = self._current_iter.__next__() - return self._subplots[pos] - - def __str__(self): - return f"{self.__class__.__name__} @ {hex(id(self))}" - - def __repr__(self): - newline = "\n\t" - - return ( - f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}\n" - f" Subplots:\n" - f"\t{newline.join(subplot.__str__() for subplot in self)}" - f"\n" - ) diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py deleted file mode 100644 index 34027a276..000000000 --- a/fastplotlib/layouts/_plot.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import * - -import pygfx -from wgpu.gui.auto import WgpuCanvas - -from ._subplot import Subplot -from ._frame import Frame -from ._record_mixin import RecordMixin - - -class Plot(Subplot, Frame, RecordMixin): - def __init__( - self, - canvas: Union[str, WgpuCanvas] = None, - renderer: pygfx.WgpuRenderer = None, - camera: Union[str, pygfx.PerspectiveCamera] = "2d", - controller: Union[str, pygfx.Controller] = None, - size: Tuple[int, int] = (500, 300), - **kwargs, - ): - """ - Simple Plot object. - - Parameters - ---------- - canvas: WgpuCanvas, optional - Canvas for drawing - - renderer: pygfx.Renderer, optional - pygfx renderer instance - - camera: str or pygfx.PerspectiveCamera, optional - | One of ``"2d"`` or ``"3d"`` indicating 2D or 3D camera - - controller: str or pygfx.Controller, optional - Usually ``None``, you can pass an existing controller from another - ``Plot`` or ``Subplot`` to synchronize them. - - You can also pass str arguments of valid controller names, see Subplot docstring for valid names - - size: (int, int) - starting size of canvas, default (500, 300) - - kwargs - passed to Subplot, for example ``name`` - - """ - super(Plot, self).__init__( - parent=None, - position=(0, 0), - parent_dims=(1, 1), - canvas=canvas, - renderer=renderer, - camera=camera, - controller=controller, - **kwargs, - ) - RecordMixin.__init__(self) - Frame.__init__(self) - - self._starting_size = size - - 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() diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 9522832d3..d8e0adebc 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -1,5 +1,6 @@ from inspect import getfullargspec -from typing import * +from sys import getrefcount +from typing import TypeAlias, Literal, Union import weakref from warnings import warn @@ -7,49 +8,118 @@ import pygfx from pylinalg import vec_transform, vec_unproject -from wgpu.gui.auto import WgpuCanvas +from wgpu.gui import WgpuCanvasBase -from ._utils import create_camera, create_controller +from ._utils import create_controller from ..graphics._base import Graphic +from ..graphics._collection_base import GraphicCollection from ..graphics.selectors._base_selector import BaseSelector +from ..legends import Legend -# dict to store Graphic instances -# this is the only place where the real references to Graphics are stored in a Python session -# {hex id str: Graphic} -GRAPHICS: Dict[str, Graphic] = dict() -SELECTORS: Dict[str, BaseSelector] = dict() + +HexStr: TypeAlias = str + + +class References: + """ + This is the only place where the real graphic objects are stored. Everywhere else gets a proxy. + """ + + _graphics: dict[HexStr, Graphic] = dict() + _selectors: dict[HexStr, BaseSelector] = dict() + _legends: dict[HexStr, Legend] = dict() + + def add(self, graphic: Graphic | BaseSelector | Legend): + """Adds the real graphic to the dict""" + addr = graphic._fpl_address + + if isinstance(graphic, BaseSelector): + self._selectors[addr] = graphic + + elif isinstance(graphic, Legend): + self._legends[addr] = graphic + + elif isinstance(graphic, Graphic): + self._graphics[addr] = graphic + + else: + raise TypeError("Can only add Graphic, Selector or Legend types") + + def remove(self, address): + if address in self._graphics.keys(): + del self._graphics[address] + elif address in self._selectors.keys(): + del self._selectors[address] + elif address in self._legends.keys(): + del self._legends[address] + else: + raise KeyError(f"graphic with address not found: {address}") + + def get_proxies(self, refs: list[HexStr]) -> tuple[weakref.proxy]: + proxies = list() + for key in refs: + if key in self._graphics.keys(): + proxies.append(weakref.proxy(self._graphics[key])) + + elif key in self._selectors.keys(): + proxies.append(weakref.proxy(self._selectors[key])) + + elif key in self._legends.keys(): + proxies.append(weakref.proxy(self._legends[key])) + + else: + raise KeyError(f"graphic object with address not found: {key}") + + return tuple(proxies) + + def get_refcounts(self) -> dict[HexStr:int]: + counts = dict() + + for item in (self._graphics, self._selectors, self._legends): + for k in item.keys(): + counts[(k, item[k].name, item[k].__class__.__name__)] = getrefcount( + item[k] + ) + + return counts + + +REFERENCES = References() class PlotArea: + def get_refcounts(self): + return REFERENCES.get_refcounts() + def __init__( self, - parent, - position: Any, - camera: Union[pygfx.PerspectiveCamera], - controller: Union[pygfx.Controller], + parent: Union["PlotArea", "Figure"], + position: tuple[int, int] | str, + camera: pygfx.PerspectiveCamera, + controller: pygfx.Controller, scene: pygfx.Scene, - canvas: WgpuCanvas, + canvas: WgpuCanvasBase, renderer: pygfx.WgpuRenderer, name: str = None, ): """ Base class for plot creation and management. ``PlotArea`` is not intended to be instantiated by users - but rather to provide functionality for ``subplot`` in ``gridplot`` and single ``plot``. + but rather to provide functionality for ``subplots`` in a user ``Figure`` Parameters ---------- - parent: PlotArea - parent class of subclasses will be a ``PlotArea`` instance + parent: PlotArea or Figure + parent object position: Any - typical use will be for ``subplots`` in a ``gridplot``, position would correspond to the ``[row, column]`` - location of the ``subplot`` in its ``gridplot`` + position of the plot area. In a ``subplot`` position would correspond to the ``[row, column]`` + index of the ``subplot``. In docks this would correspond to a str name, "top", "right", "bottom" or "left" camera: pygfx.PerspectiveCamera - Use perspective camera for both perspective and orthographic views. Set fov = 0 for orthographic mode. + Use perspective camera for both perspective and orthographic views. Set fov = 0 for orthographic projection controller: pygfx.Controller - One of the pygfx controllers, panzoom, fly, orbit, or trackball + One of the pygfx controllers: "panzoom", "fly", "trackball", "orbit" scene: pygfx.Scene represents the root of a scene graph, will be viewed by the given ``camera`` @@ -61,20 +131,17 @@ def __init__( renders the scene onto the canvas name: str, optional - name this ``subplot`` or ``plot`` + name this plot area """ - self._parent: PlotArea = parent + self._parent = parent self._position = position self._scene = scene self._canvas = canvas self._renderer = renderer - if parent is None: - self._viewport: pygfx.Viewport = pygfx.Viewport(renderer) - else: - self._viewport = pygfx.Viewport(parent.renderer) + self._viewport: pygfx.Viewport = pygfx.Viewport(renderer) self._camera = camera self._controller = controller @@ -84,18 +151,21 @@ def __init__( self.viewport, ) - self._animate_funcs_pre = list() - self._animate_funcs_post = list() + self._animate_funcs_pre: list[callable] = list() + self._animate_funcs_post: list[callable] = list() self.renderer.add_event_handler(self.set_viewport_rect, "resize") # list of hex id strings for all graphics managed by this PlotArea - # the real Graphic instances are stored in the ``GRAPHICS`` dict - self._graphics: List[str] = list() + # the real Graphic instances are managed by REFERENCES + self._graphics: list[HexStr] = list() # selectors are in their own list so they can be excluded from scene bbox calculations # managed similar to GRAPHICS for garbage collection etc. - self._selectors: List[str] = list() + self._selectors: list[HexStr] = list() + + # legends, managed just like other graphics as explained above + self._legends: list[HexStr] = list() self._name = name @@ -107,12 +177,12 @@ def __init__( # several read-only properties @property def parent(self): - """The parent PlotArea""" + """A parent if relevant""" return self._parent @property - def position(self) -> Union[Tuple[int, int], Any]: - """Position of this plot area within a larger layout (such as GridPlot) if relevant""" + def position(self) -> tuple[int, int] | str: + """Position of this plot area within a larger layout (such as a Figure) if relevant""" return self._position @property @@ -121,7 +191,7 @@ def scene(self) -> pygfx.Scene: return self._scene @property - def canvas(self) -> WgpuCanvas: + def canvas(self) -> WgpuCanvasBase: """Canvas associated to the plot area""" return self._canvas @@ -141,7 +211,7 @@ def camera(self) -> pygfx.PerspectiveCamera: return self._camera @camera.setter - def camera(self, new_camera: Union[str, pygfx.PerspectiveCamera]): + def camera(self, new_camera: str | pygfx.PerspectiveCamera): # user wants to set completely new camera, remove current camera from controller if isinstance(new_camera, pygfx.PerspectiveCamera): self.controller.remove_camera(self._camera) @@ -162,9 +232,13 @@ def camera(self, new_camera: Union[str, pygfx.PerspectiveCamera]): self._camera.fov = 50 else: - raise ValueError("camera must be one of '2d', '3d' or a pygfx.PerspectiveCamera instance") + raise ValueError( + "camera must be one of '2d', '3d' or a pygfx.PerspectiveCamera instance" + ) else: - raise ValueError("camera must be one of '2d', '3d' or a pygfx.PerspectiveCamera instance") + raise ValueError( + "camera must be one of '2d', '3d' or a pygfx.PerspectiveCamera instance" + ) # in the future we can think about how to allow changing the controller @property @@ -173,7 +247,7 @@ def controller(self) -> pygfx.Controller: return self._controller @controller.setter - def controller(self, new_controller: Union[str, pygfx.Controller]): + def controller(self, new_controller: str | pygfx.Controller): new_controller = create_controller(new_controller, self._camera) cameras_list = list() @@ -187,14 +261,12 @@ def controller(self, new_controller: Union[str, pygfx.Controller]): for camera in cameras_list: new_controller.add_camera(camera) - new_controller.register_events( - self.viewport - ) + new_controller.register_events(self.viewport) # TODO: monkeypatch until we figure out a better # pygfx plans on refactoring viewports anyways if self.parent is not None: - if self.parent.__class__.__name__ == "GridPlot": + if self.parent.__class__.__name__ == "Figure": for subplot in self.parent: if subplot.camera in cameras_list: new_controller.register_events(subplot.viewport) @@ -203,40 +275,54 @@ def controller(self, new_controller: Union[str, pygfx.Controller]): self._controller = new_controller @property - def graphics(self) -> Tuple[Graphic, ...]: + def graphics(self) -> tuple[Graphic, ...]: """Graphics in the plot area. Always returns a proxy to the Graphic instances.""" - proxies = list() - for loc in self._graphics: - p = weakref.proxy(GRAPHICS[loc]) - proxies.append(p) - - return tuple(proxies) + return REFERENCES.get_proxies(self._graphics) @property - def selectors(self) -> Tuple[BaseSelector, ...]: + def selectors(self) -> tuple[BaseSelector, ...]: """Selectors in the plot area. Always returns a proxy to the Graphic instances.""" - proxies = list() - for loc in self._selectors: - p = weakref.proxy(SELECTORS[loc]) - proxies.append(p) + return REFERENCES.get_proxies(self._selectors) - return tuple(proxies) + @property + def legends(self) -> tuple[Legend, ...]: + """Legends in the plot area.""" + return REFERENCES.get_proxies(self._legends) + + @property + def objects(self) -> tuple[Graphic | BaseSelector | Legend, ...]: + return *self.graphics, *self.selectors, *self.legends @property - def name(self) -> Any: + def name(self) -> str: """The name of this plot area""" return self._name @name.setter - def name(self, name: Any): + def name(self, name: str): + if name is None: + self._name = None + return + + if not isinstance(name, str): + raise TypeError("PlotArea `name` must be of type ") self._name = name - def get_rect(self) -> Tuple[float, float, float, float]: - """allows setting the region occupied by the viewport w.r.t. the parent""" + def get_rect(self) -> tuple[float, float, float, float]: + """ + Returns the viewport rect to define the rectangle + occupied by the viewport w.r.t. the Canvas. + + If this is a subplot within a Figure, it returns the rectangle + for only this subplot w.r.t. the parent canvas. + + Must return: [x_pos, y_pos, width_viewport, height_viewport] + + """ raise NotImplementedError("Must be implemented in subclass") def map_screen_to_world( - self, pos: Union[Tuple[float, float], pygfx.PointerEvent] + self, pos: tuple[float, float] | pygfx.PointerEvent ) -> np.ndarray: """ Map screen position to world position @@ -285,7 +371,7 @@ def render(self): self._call_animate_functions(self._animate_funcs_post) - def _call_animate_functions(self, funcs: Iterable[callable]): + def _call_animate_functions(self, funcs: list[callable]): for fn in funcs: try: args = getfullargspec(fn).args @@ -306,7 +392,7 @@ def _call_animate_functions(self, funcs: Iterable[callable]): def add_animations( self, - *funcs: Iterable[callable], + *funcs: callable, pre_render: bool = True, post_render: bool = False, ): @@ -316,7 +402,7 @@ def add_animations( Parameters ---------- - *funcs: callable or iterable of callable + *funcs: callable(s) function(s) that are called on each render cycle pre_render: bool, default ``True``, optional keyword-only argument @@ -373,16 +459,25 @@ def add_graphic(self, graphic: Graphic, center: bool = True): Center the camera on the newly added Graphic """ + + if graphic in self: + # graphic is already in this plot but was removed from the scene, add it back + self.scene.add(graphic.world_object) + return + self._add_or_insert_graphic(graphic=graphic, center=center, action="add") - graphic.position_z = len(self) + if self.camera.fov == 0: + # for orthographic positions stack objects along the z-axis + # for perspective projections we assume the user wants full 3D control + graphic.offset = (*graphic.offset[:-1], len(self)) def insert_graphic( self, graphic: Graphic, center: bool = True, index: int = 0, - z_position: int = None, + auto_offset: int = None, ): """ Insert graphic into scene at given position ``index`` in stored graphics. @@ -399,8 +494,8 @@ def insert_graphic( index: int, default 0 Index to insert graphic. - z_position: int, default None - z axis position to place Graphic. If ``None``, uses value of `index` argument + auto_offset: bool, default True + If True and using an orthographic projection, sets z-axis offset of graphic to `index` """ if index > len(self._graphics): @@ -414,16 +509,17 @@ def insert_graphic( graphic=graphic, center=center, action="insert", index=index ) - if z_position is None: - graphic.position_z = index - else: - graphic.position_z = z_position + if self.camera.fov == 0: + # for orthographic positions stack objects along the z-axis + # for perspective projections we assume the user wants full 3D control + if auto_offset: + graphic.offset = (*graphic.offset[:-1], index) def _add_or_insert_graphic( self, graphic: Graphic, center: bool = True, - action: str = Union["insert", "add"], + action: str = Literal["insert", "add"], index: int = 0, ): """Private method to handle inserting or adding a graphic to a PlotArea.""" @@ -435,28 +531,28 @@ def _add_or_insert_graphic( if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) + addr = graphic._fpl_address + if isinstance(graphic, BaseSelector): - # store in SELECTORS dict - loc = graphic.loc - SELECTORS[ - loc - ] = graphic # add hex id string for referencing this graphic instance - # don't manage garbage collection of LineSliders for now - if action == "insert": - self._selectors.insert(index, loc) - else: - self._selectors.append(loc) + addr_list = self._selectors + + elif isinstance(graphic, Legend): + addr_list = self._legends + + elif isinstance(graphic, Graphic): + addr_list = self._graphics + else: - # store in GRAPHICS dict - loc = graphic.loc - GRAPHICS[ - loc - ] = graphic # add hex id string for referencing this graphic instance - - if action == "insert": - self._graphics.insert(index, loc) - else: - self._graphics.append(loc) + raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") + + if action == "insert": + addr_list.insert(index, addr) + elif action == "add": + addr_list.append(addr) + else: + raise ValueError("valid actions are 'insert' | 'add'") + + REFERENCES.add(graphic) # now that it's in the dict, just use the weakref graphic = weakref.proxy(graphic) @@ -468,21 +564,13 @@ def _add_or_insert_graphic( 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) + graphic._fpl_add_plot_area_hook(self) def _check_graphic_name_exists(self, name): - graphic_names = list() - - for g in self.graphics: - graphic_names.append(g.name) - - for s in self.selectors: - graphic_names.append(s.name) - - if name in graphic_names: + if name in self: raise ValueError( - f"graphics must have unique names, current graphic names are:\n {graphic_names}" + f"Graphic with given name already exists in subplot or plot area. " + f"All graphics within a subplot or plot area must have a unique name." ) def center_graphic(self, graphic: Graphic, zoom: float = 1.35): @@ -528,10 +616,10 @@ def center_scene(self, *, zoom: float = 1.35): camera.zoom = zoom def auto_scale( - self, - *, # since this is often used as an event handler, don't want to coerce maintain_aspect = True - maintain_aspect: Union[None, bool] = None, - zoom: float = 0.8 + self, + *, # since this is often used as an event handler, don't want to coerce maintain_aspect = True + maintain_aspect: None | bool = None, + zoom: float = 0.8, ): """ Auto-scale the camera w.r.t to the scene @@ -610,81 +698,51 @@ def delete_graphic(self, graphic: Graphic): """ # TODO: proper gc of selectors, RAM is freed for regular graphics but not selectors # TODO: references to selectors must be lingering somewhere - # get location - loc = graphic.loc - - # check which dict it's in - if loc in self._graphics: - glist = self._graphics - kind = "graphic" - elif loc in self._selectors: - kind = "selector" - glist = self._selectors - else: - raise KeyError( - f"Graphic with following address not found in plot area: {loc}" - ) + # TODO: update March 2024, I think selectors are gc properly, should check + # get memory address + address = graphic._fpl_address + + if graphic not in self: + raise KeyError(f"Graphic not found in plot area: {graphic}") + + # check which type it is + for l in [self._graphics, self._selectors, self._legends]: + if address in l: + l.remove(address) + break # remove from scene if necessary if graphic.world_object in self.scene.children: self.scene.remove(graphic.world_object) - # remove from list of addresses - glist.remove(loc) - # cleanup - graphic._cleanup() + graphic._fpl_cleanup() - if kind == "graphic": - del GRAPHICS[loc] - elif kind == "selector": - del SELECTORS[loc] + REFERENCES.remove(address) def clear(self): """ Clear the Plot or Subplot. Also performs garbage collection, i.e. runs ``delete_graphic`` on all graphics. """ - - for g in self.graphics: + for g in self.objects: self.delete_graphic(g) - for s in self.selectors: - self.delete_graphic(s) - def __getitem__(self, name: str): - for graphic in self.graphics: + for graphic in self.objects: if graphic.name == name: return graphic - for selector in self.selectors: - if selector.name == name: - return selector - - graphic_names = list() - for g in self.graphics: - graphic_names.append(g.name) - - selector_names = list() - for s in self.selectors: - selector_names.append(s.name) - - raise IndexError( - f"No graphic or selector of given name.\n" - f"The current graphics are:\n {graphic_names}\n" - f"The current selectors are:\n {selector_names}" - ) - - def __contains__(self, item: Union[str, Graphic]): - to_check = [*self.graphics, *self.selectors] + raise IndexError(f"No graphic or selector of given name in plot area.\n") + def __contains__(self, item: str | Graphic): if isinstance(item, Graphic): - if item in to_check: + if item in self.objects: return True else: return False elif isinstance(item, str): - for graphic in to_check: + for graphic in self.objects: # only check named graphics if graphic.name is None: continue @@ -695,7 +753,7 @@ def __contains__(self, item: Union[str, Graphic]): 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/_record_mixin.py b/fastplotlib/layouts/_record_mixin.py deleted file mode 100644 index e3a491915..000000000 --- a/fastplotlib/layouts/_record_mixin.py +++ /dev/null @@ -1,241 +0,0 @@ -from typing import * -from pathlib import Path -from multiprocessing import Queue, Process -from time import time - -try: - import av -except ImportError: - HAS_AV = False -else: - HAS_AV = True - - -class VideoWriterAV(Process): - """Video writer, uses PyAV in an external process to write frames to disk""" - - def __init__( - self, - path: Union[Path, str], - queue: Queue, - fps: int, - width: int, - height: int, - codec: str, - pixel_format: str, - options: dict = None, - ): - super().__init__() - self.queue = queue - - self.container = av.open(path, mode="w") - - self.stream = self.container.add_stream(codec, rate=fps, options=options) - - # in case libx264, trim last rows and/or column - # because libx264 doesn't like non-even number width or height - if width % 2 != 0: - width -= 1 - if height % 2 != 0: - height -= 1 - - self.stream.width = width - self.stream.height = height - - self.stream.pix_fmt = pixel_format - - def run(self): - while True: - if self.queue.empty(): # no frame to write - continue - - frame = self.queue.get() - - # recording has ended - if frame is None: - self.container.close() - break - - frame = av.VideoFrame.from_ndarray( - frame[ - : self.stream.height, : self.stream.width - ], # trim if necessary because of x264 - format="rgb24", - ) - - for packet in self.stream.encode(frame): - self.container.mux(packet) - - # I don't exactly know what this does, copied from pyav example - for packet in self.stream.encode(): - self.container.mux(packet) - - # close file - self.container.close() - - # close process, release resources - self.close() - - -# adds recording functionality to GridPlot and Plot -class RecordMixin: - def __init__(self): - self._video_writer: VideoWriterAV = None - self._video_writer_queue = Queue() - self._record_fps = 25 - self._record_timer = 0 - self._record_start_time = 0 - - def _record(self): - """ - Sends frame to VideoWriter through video writer queue - """ - # current time - t = time() - - # put frame in queue only if enough time as passed according to the desired framerate - # otherwise it tries to record EVERY frame on every rendering cycle, which just blocks the rendering - if t - self._record_timer < (1 / self._record_fps): - return - - # reset timer - self._record_timer = t - - if self._video_writer is not None: - ss = self.canvas.snapshot() - # exclude alpha channel - self._video_writer_queue.put(ss.data[..., :-1]) - - def record_start( - self, - path: Union[str, Path], - fps: int = 25, - codec: str = "mpeg4", - pixel_format: str = "yuv420p", - options: dict = None, - ): - """ - Start a recording, experimental. Call ``record_end()`` to end a recording. - Note: playback duration does not exactly match recording duration. - - Requires PyAV: https://github.com/PyAV-Org/PyAV - - **Do not resize canvas during a recording, the width and height must remain constant!** - - Parameters - ---------- - path: str or Path - path to save the recording - - fps: int, default ``25`` - framerate, do not use > 25 within jupyter - - codec: str, default "mpeg4" - codec to use, see ``ffmpeg`` list: https://www.ffmpeg.org/ffmpeg-codecs.html . - In general, ``"mpeg4"`` should work on most systems. ``"libx264"`` is a - better option if you have it installed. - - pixel_format: str, default "yuv420p" - pixel format - - options: dict, optional - Codec options. For example, if using ``"mpeg4"`` you can use ``{"q:v": "20"}`` to set the quality between - 1-31, where "1" is highest and "31" is lowest. If using ``"libx264"``` you can use ``{"crf": "30"}`` where - the "crf" value is between "0" (highest quality) and "50" (lowest quality). See ``ffmpeg`` docs for more - info on codec options - - Examples - -------- - - With ``"mpeg4"`` - - .. code-block:: python - - # create a plot or gridplot etc - - # start recording video - plot.record_start("./video.mp4", options={"q:v": "20"} - - # do stuff like interacting with the plot, change things, etc. - - # end recording - plot.record_end() - - With ``"libx264"`` - - .. code-block:: python - - # create a plot or gridplot etc - - # start recording video - plot.record_start("./vid_x264.mp4", codec="libx264", options={"crf": "25"}) - - # do stuff like interacting with the plot, change things, etc. - - # end recording - plot.record_end() - - """ - - if not HAS_AV: - raise ModuleNotFoundError( - "Recording to video file requires `av`:\n" - "https://github.com/PyAV-Org/PyAV" - ) - - if Path(path).exists(): - raise FileExistsError(f"File already exists at given path: {path}") - - # queue for sending frames to VideoWriterAV process - self._video_writer_queue = Queue() - - # snapshot to get canvas width height - ss = self.canvas.snapshot() - - # writer process - self._video_writer = VideoWriterAV( - path=str(path), - queue=self._video_writer_queue, - fps=int(fps), - width=ss.width, - height=ss.height, - codec=codec, - pixel_format=pixel_format, - options=options, - ) - - # start writer process - self._video_writer.start() - - # 1.3 seems to work well to reduce that difference between playback time and recording time - # will properly investigate later - self._record_fps = fps * 1.3 - self._record_start_time = time() - - # record timer used to maintain desired framerate - self._record_timer = time() - - self.add_animations(self._record) - - def record_stop(self) -> float: - """ - End a current recording. Returns the real duration of the recording - - Returns - ------- - float - recording duration - """ - - # tell video writer that recording has finished - self._video_writer_queue.put(None) - - # wait for writer to finish - self._video_writer.join(timeout=5) - - self._video_writer = None - - # so self._record() is no longer called on every render cycle - self.remove_animation(self._record) - - return time() - self._record_start_time diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index e9eae7603..059307e6b 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -1,46 +1,50 @@ -from typing import * +from typing import Literal, Union import numpy as np import pygfx -from wgpu.gui.auto import WgpuCanvas +from wgpu.gui import WgpuCanvasBase from ..graphics import TextGraphic from ._utils import make_canvas_and_renderer, create_camera, create_controller from ._plot_area import PlotArea -from .graphic_methods_mixin import GraphicMethodsMixin +from ._graphic_methods_mixin import GraphicMethodsMixin class Subplot(PlotArea, GraphicMethodsMixin): def __init__( self, - parent: Any = None, - position: Tuple[int, int] = None, - parent_dims: Tuple[int, int] = None, - camera: Union[str, pygfx.PerspectiveCamera] = "2d", - controller: Union[str, pygfx.Controller] = None, - canvas: Union[str, WgpuCanvas, pygfx.Texture] = None, + parent: Union["Figure", None] = None, + position: tuple[int, int] = None, + parent_dims: tuple[int, int] = None, + camera: Literal["2d", "3d"] | pygfx.PerspectiveCamera = "2d", + controller: ( + Literal["panzoom", "fly", "trackball", "orbit"] | pygfx.Controller + ) = None, + canvas: ( + Literal["glfw", "jupyter", "qt", "wx"] | WgpuCanvasBase | pygfx.Texture + ) = None, renderer: pygfx.WgpuRenderer = None, name: str = None, ): """ - General plot object that composes a ``Gridplot``. Each ``Gridplot`` instance will have [n rows, n columns] + General plot object is found within a ``Figure``. Each ``Figure`` instance will have [n rows, n columns] of subplots. .. important:: - ``Subplot`` is not meant to be constructed directly, it only exists as part of a ``GridPlot`` + ``Subplot`` is not meant to be constructed directly, it only exists as part of a ``Figure`` Parameters ---------- - parent: Any - parent GridPlot instance + parent: 'Figure' | None + parent Figure instance position: (int, int), optional - corresponds to the [row, column] position of the subplot within a ``Gridplot`` + corresponds to the [row, column] position of the subplot within a ``Figure`` parent_dims: (int, int), optional - dimensions of the parent ``GridPlot`` + dimensions of the parent ``Figure`` camera: str or pygfx.PerspectiveCamera, default '2d' indicates the FOV for the camera, '2d' sets ``fov = 0``, '3d' sets ``fov = 50``. @@ -51,7 +55,7 @@ def __init__( | if ``str``, must be one of: `"panzoom", "fly", "trackball", or "orbit"`. | also accepts a pygfx.Controller instance - canvas: one of "jupyter", "glfw", "qt", WgpuCanvas, or pygfx.Texture, optional + canvas: one of "jupyter", "glfw", "qt", "ex, a WgpuCanvas, or a pygfx.Texture, optional Provides surface on which a scene will be rendered. Can optionally provide a WgpuCanvas instance or a str to force the PlotArea to use a specific canvas from one of the following options: "jupyter", "glfw", "qt". Can also provide a pygfx Texture to render to. @@ -90,6 +94,8 @@ def __init__( self._grid: pygfx.GridHelper = pygfx.GridHelper(size=100, thickness=1) + self._title_graphic: TextGraphic = None + super(Subplot, self).__init__( parent=parent, position=position, @@ -107,16 +113,15 @@ def __init__( self.docks[pos] = dv self.children.append(dv) - self._title_graphic: TextGraphic = None if self.name is not None: self.set_title(self.name) @property - def name(self) -> Any: + def name(self) -> str: return self._name @name.setter - def name(self, name: Any): + def name(self, name: str): self._name = name self.set_title(name) @@ -135,7 +140,7 @@ def docks(self) -> dict: """ return self._docks - def set_title(self, text: Any): + def set_title(self, text: str): """Sets the plot title, stored as a ``TextGraphic`` in the "top" dock area""" if text is None: return @@ -144,7 +149,7 @@ def set_title(self, text: Any): if self._title_graphic is not None: self._title_graphic.text = text else: - tg = TextGraphic(text=text, size=18) + tg = TextGraphic(text=text, font_size=18) self._title_graphic = tg self.docks["top"].size = 35 @@ -213,7 +218,7 @@ def __init__( self._size = size - super(Dock, self).__init__( + super().__init__( parent=parent, position=position, camera=pygfx.OrthographicCamera(), @@ -348,4 +353,4 @@ def render(self): if self.size == 0: return - super(Dock, self).render() + super().render() diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 7db1d84c4..85c35532c 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -1,64 +1,14 @@ -from typing import * +import importlib import pygfx -from pygfx import WgpuRenderer, Texture +from pygfx import WgpuRenderer, Texture, Renderer +from wgpu.gui import WgpuCanvasBase -# default auto-determined canvas -from wgpu.gui.auto import WgpuCanvas -from wgpu.gui.base import WgpuCanvasBase - - -# TODO: this determination can be better -try: - from wgpu.gui.jupyter import JupyterWgpuCanvas -except ImportError: - JupyterWgpuCanvas = False - -try: - import PyQt6 - from wgpu.gui.qt import QWgpuCanvas -except ImportError: - QWgpuCanvas = False - -try: - from wgpu.gui.glfw import GlfwWgpuCanvas -except ImportError: - GlfwWgpuCanvas = False - - -CANVAS_OPTIONS = ["jupyter", "glfw", "qt"] -CANVAS_OPTIONS_AVAILABLE = { - "jupyter": JupyterWgpuCanvas, - "glfw": GlfwWgpuCanvas, - "qt": QWgpuCanvas, -} - - -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 +from ..utils import gui def make_canvas_and_renderer( - canvas: Union[str, WgpuCanvas, Texture, None], renderer: [WgpuRenderer, None] + canvas: str | WgpuCanvasBase | Texture | None, renderer: Renderer | None ): """ Parses arguments and returns the appropriate canvas and renderer instances @@ -66,33 +16,28 @@ def make_canvas_and_renderer( """ if canvas is None: - Canvas = auto_determine_canvas() - canvas = Canvas(max_fps=60) - + canvas = gui.WgpuCanvas(max_fps=60) elif isinstance(canvas, str): - if canvas not in CANVAS_OPTIONS: - raise ValueError(f"str canvas argument must be one of: {CANVAS_OPTIONS}") - elif not CANVAS_OPTIONS_AVAILABLE[canvas]: - raise ImportError( - f"The {canvas} framework is not installed for using this canvas" - ) - else: - canvas = CANVAS_OPTIONS_AVAILABLE[canvas](max_fps=60) - + m = importlib.import_module("wgpu.gui." + canvas) + canvas = m.WgpuCanvas(max_fps=60) elif not isinstance(canvas, (WgpuCanvasBase, Texture)): - raise ValueError( + raise TypeError( f"canvas option must either be a valid WgpuCanvas implementation, a pygfx Texture" - f" or a str from the following options: {CANVAS_OPTIONS}" + f" or a str with the wgpu gui backend name." ) if renderer is None: renderer = WgpuRenderer(canvas) + elif not isinstance(renderer, Renderer): + raise TypeError( + f"renderer option must be a pygfx.Renderer instance such as pygfx.WgpuRenderer" + ) return canvas, renderer def create_camera( - camera_type: Union[pygfx.PerspectiveCamera, str], + camera_type: pygfx.PerspectiveCamera | str, ) -> pygfx.PerspectiveCamera: if isinstance(camera_type, pygfx.PerspectiveCamera): return camera_type @@ -119,7 +64,7 @@ def create_camera( def create_controller( - controller_type: Union[pygfx.Controller, None, str], + controller_type: pygfx.Controller | None | str, camera: pygfx.PerspectiveCamera, ) -> pygfx.Controller: """ diff --git a/fastplotlib/layouts/_video_writer.py b/fastplotlib/layouts/_video_writer.py new file mode 100644 index 000000000..b7e111b50 --- /dev/null +++ b/fastplotlib/layouts/_video_writer.py @@ -0,0 +1,82 @@ +from pathlib import Path +from multiprocessing import Queue, Process + + +def _get_av(): + try: + import av + except ImportError: + raise ModuleNotFoundError( + "Recording to video file requires `av`:\n" + "https://github.com/PyAV-Org/PyAV" + ) from None + else: + return av + + +class VideoWriterAV(Process): + """Video writer, uses PyAV in an external process to write frames to disk""" + + def __init__( + self, + path: Path | str, + queue: Queue, + fps: int, + width: int, + height: int, + codec: str, + pixel_format: str, + options: dict = None, + ): + super().__init__() + self.queue = queue + + av = _get_av() + self.container = av.open(path, mode="w") + + self.stream = self.container.add_stream(codec, rate=fps, options=options) + + # in case libx264, trim last rows and/or column + # because libx264 doesn't like non-even number width or height + if width % 2 != 0: + width -= 1 + if height % 2 != 0: + height -= 1 + + self.stream.width = width + self.stream.height = height + + self.stream.pix_fmt = pixel_format + + def run(self): + av = _get_av() + while True: + if self.queue.empty(): # no frame to write + continue + + frame = self.queue.get() + + # recording has ended + if frame is None: + self.container.close() + break + + frame = av.VideoFrame.from_ndarray( + frame[ + : self.stream.height, : self.stream.width + ], # trim if necessary because of x264 + format="rgb24", + ) + + for packet in self.stream.encode(frame): + self.container.mux(packet) + + # I don't exactly know what this does, copied from pyav example + for packet in self.stream.encode(): + self.container.mux(packet) + + # close file + self.container.close() + + # close process, release resources + self.close() diff --git a/fastplotlib/layouts/graphic_methods_mixin.py b/fastplotlib/layouts/graphic_methods_mixin.py deleted file mode 100644 index b00187df7..000000000 --- a/fastplotlib/layouts/graphic_methods_mixin.py +++ /dev/null @@ -1,411 +0,0 @@ -# This is an auto-generated file and should not be modified directly - -from typing import * - -import numpy -import weakref - -from ..graphics import * -from ..graphics._base import Graphic - - -class GraphicMethodsMixin: - def __init__(self): - pass - - def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: - if 'center' in kwargs.keys(): - center = kwargs.pop('center') - else: - center = False - - if 'name' in kwargs.keys(): - self._check_graphic_name_exists(kwargs['name']) - - graphic = graphic_class(*args, **kwargs) - self.add_graphic(graphic, center=center) - - # only return a proxy to the real graphic - return weakref.proxy(graphic) - - def add_heatmap(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = 'plasma', filter: str = 'nearest', chunk_size: int = 8192, isolated_buffer: bool = True, *args, **kwargs) -> HeatmapGraphic: - """ - - Create an Image Graphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` - - vmin: int, optional - minimum value for color scaling, calculated from data if not provided - - vmax: int, optional - maximum value for color scaling, calculated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the data - - filter: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - chunk_size: int, default 8192, max 8192 - chunk size for each tile used to make up the heatmap texture - - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer. - - args: - additional arguments passed to Graphic - - kwargs: - additional keyword arguments passed to Graphic - - Features - -------- - - **data**: :class:`.HeatmapDataFeature` - Manages the data buffer displayed in the HeatmapGraphic - - **cmap**: :class:`.HeatmapCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - - - """ - return self._create_graphic(HeatmapGraphic, data, vmin, vmax, cmap, filter, chunk_size, isolated_buffer, *args, **kwargs) - - def add_image(self, data: Any, vmin: int = None, vmax: int = None, cmap: str = 'plasma', filter: str = 'nearest', isolated_buffer: bool = True, *args, **kwargs) -> ImageGraphic: - """ - - Create an Image Graphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` - - vmin: int, optional - minimum value for color scaling, calculated from data if not provided - - vmax: int, optional - maximum value for color scaling, calculated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the image data, ignored if data is RGB - - filter: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer. - - args: - additional arguments passed to Graphic - - kwargs: - additional keyword arguments passed to Graphic - - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the data buffer displayed in the ImageGraphic - - **cmap**: :class:`.ImageCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - - - """ - return self._create_graphic(ImageGraphic, data, vmin, vmax, cmap, filter, isolated_buffer, *args, **kwargs) - - def add_line_collection(self, data: List[numpy.ndarray], z_position: Union[List[float], float] = None, thickness: Union[float, List[float]] = 2.0, colors: Union[List[numpy.ndarray], numpy.ndarray] = 'w', alpha: float = 1.0, cmap: Union[List[str], str] = None, cmap_values: Union[numpy.ndarray, List] = None, name: str = None, metadata: Union[list, tuple, numpy.ndarray] = None, *args, **kwargs) -> LineCollection: - """ - - Create a collection of :class:`.LineGraphic` - - Parameters - ---------- - - data: list of array-like or array - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] - - z_position: list of float or float, optional - | if ``float``, single position will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - thickness: float or list of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, list of RGBA array, or list of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: list of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - cmap_values: 1D array-like or list of numerical values, optional - if provided, these values are used to map the colors from the cmap - - name: str, optional - name of the line collection - - metadata: list, tuple, or array - metadata associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` - - args - passed to GraphicCollection - - kwargs - passed to GraphicCollection - - Features - -------- - - Collections support the same features as the underlying graphic. You just have to slice the selection. - - See :class:`LineGraphic` details on the features. - - - """ - return self._create_graphic(LineCollection, data, z_position, thickness, colors, alpha, cmap, cmap_values, name, metadata, *args, **kwargs) - - def add_line(self, data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Iterable] = 'w', alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, List] = None, z_position: float = None, collection_index: int = None, *args, **kwargs) -> LineGraphic: - """ - - Create a line Graphic, 2d or 3d - - Parameters - ---------- - data: array-like - Line data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] - - thickness: float, optional, default 2.0 - thickness of the line - - colors: str, array, or iterable, default "w" - specify colors as a single human-readable string, a single RGBA array, - or an iterable of strings or RGBA arrays - - cmap: str, optional - apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors" - - cmap_values: 1D array-like or list of numerical values, optional - if provided, these values are used to map the colors from the cmap - - alpha: float, optional, default 1.0 - alpha value for the colors - - z_position: float, optional - z-axis position for placing the graphic - - args - passed to Graphic - - kwargs - passed to Graphic - - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. - - **thickness**: :class:`.ThicknessFeature` - Manages the thickness feature of the lines. - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` - - - """ - return self._create_graphic(LineGraphic, data, thickness, colors, alpha, cmap, cmap_values, z_position, collection_index, *args, **kwargs) - - def add_line_stack(self, data: List[numpy.ndarray], z_position: Union[List[float], float] = None, thickness: Union[float, List[float]] = 2.0, colors: Union[List[numpy.ndarray], numpy.ndarray] = 'w', cmap: Union[List[str], str] = None, separation: float = 10, separation_axis: str = 'y', name: str = None, *args, **kwargs) -> LineStack: - """ - - Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. - - Parameters - ---------- - data: list of array-like - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] - - z_position: list of float or float, optional - | if ``float``, single position will be used for all lines - | if ``list`` of ``float``, each value will apply to individual lines - - thickness: float or list of float, default 2.0 - | if ``float``, single thickness will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines - - colors: str, RGBA array, list of RGBA array, or list of str, default "w" - | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines - | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines - | is ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] - | if ``list`` of ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line - - cmap: list of str or str, optional - | if ``str``, single cmap will be used for all lines - | if ``list`` of ``str``, each cmap will apply to the individual lines - - .. note:: - ``cmap`` overrides any arguments passed to ``colors`` - - name: str, optional - name of the line stack - - separation: float, default 10 - space in between each line graphic in the stack - - separation_axis: str, default "y" - axis in which the line graphics in the stack should be separated - - name: str, optional - name of the line stack - - args - passed to LineCollection - - kwargs - passed to LineCollection - - - Features - -------- - - Collections support the same features as the underlying graphic. You just have to slice the selection. - - See :class:`LineGraphic` details on the features. - - - """ - 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, 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 - - Parameters - ---------- - data: array-like - Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] - - sizes: float or iterable of float, optional, default 1.0 - size of the scatter points - - colors: str, array, or iterable, default "w" - specify colors as a single human readable string, a single RGBA array, - or an iterable of strings or RGBA arrays - - cmap: str, optional - apply a colormap to the scatter instead of assigning colors manually, this - overrides any argument passed to "colors" - - cmap_values: 1D array-like or list of numerical values, optional - if provided, these values are used to map the colors from the cmap - - alpha: float, optional, default 1.0 - alpha value for the colors - - z_position: float, optional - z-axis position for placing the graphic - - args - passed to Graphic - - kwargs - passed to Graphic - - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` - - - """ - 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 = 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 - - Parameters - ---------- - text: str - display text - - position: int tuple, default (0, 0, 0) - int tuple indicating location of text in scene - - size: int, default 10 - text size - - face_color: str or array, default "w" - str or RGBA array to set the color of the text - - outline_color: str or array, default "w" - str or RGBA array to set the outline color of the text - - 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, screen_space, anchor, *args, **kwargs) - diff --git a/examples/desktop/heatmap/__init__.py b/fastplotlib/layouts/output/__init__.py similarity index 100% rename from examples/desktop/heatmap/__init__.py rename to fastplotlib/layouts/output/__init__.py diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/output/_ipywidget_toolbar.py similarity index 51% rename from fastplotlib/layouts/_frame/_ipywidget_toolbar.py rename to fastplotlib/layouts/output/_ipywidget_toolbar.py index f27856e61..787c8d442 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/output/_ipywidget_toolbar.py @@ -2,31 +2,27 @@ from datetime import datetime from itertools import product from math import copysign -from functools import partial -from typing import * - +from pathlib import Path from ipywidgets.widgets import ( - IntSlider, - VBox, HBox, ToggleButton, Dropdown, Layout, Button, - BoundedIntText, - Play, - jslink, + Image, ) from ...graphics.selectors import PolygonSelector from ._toolbar import ToolBar +from ...utils import config class IpywidgetToolBar(HBox, ToolBar): """Basic toolbar using ipywidgets""" - def __init__(self, plot): - ToolBar.__init__(self, plot) + + def __init__(self, figure): + ToolBar.__init__(self, figure) self._auto_scale_button = Button( value=False, @@ -79,7 +75,7 @@ def __init__(self, plot): disabled=False, icon="draw-polygon", layout=Layout(width="auto"), - tooltip="add PolygonSelector" + tooltip="add PolygonSelector", ) widgets = [ @@ -92,25 +88,39 @@ def __init__(self, plot): 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)) + if config.party_parrot: + gif_path = Path(__file__).parent.parent.parent.joinpath("assets", "egg.gif") + with open(gif_path, "rb") as f: + gif = f.read() - self._dropdown = Dropdown( - options=values, - disabled=False, - description="Subplots:", - layout=Layout(width="200px"), + image = Image( + value=gif, + format="png", + width=35, + height=25, ) + widgets.append(image) - self.plot.renderer.add_event_handler(self.update_current_subplot, "click") + positions = list( + product(range(self.figure.shape[0]), range(self.figure.shape[1])) + ) + values = list() + for pos in positions: + if self.figure[pos].name is not None: + values.append(self.figure[pos].name) + else: + values.append(str(pos)) + + self._dropdown = Dropdown( + options=values, + disabled=False, + description="Subplots:", + layout=Layout(width="200px"), + ) - widgets.append(self._dropdown) + self.figure.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) @@ -134,7 +144,9 @@ 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) + self.current_subplot.auto_scale( + maintain_aspect=self.current_subplot.camera.maintain_aspect + ) def center_scene_handler(self, obj): self.current_subplot.center_scene() @@ -157,7 +169,7 @@ def y_direction_handler(self, obj): self._y_direction_button.icon = "arrow-up" def update_current_subplot(self, ev): - for subplot in self.plot: + for subplot in self.figure: pos = subplot.map_screen_to_world((ev.x, ev.y)) if pos is not None: # update self.dropdown @@ -176,135 +188,15 @@ def update_current_subplot(self, ev): def record_plot(self, obj): if self._record_button.value: try: - self.plot.record_start( + self.figure.recorder.start( f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" ) except Exception: traceback.print_exc() self._record_button.value = False else: - self.plot.record_stop() + self.figure.recorder.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/output/_qt_toolbar.py b/fastplotlib/layouts/output/_qt_toolbar.py new file mode 100644 index 000000000..4334f1369 --- /dev/null +++ b/fastplotlib/layouts/output/_qt_toolbar.py @@ -0,0 +1,125 @@ +from datetime import datetime +from math import copysign +import traceback + +from ...utils.gui import QtWidgets +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, figure): + QtWidgets.QWidget.__init__(self, parent=output_context) + ToolBar.__init__(self, figure) + + # 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) + + # subplot labels update when a user click on subplots + subplot = self.figure[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.figure.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.figure: + 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.figure.record_start( + f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" + ) + except Exception: + traceback.print_exc() + self.ui.record_button.setChecked(False) + else: + self.figure.record_stop() + + def add_polygon(self, *args): + ps = PolygonSelector(edge_width=3, edge_color="mageneta") + self.current_subplot.add_graphic(ps, center=False) diff --git a/fastplotlib/layouts/_frame/_qtoolbar_template.py b/fastplotlib/layouts/output/_qtoolbar_template.py similarity index 96% rename from fastplotlib/layouts/_frame/_qtoolbar_template.py rename to fastplotlib/layouts/output/_qtoolbar_template.py index a8a1c6f86..d2311c595 100644 --- a/fastplotlib/layouts/_frame/_qtoolbar_template.py +++ b/fastplotlib/layouts/output/_qtoolbar_template.py @@ -5,8 +5,7 @@ # 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 +from ...utils.gui import QtGui, QtCore, QtWidgets class Ui_QToolbar(object): @@ -30,7 +29,7 @@ def setupUi(self, QToolbar): self.maintain_aspect_button = QtWidgets.QPushButton(parent=QToolbar) font = QtGui.QFont() font.setBold(True) - font.setWeight(75) + font.setWeight(QtGui.QFont.Weight.Bold) self.maintain_aspect_button.setFont(font) self.maintain_aspect_button.setCheckable(True) self.maintain_aspect_button.setObjectName("maintain_aspect_button") diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/output/_toolbar.py similarity index 79% rename from fastplotlib/layouts/_frame/_toolbar.py rename to fastplotlib/layouts/output/_toolbar.py index 94410b8ea..5edd201fa 100644 --- a/fastplotlib/layouts/_frame/_toolbar.py +++ b/fastplotlib/layouts/output/_toolbar.py @@ -1,9 +1,9 @@ -from fastplotlib.layouts._subplot import Subplot +from .._subplot import Subplot class ToolBar: - def __init__(self, plot): - self.plot = plot + def __init__(self, figure): + self.figure = figure def _get_subplot_dropdown_value(self) -> str: raise NotImplemented @@ -11,17 +11,17 @@ def _get_subplot_dropdown_value(self) -> str: @property def current_subplot(self) -> Subplot: """Returns current subplot""" - if hasattr(self.plot, "_subplots"): + if hasattr(self.figure, "_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] + return self.figure[current] else: - return self.plot[current] + return self.figure[current] else: - return self.plot + return self.figure def panzoom_handler(self, ev): raise NotImplemented diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/output/jupyter_output.py similarity index 92% rename from fastplotlib/layouts/_frame/_jupyter_output.py rename to fastplotlib/layouts/output/jupyter_output.py index 25f5e2a2e..9ebf0941d 100644 --- a/fastplotlib/layouts/_frame/_jupyter_output.py +++ b/fastplotlib/layouts/output/jupyter_output.py @@ -1,5 +1,3 @@ -from typing import * - from ipywidgets import VBox, Widget from sidecar import Sidecar from IPython.display import display @@ -13,13 +11,14 @@ class JupyterOutputContext(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], + self, + frame, + make_toolbar: bool, + use_sidecar: bool, + sidecar_kwargs: dict, + add_widgets: list[Widget], ): """ diff --git a/fastplotlib/layouts/_frame/_qt_output.py b/fastplotlib/layouts/output/qt_output.py similarity index 88% rename from fastplotlib/layouts/_frame/_qt_output.py rename to fastplotlib/layouts/output/qt_output.py index b4c7cffd9..20aaef2d1 100644 --- a/fastplotlib/layouts/_frame/_qt_output.py +++ b/fastplotlib/layouts/output/qt_output.py @@ -1,5 +1,4 @@ -from PyQt6 import QtWidgets - +from ...utils.gui import QtWidgets from ._qt_toolbar import QToolbar @@ -9,11 +8,12 @@ class QOutputContext(QtWidgets.QWidget): Basically vstacks plot canvas, toolbar, and other widgets. """ + def __init__( - self, - frame, - make_toolbar, - add_widgets, + self, + frame, + make_toolbar, + add_widgets, ): """ @@ -37,7 +37,7 @@ def __init__( self.vlayout.addWidget(self.frame.canvas) if make_toolbar: # make toolbar and add to layout - self.toolbar = QToolbar(output_context=self, plot=frame) + self.toolbar = QToolbar(output_context=self, figure=frame) self.vlayout.addWidget(self.toolbar) for w in add_widgets: # add any additional widgets to layout diff --git a/fastplotlib/layouts/_frame/qtoolbar.ui b/fastplotlib/layouts/output/qtoolbar.ui similarity index 100% rename from fastplotlib/layouts/_frame/qtoolbar.ui rename to fastplotlib/layouts/output/qtoolbar.ui diff --git a/fastplotlib/legends/__init__.py b/fastplotlib/legends/__init__.py new file mode 100644 index 000000000..507251f59 --- /dev/null +++ b/fastplotlib/legends/__init__.py @@ -0,0 +1,3 @@ +from .legend import Legend + +__all__ = ["Legend"] diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py new file mode 100644 index 000000000..8ab3ddedb --- /dev/null +++ b/fastplotlib/legends/legend.py @@ -0,0 +1,356 @@ +from functools import partial +from collections import OrderedDict +from typing import Iterable + +import numpy as np +import pygfx + +from ..graphics._base import Graphic +from ..graphics._features._base import FeatureEvent +from ..graphics import LineGraphic, ScatterGraphic, ImageGraphic +from ..utils import mesh_masks + + +class LegendItem: + def __init__( + self, + label: str, + color: pygfx.Color, + ): + """ + + Parameters + ---------- + label: str + + color: pygfx.Color + """ + self._label = label + self._color = color + + +class LineLegendItem(LegendItem): + def __init__( + self, parent, graphic: LineGraphic, label: str, position: tuple[int, int] + ): + """ + + Parameters + ---------- + graphic: LineGraphic + + label: str + + position: [x, y] + """ + + if label is not None: + pass + + elif graphic.name is not None: + pass + + else: + raise ValueError( + "Must specify `label` or Graphic must have a `name` to auto-use as the label" + ) + + # for now only support lines with a single color + if np.unique(graphic.colors(), axis=0).shape[0] > 1: + raise ValueError("Use colorbars for multi-colored lines, not legends") + + color = pygfx.Color(np.unique(graphic.colors(), axis=0).ravel()) + + self._parent = parent + + super().__init__(label, color) + + graphic.colors.add_event_handler(self._update_color) + + # construct Line WorldObject + data = np.array([[0, 0, 0], [3, 0, 0]], dtype=np.float32) + + material = pygfx.LineMaterial + + self._line_world_object = pygfx.Line( + geometry=pygfx.Geometry(positions=data), + material=material(thickness=8, color=self._color), + ) + + # self._line_world_object.world.x = position[0] + + self._label_world_object = pygfx.Text( + geometry=pygfx.TextGeometry( + text=str(label), + font_size=6, + screen_space=False, + anchor="middle-left", + ), + material=pygfx.TextMaterial( + color="w", + outline_color="w", + outline_thickness=0, + ), + ) + + self.world_object = pygfx.Group() + self.world_object.add(self._line_world_object, self._label_world_object) + + self.world_object.world.x = position[0] + # add 10 to x to account for space for the line + self._label_world_object.world.x = position[0] + 10 + + self.world_object.world.y = position[1] + self.world_object.world.z = 2 + + self.world_object.add_event_handler( + partial(self._highlight_graphic, graphic), "click" + ) + + @property + def label(self) -> str: + return self._label + + @label.setter + def label(self, text: str): + self._parent._check_label_unique(text) + self._label_world_object.geometry.set_text(text) + + def _update_color(self, ev: FeatureEvent): + new_color = ev.pick_info["new_data"] + if np.unique(new_color, axis=0).shape[0] > 1: + raise ValueError( + "LegendError: LineGraphic colors no longer appropriate for legend" + ) + + self._color = new_color[0] + self._line_world_object.material.color = pygfx.Color(self._color) + + def _highlight_graphic(self, graphic, ev): + graphic_color = pygfx.Color(np.unique(graphic.colors(), axis=0).ravel()) + + if graphic_color == self._parent.highlight_color: + graphic.colors = self._color + else: + # hacky but fine for now + orig_color = pygfx.Color(self._color) + graphic.colors = self._parent.highlight_color + self._color = orig_color + + +class Legend(Graphic): + def __init__( + self, + plot_area, + highlight_color: str | tuple | np.ndarray = "w", + max_rows: int = 5, + *args, + **kwargs, + ): + """ + + Parameters + ---------- + plot_area: Union[Plot, Subplot, Dock] + plot area to put the legend in + + highlight_color: Union[str, tuple, np.ndarray], default "w" + highlight color + + max_rows: int, default 5 + maximum number of rows allowed in the legend + + """ + self._graphics: list[Graphic] = list() + + # hex id of Graphic, i.e. graphic._fpl_address are the keys + self._items: OrderedDict[str:LegendItem] = OrderedDict() + + super().__init__(*args, **kwargs) + + group = pygfx.Group() + self._legend_items_group = pygfx.Group() + self._set_world_object(group) + + self._mesh = pygfx.Mesh( + pygfx.box_geometry(50, 10, 1), + pygfx.MeshBasicMaterial( + color=pygfx.Color([0.1, 0.1, 0.1, 1]), wireframe_thickness=10 + ), + ) + + self.world_object.add(self._mesh) + self.world_object.add(self._legend_items_group) + + self.highlight_color = pygfx.Color(highlight_color) + + self._plot_area = plot_area + self._plot_area.add_graphic(self) + + if self._plot_area.__class__.__name__ == "Dock": + if self._plot_area.size < 1: + self._plot_area.size = 100 + + # TODO: refactor with "moveable graphic" base class once that's done + self._mesh.add_event_handler(self._pointer_down, "pointer_down") + self._plot_area.renderer.add_event_handler(self._pointer_move, "pointer_move") + self._plot_area.renderer.add_event_handler(self._pointer_up, "pointer_up") + + self._last_position = None + self._initial_controller_state = self._plot_area.controller.enabled + + self._max_rows = max_rows + + self._row_counter = 0 + self._col_counter = 0 + + def graphics(self) -> tuple[Graphic, ...]: + return tuple(self._graphics) + + def _check_label_unique(self, label): + for legend_item in self._items.values(): + if legend_item.label == label: + raise ValueError( + f"You have passed the label '{label}' which is already used for another legend item. " + f"All labels within a legend must be unique." + ) + + def add_graphic(self, graphic: Graphic, label: str = None): + if graphic in self._graphics: + raise KeyError( + f"Graphic already exists in legend with label: '{self._items[graphic._fpl_address].label}'" + ) + + self._check_label_unique(label) + + new_col_ix = self._col_counter + new_row_ix = self._row_counter + + x_pos = 0 + y_pos = 0 + + if self._row_counter == self._max_rows: + # set counters + new_col_ix = self._col_counter + 1 + + # get x position offset for this new column of LegendItems + # start by getting the LegendItems in the previous column + prev_column_items: list[LegendItem] = list(self._items.values())[ + -self._max_rows : + ] + # x position of LegendItems in previous column + x_pos = prev_column_items[-1].world_object.world.x + max_width = 0 + # get width of widest LegendItem in previous column to add to x_pos offset for this column + for item in prev_column_items: + bbox = item.world_object.get_world_bounding_box() + width, height, depth = np.ptp(bbox, axis=0) + max_width = max(max_width, width) + + # x position offset for this new column + x_pos = x_pos + max_width + 15 # add 15 for spacing + + # rest row index for next iteration + new_row_ix = 1 + else: + if len(self._items) > 0: + x_pos = list(self._items.values())[-1].world_object.world.x + + y_pos = new_row_ix * -10 + new_row_ix = self._row_counter + 1 + + if isinstance(graphic, LineGraphic): + legend_item = LineLegendItem(self, graphic, label, position=(x_pos, y_pos)) + else: + raise ValueError("Legend only supported for LineGraphic for now.") + + self._legend_items_group.add(legend_item.world_object) + self._reset_mesh_dims() + + self._graphics.append(graphic) + self._items[graphic._fpl_address] = legend_item + + graphic.deleted.add_event_handler(partial(self.remove_graphic, graphic)) + + self._col_counter = new_col_ix + self._row_counter = new_row_ix + + def _reset_mesh_dims(self): + bbox = self._legend_items_group.get_world_bounding_box() + + width, height, _ = np.ptp(bbox, axis=0) + + self._mesh.geometry.positions.data[mesh_masks.x_right] = width + 7 + self._mesh.geometry.positions.data[mesh_masks.x_left] = -5 + self._mesh.geometry.positions.data[mesh_masks.y_bottom] = 0 + self._mesh.geometry.positions.data[mesh_masks.y_bottom] = -height - 3 + self._mesh.geometry.positions.update_range() + + def remove_graphic(self, graphic: Graphic): + self._graphics.remove(graphic) + legend_item = self._items.pop(graphic._fpl_address) + self._legend_items_group.remove(legend_item.world_object) + self._reset_item_positions() + + def _reset_item_positions(self): + for i, (graphic_loc, legend_item) in enumerate(self._items.items()): + y_pos = i * -10 + legend_item.world_object.world.y = y_pos + + self._reset_mesh_dims() + + def reorder(self, labels: Iterable[str]): + all_labels = [legend_item.label for legend_item in self._items.values()] + + if not set(labels) == set(all_labels): + raise ValueError("Must pass all existing legend labels") + + new_items = OrderedDict() + + for label in labels: + for graphic_loc, legend_item in self._items.items(): + if label == legend_item.label: + new_items[graphic_loc] = self._items.pop(graphic_loc) + break + + self._items = new_items + self._reset_item_positions() + + def _pointer_down(self, ev): + self._last_position = self._plot_area.map_screen_to_world(ev) + self._initial_controller_state = self._plot_area.controller.enabled + + def _pointer_move(self, ev): + if self._last_position is None: + return + + self._plot_area.controller.enabled = False + + world_pos = self._plot_area.map_screen_to_world(ev) + + # outside viewport + if world_pos is None: + return + + delta = world_pos - self._last_position + + self.world_object.world.x = self.world_object.world.x + delta[0] + self.world_object.world.y = self.world_object.world.y + delta[1] + + self._last_position = world_pos + + self._plot_area.controller.enabled = self._initial_controller_state + + def _pointer_up(self, ev): + self._last_position = None + if self._initial_controller_state is not None: + self._plot_area.controller.enabled = self._initial_controller_state + + def __getitem__(self, graphic: Graphic) -> LegendItem: + if not isinstance(graphic, Graphic): + raise TypeError("Must index Legend with Graphics") + + if graphic._fpl_address not in self._items.keys(): + raise KeyError("Graphic not in legend") + + return self._items[graphic._fpl_address] diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index c8f754883..3ae83fb6b 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -1 +1,14 @@ +from dataclasses import dataclass + + from .functions import * +from .gpu import enumerate_adapters, select_adapter, print_wgpu_report +from ._plot_helpers import * + + +@dataclass +class _Config: + party_parrot: bool + + +config = _Config(party_parrot=False) diff --git a/fastplotlib/utils/_plot_helpers.py b/fastplotlib/utils/_plot_helpers.py new file mode 100644 index 000000000..ac0ff2cda --- /dev/null +++ b/fastplotlib/utils/_plot_helpers.py @@ -0,0 +1,53 @@ +from typing import Sequence + +import numpy as np + +from ..graphics._base import Graphic +from ..graphics._collection_base import GraphicCollection + + +def get_nearest_graphics( + pos: tuple[float, float] | tuple[float, float, float], + graphics: Sequence[Graphic] | GraphicCollection, +) -> np.ndarray[Graphic]: + """ + Returns the nearest ``graphics`` to the passed position ``pos`` in world space. + Uses the distance between ``pos`` and the center of the bounding sphere for each graphic. + + Parameters + ---------- + pos: (x, y) | (x, y, z) + position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D + + graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection + the graphics from which to return a sorted array of graphics in order of closest + to furthest graphic + + Returns + ------- + tuple[Graphic] + nearest graphics to ``pos`` in order + + """ + + if isinstance(graphics, GraphicCollection): + graphics = graphics.graphics + + if not all(isinstance(g, Graphic) for g in graphics): + raise TypeError("all elements of `graphics` must be Graphic objects") + + pos = np.asarray(pos) + + if pos.shape != (2,) or not pos.shape != (3,): + raise TypeError + + # get centers + centers = np.empty(shape=(len(graphics), len(pos))) + for i in range(centers.shape[0]): + centers[i] = graphics[i].world_object.get_world_bounding_sphere()[: len(pos)] + + # l2 + distances = np.linalg.norm(centers[:, : len(pos)] - pos, ord=2, axis=1) + + sort_indices = np.argsort(distances) + return np.asarray(graphics)[sort_indices] diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 8d1e8694f..73752ba5e 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -1,5 +1,4 @@ from collections import OrderedDict -from typing import * from pathlib import Path import numpy as np @@ -92,8 +91,8 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray: max_colors = cmap.shape[0] if n_colors > cmap.shape[0]: raise ValueError( - f"You have requested <{n_colors}> but only <{max_colors} existing for the " - f"chosen cmap: <{cmap}>" + f"You have requested <{n_colors}> colors but only <{max_colors}> exist for the " + f"chosen cmap: <{name}>" ) return cmap[:n_colors] @@ -165,7 +164,7 @@ def make_colors_dict(labels: iter, cmap: str, **kwargs) -> OrderedDict: return OrderedDict(zip(labels, colors)) -def quick_min_max(data: np.ndarray) -> Tuple[float, float]: +def quick_min_max(data: np.ndarray) -> tuple[float, float]: """ Adapted from pyqtgraph.ImageView. Estimate the min/max values of *data* by subsampling. @@ -220,7 +219,7 @@ def make_pygfx_colors(colors, n_colors): return colors_array -def calculate_gridshape(n_subplots: int) -> Tuple[int, int]: +def calculate_figure_shape(n_subplots: int) -> tuple[int, int]: """ Returns ``(n_rows, n_cols)`` from given number of subplots ``n_subplots`` """ @@ -240,7 +239,7 @@ def normalize_min_max(a): def parse_cmap_values( n_colors: int, cmap_name: str, - cmap_values: Union[np.ndarray, List[Union[int, float]]] = None, + transform: np.ndarray | list[int | float] = None, ) -> np.ndarray: """ @@ -252,28 +251,25 @@ def parse_cmap_values( cmap_name: str colormap name - cmap_values: np.ndarray | List[int | float], optional - cmap values + transform: np.ndarray | List[int | float], optional + cmap transform Returns ------- """ - if cmap_values is None: - # use the cmap values linearly just along the collection indices - # for example, if len(data) = 10 and the cmap is "jet", then it will - # linearly go from blue to red from data[0] to data[-1] + if transform is None: colors = make_colors(n_colors, cmap_name) return colors else: - if not isinstance(cmap_values, np.ndarray): - cmap_values = np.array(cmap_values) + if not isinstance(transform, np.ndarray): + transform = np.array(transform) - # use the values within cmap_values to set the color of the corresponding data - # each individual data[i] has its color based on the "relative cmap_value intensity" - if len(cmap_values) != n_colors: + # use the of the cmap_transform to set the color of the corresponding data + # each individual data[i] has its color based on the transform values + if len(transform) != n_colors: raise ValueError( - f"len(cmap_values) != len(data): {len(cmap_values)} != {n_colors}" + f"len(cmap_values) != len(data): {len(transform)} != {n_colors}" ) colormap = get_cmap(cmap_name) @@ -281,23 +277,23 @@ def parse_cmap_values( n_colors = colormap.shape[0] - 1 if cmap_name in QUALITATIVE_CMAPS: - # check that cmap_values are and within the number of colors `n_colors` + # check that cmap_transform are and within the number of colors `n_colors` # do not scale, use directly - if not np.issubdtype(cmap_values.dtype, np.integer): + if not np.issubdtype(transform.dtype, np.integer): raise TypeError( - f" cmap_values should be used with qualitative colormaps, the dtype you " - f"have passed is {cmap_values.dtype}" + f" `cmap_transform` values should be used with qualitative colormaps, " + f"the dtype you have passed is {transform.dtype}" ) - if max(cmap_values) > n_colors: + if max(transform) > n_colors: raise IndexError( f"You have chosen the qualitative colormap <'{cmap_name}'> which only has " - f"<{n_colors}> colors, which is lower than the max value of your `cmap_values`." + f"<{n_colors}> colors, which is lower than the max value of your `cmap_transform`." f"Choose a cmap with more colors, or a non-quantitative colormap." ) - norm_cmap_values = cmap_values + norm_cmap_values = transform else: # scale between 0 - n_colors so we can just index the colormap as a LUT - norm_cmap_values = (normalize_min_max(cmap_values) * n_colors).astype(int) + norm_cmap_values = (normalize_min_max(transform) * n_colors).astype(int) # use colormap as LUT to map the cmap_values to the colormap index colors = np.vstack([colormap[val] for val in norm_cmap_values]) diff --git a/fastplotlib/utils/gpu.py b/fastplotlib/utils/gpu.py new file mode 100644 index 000000000..72d303d23 --- /dev/null +++ b/fastplotlib/utils/gpu.py @@ -0,0 +1,24 @@ +import wgpu +from pygfx.renderers.wgpu import select_adapter as pygfx_select_adapter +from pygfx import print_wgpu_report as pygfx_print_wgpu_report + + +def enumerate_adapters() -> list[wgpu.GPUAdapter]: + return wgpu.gpu.enumerate_adapters() + + +enumerate_adapters.__doc__ = wgpu.gpu.enumerate_adapters.__doc__ + + +def select_adapter(adapter: wgpu.GPUAdapter): + return pygfx_select_adapter(adapter) + + +select_adapter.__doc__ = pygfx_select_adapter.__doc__ + + +def print_wgpu_report(): + return pygfx_print_wgpu_report() + + +print_wgpu_report.__doc__ = pygfx_print_wgpu_report.__doc__ diff --git a/fastplotlib/utils/gui.py b/fastplotlib/utils/gui.py new file mode 100644 index 000000000..1941674ee --- /dev/null +++ b/fastplotlib/utils/gui.py @@ -0,0 +1,107 @@ +import sys +import importlib +from pathlib import Path + +import wgpu + + +# --- Prepare + + +# Ultimately, we let wgpu-py decide, but we can prime things a bit to create our +# own preferred order, by importing a Qt lib. But we only do this if no GUI has +# been imported yet. + +# Qt libs that we will try to import +qt_libs = ["PySide6", "PyQt6", "PySide2", "PyQt5"] + +# Other known libs that, if imported, we should probably not try to force qt +other_libs = ["glfw", "wx", "ipykernel"] + +already_imported = [name for name in (qt_libs + other_libs) if name in sys.modules] +if not already_imported: + for name in qt_libs: + try: + importlib.import_module(name) + except Exception: + pass + else: + break + + +# --- Triage + + +# Let wgpu do the auto gui selection +from wgpu.gui.auto import WgpuCanvas, run + +# Get the name of the backend ('qt', 'glfw', 'jupyter') +GUI_BACKEND = WgpuCanvas.__module__.split(".")[-1] +IS_JUPYTER = GUI_BACKEND == "jupyter" + + +# --- Some backend-specific preparations + + +def _notebook_print_banner(): + from ipywidgets import Image + from IPython.display import display + + logo_path = Path(__file__).parent.parent.joinpath( + "assets", "fastplotlib_face_logo.png" + ) + + with open(logo_path, "rb") as f: + logo_data = f.read() + + image = Image(value=logo_data, format="png", width=300, height=55) + + display(image) + + # print logo and adapter info + adapters = [a for a in wgpu.gpu.enumerate_adapters()] + adapters_info = [a.request_adapter_info() for a in adapters] + + default_adapter_info = wgpu.gpu.request_adapter().request_adapter_info() + default_ix = adapters_info.index(default_adapter_info) + + if len(adapters) > 0: + print("Available devices:") + + for ix, adapter in enumerate(adapters_info): + atype = adapter["adapter_type"] + backend = adapter["backend_type"] + driver = adapter["description"] + device = adapter["device"] + + if atype == "DiscreteGPU" and backend != "OpenGL": + charactor = chr(0x2705) + elif atype == "IntegratedGPU" and backend != "OpenGL": + charactor = chr(0x0001FBC4) + else: + charactor = chr(0x2757) + + if ix == default_ix: + default = " (default) " + else: + default = " " + + output_str = f"{charactor}{default}| {device} | {atype} | {backend} | {driver}" + print(output_str) + + +if GUI_BACKEND == "jupyter": + _notebook_print_banner() + +elif GUI_BACKEND == "qt": + from wgpu.gui.qt import get_app, libname + + # create and store ref to qt app + _qt_app = get_app() + + # Import submodules of PySide6/PyQt6/PySid2/PyQt5 + # For the way that fpl uses Qt, the supported Qt libs seems compatible enough. + # If necessary we can do some qtpy-like monkey-patching here. + QtCore = importlib.import_module(".QtCore", libname) + QtGui = importlib.import_module(".QtGui", libname) + QtWidgets = importlib.import_module(".QtWidgets", libname) diff --git a/fastplotlib/utils/mesh_masks.py b/fastplotlib/utils/mesh_masks.py new file mode 100644 index 000000000..c44588b6c --- /dev/null +++ b/fastplotlib/utils/mesh_masks.py @@ -0,0 +1,128 @@ +import numpy as np + + +""" +positions for indexing the BoxGeometry to set the "width" and "size" of the box +hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +""" + +x_right = np.array( + [ + True, + True, + True, + True, + False, + False, + False, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + ] +) + +x_left = np.array( + [ + False, + False, + False, + False, + True, + True, + True, + True, + True, + False, + True, + False, + False, + True, + False, + True, + True, + False, + True, + False, + False, + True, + False, + True, + ] +) + +y_top = np.array( + [ + False, + True, + False, + True, + False, + True, + False, + True, + True, + True, + True, + True, + False, + False, + False, + False, + False, + False, + True, + True, + False, + False, + True, + True, + ] +) + +y_bottom = np.array( + [ + True, + False, + True, + False, + True, + False, + True, + False, + False, + False, + False, + False, + True, + True, + True, + True, + True, + True, + False, + False, + True, + True, + False, + False, + ] +) + +x_right = (x_right, 0) +x_left = (x_left, 0) +y_top = (y_top, 1) +y_bottom = (y_bottom, 1) diff --git a/fastplotlib/widgets/_image_widget_ipywidget_toolbar.py b/fastplotlib/widgets/_image_widget_ipywidget_toolbar.py new file mode 100644 index 000000000..24f7a6279 --- /dev/null +++ b/fastplotlib/widgets/_image_widget_ipywidget_toolbar.py @@ -0,0 +1,135 @@ +from functools import partial + +from ipywidgets import ( + VBox, + Button, + Layout, + IntSlider, + BoundedIntText, + Play, + jslink, + HBox, +) + + +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.figure.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.figure.renderer.logical_size + + for k, v in self.sliders.items(): + v.layout = Layout(width=f"{w}px") diff --git a/fastplotlib/widgets/_image_widget_qt_toolbar.py b/fastplotlib/widgets/_image_widget_qt_toolbar.py new file mode 100644 index 000000000..2117f95ab --- /dev/null +++ b/fastplotlib/widgets/_image_widget_qt_toolbar.py @@ -0,0 +1,127 @@ +from functools import partial +from typing import Dict + +from fastplotlib.utils.gui import QtWidgets, QtCore + + +# TODO: There must be a better way to do this +# TODO: Check if an interface exists between ipywidgets and Qt +# TODO: Or we won't need it anyways once we have UI in pygfx +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/widgets/histogram_lut.py b/fastplotlib/widgets/histogram_lut.py index 64feb8df6..02c21aa38 100644 --- a/fastplotlib/widgets/histogram_lut.py +++ b/fastplotlib/widgets/histogram_lut.py @@ -1,4 +1,3 @@ -from typing import * import weakref import numpy as np @@ -13,12 +12,12 @@ # 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 + self, + data: np.ndarray, + image_graphic: ImageGraphic, + nbins: int = 100, + flank_divisor: float = 5.0, + **kwargs, ): """ @@ -45,52 +44,60 @@ def __init__( line_data = np.column_stack([hist_scaled, edges_flanked]) - self.line = LineGraphic(line_data) + self._histogram_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, + self._linear_region_selector = LinearRegionSelector( + selection=bounds, limits=limits, size=size, - origin=origin, + center=origin[0], axis="y", - edge_thickness=8 + edge_thickness=8, + parent=self._histogram_line, ) # 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._linear_region_selector.selection = ( + self._image_graphic.vmin, + self._image_graphic.vmax, + ) - self._vmin = self.image_graphic.cmap.vmin - self._vmax = self.image_graphic.cmap.vmax + self._vmin = self.image_graphic.vmin + self._vmax = self.image_graphic.vmax vmin_str, vmax_str = self._get_vmin_vmax_str() self._text_vmin = TextGraphic( text=vmin_str, - size=16, - position=(0, 0), + font_size=16, + offset=(0, 0, 0), anchor="top-left", outline_color="black", outline_thickness=1, ) + self._text_vmin.world_object.material.pick_write = False + self._text_vmax = TextGraphic( text=vmax_str, - size=16, - position=(0, 0), + font_size=16, + offset=(0, 0, 0), anchor="bottom-left", outline_color="black", outline_thickness=1, ) + self._text_vmax.world_object.material.pick_write = False + widget_wo = Group() widget_wo.add( - self.line.world_object, - self.linear_region.world_object, + self._histogram_line.world_object, + self._linear_region_selector.world_object, self._text_vmin.world_object, self._text_vmax.world_object, ) @@ -99,19 +106,17 @@ def __init__( 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_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) - self._text_vmax.position_x = -120 - self._text_vmax.position_y = self.linear_region.selection()[1] + self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) - self.linear_region.selection.add_event_handler( - self._linear_region_handler + self._linear_region_selector.add_event_handler( + self._linear_region_handler, "selection" ) - self.image_graphic.cmap.add_event_handler(self._image_cmap_handler) + self.image_graphic.add_event_handler(self._image_cmap_handler, "vmin", "vmax") - def _get_vmin_vmax_str(self) -> Tuple[str, str]: + 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: @@ -124,10 +129,10 @@ def _get_vmin_vmax_str(self) -> Tuple[str, str]: return vmin_str, vmax_str - def _add_plot_area_hook(self, plot_area): + def _fpl_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._linear_region_selector._fpl_add_plot_area_hook(plot_area) + self._histogram_line._fpl_add_plot_area_hook(plot_area) self._plot_area.auto_scale() @@ -158,7 +163,7 @@ def _calculate_histogram(self, data): # 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())) + self._scale_factor: int = max(1, 100 * int(10 / np.ptp(data_ss))) edges = edges * self._scale_factor @@ -168,12 +173,16 @@ def _calculate_histogram(self, data): 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) + 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))) + 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 @@ -181,23 +190,20 @@ def _calculate_histogram(self, data): if edges_flanked.size > hist_scaled.size: # we don't care about accuracy here so if it's off by 1-2 bins that's fine - edges_flanked = edges_flanked[:hist_scaled.size] + edges_flanked = edges_flanked[: hist_scaled.size] 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() + selected_ixs = self._linear_region_selector.selection + vmin, vmax = selected_ixs[0], selected_ixs[1] 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) + setattr(self, ev.type, ev.info["value"]) @property def vmin(self) -> float: @@ -205,19 +211,24 @@ def vmin(self) -> float: @vmin.setter def vmin(self, value: float): - self._block_events(True) + self.image_graphic.block_events = True + self._linear_region_selector.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._linear_region_selector.selection = ( + value * self._scale_factor, + self._linear_region_selector.selection[1], + ) + self.image_graphic.vmin = value - self._block_events(False) + self.image_graphic.block_events = False + self._linear_region_selector.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.offset = (-120, self._linear_region_selector.selection[0], 0) self._text_vmin.text = vmin_str @property @@ -226,19 +237,25 @@ def vmax(self) -> float: @vmax.setter def vmax(self, value: float): - self._block_events(True) + self.image_graphic.block_events = True + self._linear_region_selector.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._linear_region_selector.selection = ( + self._linear_region_selector.selection[0], + value * self._scale_factor, + ) + + self.image_graphic.vmax = value - self._block_events(False) + self.image_graphic.block_events = False + self._linear_region_selector.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.offset = (-120, self._linear_region_selector.selection[1], 0) self._text_vmax.text = vmax_str def set_data(self, data, reset_vmin_vmax: bool = True): @@ -246,22 +263,24 @@ def set_data(self, data, reset_vmin_vmax: bool = True): line_data = np.column_stack([hist_scaled, edges_flanked]) - self.line.data = line_data + # set x and y vals + self._histogram_line.data[:, :2] = 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 + self._linear_region_selector.limits = limits + self._linear_region_selector.selection = bounds else: # don't change the current selection - self._block_events(True) - self.linear_region.limits = limits - self._block_events(False) + self.image_graphic.block_events = True + self._linear_region_selector.block_events = True + self._linear_region_selector.limits = limits + self.image_graphic.block_events = False + self._linear_region_selector.block_events = False self._data = weakref.proxy(data) @@ -279,16 +298,21 @@ def image_graphic(self, graphic): 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 - ) + if self._image_graphic is not None: + # cleanup events from current image graphic + self._image_graphic.remove_event_handler(self._image_cmap_handler) self._image_graphic = graphic - self.image_graphic.cmap.add_event_handler(self._image_cmap_handler) + self.image_graphic.add_event_handler(self._image_cmap_handler) + + def disconnect_image_graphic(self): + self._image_graphic.remove_event_handler(self._image_cmap_handler) + del self._image_graphic + # self._image_graphic = None - def _cleanup(self): - self.linear_region._cleanup() - del self.line - del self.linear_region + def _fpl_cleanup(self): + self._linear_region_selector._fpl_cleanup() + self._histogram_line._fpl_cleanup() + del self._histogram_line + del self._linear_region_selector diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index a3f6335c9..df9b46b55 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,30 +1,32 @@ -from typing import * +from typing import Any, Literal, Callable from warnings import warn import numpy as np - -from ..layouts import GridPlot +from ..layouts import Figure from ..graphics import ImageGraphic -from ..utils import calculate_gridshape +from ..utils import calculate_figure_shape 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 +# Number of dimensions that represent one image/one frame. For grayscale shape will be [x, y], i.e. 2 dims, for RGB(A) +# shape will be [x, y, c] where c is of size 3 (RGB) or 4 (RGBA) +IMAGE_DIM_COUNTS = {"gray": 2, "rgb": 3} +# Map boolean (indicating whether we use RGB or grayscale) to the string. Used to index RGB_DIM_MAP +RGB_BOOL_MAP = {False: "gray", True: "rgb"} -DEFAULT_DIMS_ORDER = { - 2: "xy", - 3: "txy", - 4: "tzxy", - 5: "tzcxy", +# Dimensions that can be scrolled from a given data array +SCROLLABLE_DIMS_ORDER = { + 0: "", + 1: "t", + 2: "tz", } +ALLOWED_SLIDER_DIMS = {0: "t", 1: "z"} + +ALLOWED_WINDOW_DIMS = {"t", "z"} + def _is_arraylike(obj) -> bool: """ @@ -40,6 +42,7 @@ def _is_arraylike(obj) -> bool: class _WindowFunctions: """Stores window function and window size""" + def __init__(self, image_widget, func: callable, window_size: int): self._image_widget = image_widget self._func = None @@ -96,11 +99,11 @@ def __repr__(self): class ImageWidget: @property - def gridplot(self) -> GridPlot: + def figure(self) -> Figure: """ - ``GridPlot`` instance within the `ImageWidget`. + ``Figure`` used by `ImageWidget`. """ - return self._gridplot + return self._figure @property def widget(self): @@ -110,17 +113,17 @@ def widget(self): return self._output @property - def managed_graphics(self) -> List[ImageGraphic]: + def managed_graphics(self) -> list[ImageGraphic]: """List of ``ImageWidget`` managed graphics.""" iw_managed = list() - for subplot in self.gridplot: + for subplot in self.figure: # empty subplots will not have any image widget data if len(subplot.graphics) > 0: iw_managed.append(subplot["image_widget_managed"]) return iw_managed @property - def cmap(self) -> List[str]: + def cmap(self) -> list[str]: cmaps = list() for g in self.managed_graphics: cmaps.append(g.cmap.name) @@ -128,10 +131,12 @@ def cmap(self) -> List[str]: return cmaps @cmap.setter - def cmap(self, names: Union[str, List[str]]): + def cmap(self, names: 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}") + 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( @@ -148,32 +153,35 @@ def cmap(self, names: Union[str, List[str]]): g.cmap = names @property - def data(self) -> List[np.ndarray]: + def data(self) -> list[np.ndarray]: """data currently displayed in the widget""" return self._data @property def ndim(self) -> int: - """number of dimensions in the image data displayed in the widget""" + """Number of dimensions of grayscale data displayed in the widget (it will be 1 more for RGB(A) data)""" return self._ndim @property - def dims_order(self) -> List[str]: - """dimension order of the data displayed in the widget""" - return self._dims_order + def n_scrollable_dims(self) -> list[int]: + """ + list indicating the number of dimenensions that are scrollable for each data array + All other dimensions are frame/image data, i.e. [x, y] or [x, y, c] + """ + return self._n_scrollable_dims @property - def sliders(self) -> Dict[str, Any]: + 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]: + def slider_dims(self) -> list[str]: """the dimensions that the sliders index""" return self._slider_dims @property - def current_index(self) -> Dict[str, int]: + def current_index(self) -> dict[str, int]: """ Get or set the current index @@ -189,8 +197,58 @@ def current_index(self) -> Dict[str, int]: """ return self._current_index + @property + def n_img_dims(self) -> list[int]: + """ + list indicating the number of dimensions that contain image/single frame data for each data array. + if 2: data are grayscale, i.e. [x, y] dims, if 3: data are [x, y, c] where c is RGB or RGBA, + this is the complement of `n_scrollable_dims` + """ + return self._n_img_dims + + def _get_n_scrollable_dims(self, curr_arr: np.ndarray, rgb: bool) -> list[int]: + """ + For a given ``array`` displayed in the ImageWidget, this function infers how many of the dimensions are + supported by sliders (aka scrollable). Ex: "xy" data has 0 scrollable dims, "txy" has 1, "tzxy" has 2. + + Parameters + ---------- + curr_arr: np.ndarray + np.ndarray or a list of array-like + + rgb: bool + True if we view this as RGB(A) and False if grayscale + + Returns + ------- + int + Number of scrollable dimensions for each ``array`` in the dataset. + """ + + n_img_dims = IMAGE_DIM_COUNTS[RGB_BOOL_MAP[rgb]] + # Make sure each image stack at least ``n_img_dims`` dimensions + if len(curr_arr.shape) < n_img_dims: + raise ValueError( + f"Your array has shape {curr_arr.shape} " + f"but you specified that each image in your array is {n_img_dims}D " + ) + + # If RGB(A), last dim must be 3 or 4 + if n_img_dims == 3: + if not (curr_arr.shape[-1] == 3 or curr_arr.shape[-1] == 4): + raise ValueError( + f"Expected size 3 or 4 for last dimension of RGB(A) array, got: {curr_arr.shape[-1]}." + ) + + n_scrollable_dims = len(curr_arr.shape) - n_img_dims + + if n_scrollable_dims not in SCROLLABLE_DIMS_ORDER.keys(): + raise ValueError(f"Array had shape {curr_arr.shape} which is not supported") + + return n_scrollable_dims + @current_index.setter - def current_index(self, index: Dict[str, int]): + def current_index(self, index: dict[str, int]): # ignore if output context has not been created yet if self.widget is None: return @@ -227,31 +285,31 @@ def current_index(self, index: Dict[str, int]): def __init__( self, - data: Union[np.ndarray, List[np.ndarray]], - dims_order: Union[str, Dict[int, str]] = None, - 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, - grid_shape: Tuple[int, int] = None, - names: List[str] = None, - grid_plot_kwargs: dict = None, + data: np.ndarray | list[np.ndarray], + window_funcs: dict[str, tuple[Callable, int]] = None, + frame_apply: Callable | dict[int, Callable] = None, + figure_shape: tuple[int, int] = None, + names: list[str] = None, + figure_kwargs: dict = None, histogram_widget: bool = True, - **kwargs, + rgb: list[bool] = None, + cmap: str = "plasma", + graphic_kwargs: dict = None, ): """ - A high level widget for displaying n-dimensional image data in conjunction with automatically generated - sliders for navigating through 1-2 selected dimensions within image data. - - Can display a single n-dimensional image array or a grid of n-dimensional images. + This widget facilitates high-level navigation through image stacks, which are arrays containing one or more + images. It includes sliders for key dimensions such as "t" (time) and "z", enabling users to smoothly navigate + through one or multiple image stacks simultaneously. - Default dimension orders: + Allowed dimensions orders for each image stack: Note that each has a an optional (c) channel which refers to + RGB(A) a channel. So this channel should be either 3 or 4. ======= ========== n_dims dims order ======= ========== - 2 "xy" - 3 "txy" - 4 "tzxy" + 2 "xy(c)" + 3 "txy(c)" + 4 "tzxy(c)" ======= ========== Parameters @@ -259,41 +317,28 @@ def __init__( data: Union[np.ndarray, List[np.ndarray] array-like or a list of array-like - dims_order: Optional[Union[str, Dict[np.ndarray, str]]] - | ``str`` or a dict mapping to indicate dimension order - | a single ``str`` if ``data`` is a single array, or a list of arrays with the same dimension order - | examples: ``"xyt"``, ``"tzxy"`` - | ``dict`` mapping of ``{array_index: axis_order}`` if specific arrays have a non-default axes order. - | "array_index" is the position of the corresponding array in the data list. - | examples: ``{array_index: "tzxy", another_array_index: "xytz"}`` - - slider_dims: Optional[Union[str, int, List[Union[str, int]]]] - | The dimensions for which to create a slider - | can be a single ``str`` such as **"t"**, **"z"** or a numerical ``int`` that indexes the desired dimension - | can also be a list of ``str`` or ``int`` if multiple sliders are desired for multiple dimensions - | examples: ``"t"``, ``["t", "z"]`` - - window_funcs: Dict[Union[int, str], int] - | average one or more dimensions using a given window - | if a slider exists for only one dimension this can be an ``int``. - | if multiple sliders exist, then it must be a `dict`` mapping in the form of: ``{dimension: window_size}`` - | dimension/axes can be specified using ``str`` such as "t", "z" etc. or ``int`` that indexes the dimension - | if window_size is not an odd number, adds 1 - | use ``None`` to disable averaging for a dimension, example: ``{"t": 5, "z": None}`` + window_funcs: dict[str, tuple[Callable, int]], i.e. {"t" or "z": (callable, int)} + | Apply function(s) with rolling windows along "t" and/or "z" dimensions of the `data` arrays. + | Pass a dict in the form: {dimension: (func, window_size)}, `func` must take a slice of the data array as + | the first argument and must take `axis` as a kwarg. + | Ex: mean along "t" dimension: {"t": (np.mean, 11)}, if `current_index` of "t" is 50, it will pass frames + | 45 to 55 to `np.mean` with `axis=0`. + | Ex: max along z dim: {"z": (np.max, 3)}, passes current, previous & next frame to `np.max` with `axis=1` frame_apply: Union[callable, Dict[int, callable]] - | apply a function to slices of the array before displaying the frame - | pass a single function or a dict of functions to apply to each array individually + | Apply function(s) to `data` arrays before to generate final 2D image that is displayed. + | Ex: apply a spatial gaussian filter + | Pass a single function or a dict of functions to apply to each array individually | examples: ``{array_index: to_grayscale}``, ``{0: to_grayscale, 2: threshold_img}`` | "array_index" is the position of the corresponding array in the data list. | if `window_funcs` is used, then this function is applied after `window_funcs` | this function must be a callable that returns a 2D array | example use case: converting an RGB frame from video to a 2D grayscale frame - grid_shape: Optional[Tuple[int, int]] - manually provide the shape for a gridplot, otherwise a square gridplot is approximated. + figure_shape: Optional[Tuple[int, int]] + manually provide the shape for the Figure, otherwise the number of rows and columns is estimated - grid_plot_kwargs: dict, optional + figure_kwargs: dict, optional passed to `GridPlot` names: Optional[str] @@ -302,40 +347,74 @@ def __init__( histogram_widget: bool, default False make histogram LUT widget for each subplot - kwargs: Any - passed to fastplotlib.graphics.Image + rgb: bool | list[bool], default None + Includes a True or False for each ``array`` in the ImageWidget, indicating whether images are displayed as + grayscale or RGB(A). - """ + graphic_kwargs: Any + passed to each ImageGraphic in the ImageWidget figure subplots + """ self._names = None # output context self._output = None + if _is_arraylike(data): + data = [data] + if isinstance(data, list): # verify that it's a list of np.ndarray if all([_is_arraylike(d) for d in data]): - if grid_shape is None: - grid_shape = calculate_gridshape(len(data)) + # Grid computations + if figure_shape is None: + figure_shape = calculate_figure_shape(len(data)) - # verify that user-specified grid shape is large enough for the number of image arrays passed - elif grid_shape[0] * grid_shape[1] < len(data): - grid_shape = calculate_gridshape(len(data)) + # verify that user-specified figure shape is large enough for the number of image arrays passed + elif figure_shape[0] * figure_shape[1] < len(data): + figure_shape = calculate_figure_shape(len(data)) warn( - f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}" + f"Invalid `figure_shape` passed, setting figure shape to: {figure_shape}" ) - _ndim = [d.ndim for d in data] + self._data: list[np.ndarray] = data - # verify that all image arrays have same number of dimensions - # sliders get messy otherwise - if not len(set(_ndim)) == 1: + # Establish number of image dimensions and number of scrollable dimensions for each array + if rgb is None: + rgb = [False] * len(self.data) + if rgb is bool: + rgb = [rgb] + if not isinstance(rgb, list): + raise TypeError( + f"rgb_disp parameter must be a list, a {type(rgb)} was provided" + ) + if not len(rgb) == len(self.data): raise ValueError( - f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" + f"rgb had length {len(rgb)} but there are {len(self.data)} data arrays; these must be equal" ) - self._data: List[np.ndarray] = data - self._ndim = self.data[0].ndim # all ndim must be same + self._rgb = rgb + + self._n_img_dims = [ + IMAGE_DIM_COUNTS[RGB_BOOL_MAP[self._rgb[i]]] + for i in range(len(self.data)) + ] + + self._n_scrollable_dims = [ + self._get_n_scrollable_dims(self.data[i], self._rgb[i]) + for i in range(len(self.data)) + ] + + # Define ndim of ImageWidget instance as largest number of scrollable dims + 2 (grayscale dimensions) + self._ndim = ( + max( + [ + self.n_scrollable_dims[i] + for i in range(len(self.n_scrollable_dims)) + ] + ) + + IMAGE_DIM_COUNTS[RGB_BOOL_MAP[False]] + ) if names is not None: if not all([isinstance(n, str) for n in names]): @@ -356,165 +435,29 @@ def __init__( f"You have passed the following types:\n" f"{[type(a) for a in data]}" ) - - elif _is_arraylike(data): - self._data = [data] - self._ndim = self.data[0].ndim - - grid_shape = calculate_gridshape(len(self._data)) else: raise TypeError( - f"`data` must be an array-like type representing an n-dimensional image " - f"or a list of array-like representing a grid of n-dimensional images. " + f"`data` must be an array-like type or a list of array-like." f"You have passed the following type {type(data)}" ) - # default dims order if not passed - # updated later if passed - self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) - - if dims_order is not None: - if isinstance(dims_order, str): - dims_order = dims_order.lower() - if len(dims_order) != self.ndim: - raise ValueError( - f"number of dims '{len(dims_order)} passed to `dims_order` " - f"does not match ndim '{self.ndim}' of data" - ) - self._dims_order: List[str] = [dims_order] * len(self.data) - elif isinstance(dims_order, dict): - self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len( - self.data - ) - - # dict of {array_ix: dims_order_str} - for data_ix in list(dims_order.keys()): - if not isinstance(data_ix, int): - raise TypeError("`dims_order` dict keys must be ") - if len(dims_order[data_ix]) != self.ndim: - raise ValueError( - f"number of dims '{len(dims_order)} passed to `dims_order` " - f"does not match ndim '{self.ndim}' of data" - ) - _do = dims_order[data_ix].lower() - # make sure the same dims are present - if not set(_do) == set(DEFAULT_DIMS_ORDER[self.ndim]): - raise ValueError( - f"Invalid `dims_order` passed for one of your arrays, " - f"valid `dims_order` for given number of dimensions " - f"can only contain the following characters: " - f"{DEFAULT_DIMS_ORDER[self.ndim]}" - ) - try: - self.dims_order[data_ix] = _do - except Exception: - raise IndexError( - f"index {data_ix} out of bounds for `dims_order`, the bounds are 0 - {len(self.data)}" - ) - else: - raise TypeError( - f"`dims_order` must be a or , you have passed a: <{type(dims_order)}>" - ) - - if not len(self.dims_order[0]) == self.ndim: - raise ValueError( - f"Number of dims specified by `dims_order`: {len(self.dims_order[0])} does not" - f" match number of dimensions in the `data`: {self.ndim}" - ) - - ao = np.array([sorted(v) for v in self.dims_order]) - - if not np.all(ao == ao[0]): - raise ValueError( - f"`dims_order` for all arrays must contain the same combination of dimensions, your `dims_order` are: " - f"{self.dims_order}" - ) - - # if slider_dims not provided - if slider_dims is None: - # by default sliders are made for all dimensions except the last 2 - default_dim_names = {0: "t", 1: "z", 2: "c"} - slider_dims = list() - for dim in range(self.ndim - 2): - if dim in default_dim_names.keys(): - slider_dims.append(default_dim_names[dim]) - else: - slider_dims.append(f"{dim}") - - # slider for only one of the dimensions - if isinstance(slider_dims, (int, str)): - # if numerical dimension is specified - if isinstance(slider_dims, int): - ao = np.array([v for v in self.dims_order]) - if not np.all(ao == ao[0]): - raise ValueError( - f"`dims_order` for all arrays must be identical if passing in a `slider_dims` argument. " - f"Pass in a argument if the `dims_order` are different for each array." - ) - self._slider_dims: List[str] = [self.dims_order[0][slider_dims]] - - # if dimension specified by str - elif isinstance(slider_dims, str): - if slider_dims not in self.dims_order[0]: - raise ValueError( - f"if `slider_dims` is a , it must be a character found in `dims_order`. " - f"Your `dims_order` characters are: {set(self.dims_order[0])}." - ) - self._slider_dims: List[str] = [slider_dims] - - # multiple sliders, one for each dimension - elif isinstance(slider_dims, list): - self._slider_dims: List[str] = list() - - # make sure window_funcs and frame_apply are dicts if multiple sliders are desired - if (not isinstance(window_funcs, dict)) and (window_funcs is not None): - raise TypeError( - f"`window_funcs` must be a if multiple `slider_dims` are provided. You must specify the " - f"window for each dimension." - ) - if (not isinstance(frame_apply, dict)) and (frame_apply is not None): - raise TypeError( - f"`frame_apply` must be a if multiple `slider_dims` are provided. You must specify a " - f"function for each dimension." - ) - - for sdm in slider_dims: - if isinstance(sdm, int): - ao = np.array([v for v in self.dims_order]) - if not np.all(ao == ao[0]): - raise ValueError( - f"`dims_order` for all arrays must be identical if passing in a `slider_dims` argument. " - f"Pass in a argument if the `dims_order` are different for each array." - ) - # parse int to a str - self.slider_dims.append(self.dims_order[0][sdm]) - - elif isinstance(sdm, str): - if sdm not in self.dims_order[0]: - raise ValueError( - f"if `slider_dims` is a , it must be a character found in `dims_order`. " - f"Your `dims_order` characters are: {set(self.dims_order[0])}." - ) - self.slider_dims.append(sdm) - - else: - raise TypeError( - "If passing a list for `slider_dims` each element must be either an or " - ) - - else: - raise TypeError( - f"`slider_dims` must a , or , you have passed a: {type(slider_dims)}" - ) + # Sliders are made for all dimensions except the image dimensions + self._slider_dims = list() + max_scrollable = max( + [self.n_scrollable_dims[i] for i in range(len(self.n_scrollable_dims))] + ) + for dim in range(max_scrollable): + if dim in ALLOWED_SLIDER_DIMS.keys(): + self.slider_dims.append(ALLOWED_SLIDER_DIMS[dim]) - self._frame_apply: Dict[int, callable] = dict() + self._frame_apply: dict[int, callable] = dict() if frame_apply is not None: if callable(frame_apply): - self._frame_apply = {0: frame_apply} + self._frame_apply = frame_apply elif isinstance(frame_apply, dict): - self._frame_apply: Dict[int, callable] = dict.fromkeys( + self._frame_apply: dict[int, callable] = dict.fromkeys( list(range(len(self.data))) ) @@ -535,32 +478,44 @@ def __init__( ) # 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._current_index: dict[str, int] = {sax: 0 for sax in self.slider_dims} self._window_funcs = None self.window_funcs = window_funcs - self._sliders: Dict[str, Any] = dict() + self._sliders: dict[str, Any] = dict() - # 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()): - for array, order in zip(self.data, self.dims_order): - self._dims_max_bounds[_dim] = min( - self._dims_max_bounds[_dim], array.shape[order.index(_dim)] - ) + # get max bound for all data arrays for all slider dimensions and ensure compatibility across slider dims + self._dims_max_bounds: dict[str, int] = {k: 0 for k in self.slider_dims} + for i, _dim in enumerate(list(self._dims_max_bounds.keys())): + for array, partition in zip(self.data, self.n_scrollable_dims): + if partition <= i: + continue + else: + if 0 < self._dims_max_bounds[_dim] != array.shape[i]: + raise ValueError(f"Two arrays differ along dimension {_dim}") + else: + self._dims_max_bounds[_dim] = max( + self._dims_max_bounds[_dim], array.shape[i] + ) - grid_plot_kwargs_default = {"controller_ids": "sync"} - if grid_plot_kwargs is None: - grid_plot_kwargs = dict() + figure_kwargs_default = {"controller_ids": "sync"} + if figure_kwargs is None: + figure_kwargs = dict() # update the default kwargs with any user-specified kwargs # user specified kwargs will overwrite the defaults - grid_plot_kwargs_default.update(grid_plot_kwargs) + figure_kwargs_default.update(figure_kwargs) + + if graphic_kwargs is None: + graphic_kwargs = dict() + + graphic_kwargs.update({"cmap": cmap}) - self._gridplot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs_default) + self._figure: Figure = Figure(shape=figure_shape, **figure_kwargs_default) - for data_ix, (d, subplot) in enumerate(zip(self.data, self.gridplot)): + self._histogram_widget = histogram_widget + for data_ix, (d, subplot) in enumerate(zip(self.data, self.figure)): if self._names is not None: name = self._names[data_ix] else: @@ -568,17 +523,13 @@ def __init__( frame = self._process_indices(d, slice_indices=self._current_index) frame = self._process_frame_apply(frame, data_ix) - ig = ImageGraphic(frame, name="image_widget_managed", **kwargs) + ig = ImageGraphic(frame, name="image_widget_managed", **graphic_kwargs) subplot.add_graphic(ig) subplot.name = name subplot.set_title(name) - if histogram_widget: - hlut = HistogramLUT( - data=d, - image_graphic=ig, - name="histogram_lut" - ) + if self._histogram_widget: + hlut = HistogramLUT(data=d, image_graphic=ig, name="histogram_lut") subplot.docks["right"].add_graphic(hlut) subplot.docks["right"].size = 80 @@ -589,20 +540,20 @@ def __init__( self._image_widget_toolbar = None @property - def frame_apply(self) -> Union[dict, None]: + def frame_apply(self) -> dict | None: return self._frame_apply @frame_apply.setter - def frame_apply(self, frame_apply: Dict[int, callable]): + def frame_apply(self, frame_apply: dict[int, callable]): if frame_apply is None: frame_apply = dict() - + self._frame_apply = frame_apply # force update image graphic self.current_index = self.current_index @property - def window_funcs(self) -> Dict[str, _WindowFunctions]: + def window_funcs(self) -> dict[str, _WindowFunctions]: """ Get or set the window functions @@ -614,69 +565,61 @@ def window_funcs(self) -> Dict[str, _WindowFunctions]: return self._window_funcs @window_funcs.setter - def window_funcs(self, sa: Union[int, Dict[str, int]]): - if sa is None: + def window_funcs(self, callable_dict: dict[str, int]): + if callable_dict is None: self._window_funcs = None # force frame to update self.current_index = self.current_index return - # for a single dim - elif isinstance(sa, tuple): - if len(self.slider_dims) > 1: - raise TypeError( - "Must pass dict argument to window_funcs if using multiple sliders. See the docstring." - ) - if not callable(sa[0]) or not isinstance(sa[1], int): - raise TypeError( - "Tuple argument to `window_funcs` must be in the form of (func, window_size). See the docstring." + elif isinstance(callable_dict, dict): + if not set(callable_dict.keys()).issubset(ALLOWED_WINDOW_DIMS): + raise ValueError( + f"The only allowed keys to window funcs are {list(ALLOWED_WINDOW_DIMS)} " + f"Your window func passed in these keys: {list(callable_dict.keys())}" ) - - dim_str = self.slider_dims[0] - self._window_funcs = dict() - self._window_funcs[dim_str] = _WindowFunctions(self, *sa) - - # for multiple dims - elif isinstance(sa, dict): if not all( - [isinstance(_sa, tuple) or (_sa is None) for _sa in sa.values()] + [ + isinstance(_callable_dict, tuple) + for _callable_dict in callable_dict.values() + ] ): raise TypeError( "dict argument to `window_funcs` must be in the form of: " "`{dimension: (func, window_size)}`. " "See the docstring." ) - for v in sa.values(): - if v is not None: - if not callable(v[0]) or not ( - isinstance(v[1], int) or v[1] is None - ): - raise TypeError( - "dict argument to `window_funcs` must be in the form of: " - "`{dimension: (func, window_size)}`. " - "See the docstring." - ) + for v in callable_dict.values(): + if not callable(v[0]): + raise TypeError( + "dict argument to `window_funcs` must be in the form of: " + "`{dimension: (func, window_size)}`. " + "See the docstring." + ) + if not isinstance(v[1], int): + raise TypeError( + f"dict argument to `window_funcs` must be in the form of: " + "`{dimension: (func, window_size)}`. " + f"where window_size is integer. you passed in {v[1]} for window_size" + ) if not isinstance(self._window_funcs, dict): self._window_funcs = dict() - for k in list(sa.keys()): - if sa[k] is None: - self._window_funcs[k] = None - else: - self._window_funcs[k] = _WindowFunctions(self, *sa[k]) + for k in list(callable_dict.keys()): + self._window_funcs[k] = _WindowFunctions(self, *callable_dict[k]) else: raise TypeError( - f"`window_funcs` must be of type `int` if using a single slider or a dict if using multiple sliders. " - f"You have passed a {type(sa)}. See the docstring." + f"`window_funcs` must be either Nonetype or dict." + f"You have passed a {type(callable_dict)}. See the docstring." ) # force frame to update self.current_index = self.current_index def _process_indices( - self, array: np.ndarray, slice_indices: Dict[Union[int, str], int] + self, array: np.ndarray, slice_indices: dict[str, int] ) -> np.ndarray: """ Get the 2D array from the given slice indices. If not returning a 2D slice (such as due to window_funcs) @@ -687,11 +630,11 @@ def _process_indices( array: np.ndarray array-like to get a 2D slice from - slice_indices: Dict[int, int] - dict in form of {dimension_index: slice_index} + slice_indices: Dict[str, int] + dict in form of {dimension_index: current_index} For example if an array has shape [1000, 30, 512, 512] corresponding to [t, z, x, y]: To get the 100th timepoint and 3rd z-plane pass: - {"t": 100, "z": 3}, or {0: 100, 1: 3} + {"t": 100, "z": 3} Returns ------- @@ -699,26 +642,32 @@ def _process_indices( array-like, 2D slice """ - indexer = [slice(None)] * self.ndim + + data_ix = None + for i in range(len(self.data)): + if self.data[i] is array: + data_ix = i + break numerical_dims = list() + + # Totally number of dimensions for this specific array + curr_ndim = self.data[data_ix].ndim + + # Initialize slices for each dimension of array + indexer = [slice(None)] * curr_ndim + + # Maps from n_scrollable_dims to one of "", "t", "tz", etc. + curr_scrollable_format = SCROLLABLE_DIMS_ORDER[self.n_scrollable_dims[data_ix]] for dim in list(slice_indices.keys()): - if isinstance(dim, str): - data_ix = None - for i in range(len(self.data)): - if self.data[i] is array: - data_ix = i - break - if data_ix is None: - raise ValueError(f"Given `array` not found in `self.data`") - # get axes order for that specific array - numerical_dim = self.dims_order[data_ix].index(dim) - else: - numerical_dim = dim + if dim not in curr_scrollable_format: + continue + # get axes order for that specific array + numerical_dim = curr_scrollable_format.index(dim) indices_dim = slice_indices[dim] - # takes care of averaging if it was specified + # takes care of index selection (window slicing) for this specific axis indices_dim = self._get_window_indices(data_ix, numerical_dim, indices_dim) # set the indices for this dimension @@ -731,9 +680,9 @@ def _process_indices( if self.window_funcs is not None: a = array for i, dim in enumerate(sorted(numerical_dims)): - dim_str = self.dims_order[data_ix][dim] + dim_str = curr_scrollable_format[dim] dim = dim - i # since we loose a dimension every iteration - _indexer = [slice(None)] * (self.ndim - i) + _indexer = [slice(None)] * (curr_ndim - i) _indexer[dim] = indexer[dim + i] # if the indexer is an int, this dim has no window func @@ -744,7 +693,6 @@ def _process_indices( func = self.window_funcs[dim_str].func window = a[tuple(_indexer)] a = func(window, axis=dim) - # a = np.mean(a[tuple(_indexer)], axis=dim) return a else: return array[tuple(indexer)] @@ -756,7 +704,7 @@ def _get_window_indices(self, data_ix, dim, indices_dim): else: ix = indices_dim - dim_str = self.dims_order[data_ix][dim] + dim_str = SCROLLABLE_DIMS_ORDER[self.n_scrollable_dims[data_ix]][dim] # if no window stuff specified for this dim if dim_str not in self.window_funcs.keys(): @@ -792,7 +740,7 @@ def _process_frame_apply(self, array, data_ix) -> np.ndarray: return array - def _slider_value_changed(self, dimension: str, change: Union[dict, int]): + def _slider_value_changed(self, dimension: str, change: dict | int): if self.block_sliders: return if isinstance(change, dict): @@ -806,7 +754,7 @@ def reset_vmin_vmax(self): Reset the vmin and vmax w.r.t. the full data """ for ig in self.managed_graphics: - ig.cmap.reset_vmin_vmax() + ig.reset_vmin_vmax() def reset_vmin_vmax_frame(self): """ @@ -818,17 +766,17 @@ def reset_vmin_vmax_frame(self): TODO: We could think of applying the frame_apply funcs to a subsample of the entire array to get a better estimate of vmin vmax? """ - for subplot in self.gridplot: + for subplot in self.figure: if "histogram_lut" not in subplot.docks["right"]: continue hlut = subplot.docks["right"]["histogram_lut"] # set the data using the current image graphic data - hlut.set_data(subplot["image_widget_managed"].data()) + hlut.set_data(subplot["image_widget_managed"].data.value) def set_data( self, - new_data: Union[np.ndarray, List[np.ndarray]], + new_data: np.ndarray | list[np.ndarray], reset_vmin_vmax: bool = True, reset_indices: bool = True, ): @@ -855,9 +803,11 @@ def set_data( self.sliders[key].value = 0 # set slider max according to new data - max_lengths = {"t": np.inf, "z": np.inf} + max_lengths = dict() + for scroll_dim in self.slider_dims: + max_lengths[scroll_dim] = np.inf - if isinstance(new_data, np.ndarray): + if _is_arraylike(new_data): new_data = [new_data] if len(self._data) != len(new_data): @@ -873,35 +823,59 @@ def set_data( f"does not equal current data ndim {current_array.ndim}" ) + # Computes the number of scrollable dims and also validates new_array + new_scrollable_dims = self._get_n_scrollable_dims(new_array, self._rgb[i]) + + if self.n_scrollable_dims[i] != new_scrollable_dims: + raise ValueError( + f"number of dimensions of data arrays must match number of dimensions of " + f"existing data arrays" + ) + # if checks pass, update with new data for i, (new_array, current_array, subplot) in enumerate( - zip(new_data, self._data, self.gridplot) + zip(new_data, self._data, self.figure) ): # check last two dims (x and y) to see if data shape is changing - old_data_shape = self._data[i].shape[-2:] + old_data_shape = self._data[i].shape[-self.n_img_dims[i] :] self._data[i] = new_array - if old_data_shape != new_array.shape[-2:]: - # delete graphics at index zero - subplot.delete_graphic(graphic=subplot["image_widget_managed"]) - # insert new graphic at index zero + if old_data_shape != new_array.shape[-self.n_img_dims[i] :]: frame = self._process_indices( new_array, slice_indices=self._current_index ) frame = self._process_frame_apply(frame, i) + + # make new graphic first new_graphic = ImageGraphic(data=frame, name="image_widget_managed") - subplot.insert_graphic(graphic=new_graphic) - subplot.docks["right"]["histogram_lut"].image_graphic = new_graphic - if new_array.ndim > 2: - # to set max of time slider, txy or tzxy - max_lengths["t"] = min(max_lengths["t"], new_array.shape[0] - 1) + # set hlut tool to use new graphic + subplot.docks["right"]["histogram_lut"].image_graphic = new_graphic + # delete old graphic after setting hlut tool to new graphic + # this ensures gc + subplot.delete_graphic(graphic=subplot["image_widget_managed"]) + subplot.insert_graphic(graphic=new_graphic) - if new_array.ndim > 3: # tzxy - max_lengths["z"] = min(max_lengths["z"], new_array.shape[1] - 1) + # Returns "", "t", or "tz" + curr_scrollable_format = SCROLLABLE_DIMS_ORDER[self.n_scrollable_dims[i]] + + for scroll_dim in self.slider_dims: + if scroll_dim in curr_scrollable_format: + new_length = new_array.shape[ + curr_scrollable_format.index(scroll_dim) + ] + if max_lengths[scroll_dim] == np.inf: + max_lengths[scroll_dim] = new_length + elif max_lengths[scroll_dim] != new_length: + raise ValueError( + f"New arrays have differing values along dim {scroll_dim}" + ) # set histogram widget - subplot.docks["right"]["histogram_lut"].set_data(new_array, reset_vmin_vmax=reset_vmin_vmax) + if self._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 @@ -912,7 +886,9 @@ def set_data( # force graphics to update self.current_index = self.current_index - def show(self, toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None): + def show( + self, toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None + ): """ Show the widget. @@ -921,21 +897,25 @@ def show(self, toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict OutputContext ImageWidget just uses the Gridplot output context """ - if self.gridplot.canvas.__class__.__name__ == "JupyterWgpuCanvas": + if self.figure.canvas.__class__.__name__ == "JupyterWgpuCanvas": + from ._image_widget_ipywidget_toolbar import IpywidgetImageWidgetToolbar + self._image_widget_toolbar = IpywidgetImageWidgetToolbar(self) - elif self.gridplot.canvas.__class__.__name__ == "QWgpuCanvas": + elif self.figure.canvas.__class__.__name__ == "QWgpuCanvas": + from ._image_widget_qt_toolbar import QToolbarImageWidget + self._image_widget_toolbar = QToolbarImageWidget(self) - self._output = self.gridplot.show( + self._output = self.figure.show( toolbar=toolbar, sidecar=sidecar, sidecar_kwargs=sidecar_kwargs, - add_widgets=[self._image_widget_toolbar] + add_widgets=[self._image_widget_toolbar], ) return self._output def close(self): """Close Widget""" - self.gridplot.close() + self.figure.close() diff --git a/fastplotlib/utils/generate_add_methods.py b/scripts/generate_add_graphic_methods.py similarity index 52% rename from fastplotlib/utils/generate_add_methods.py rename to scripts/generate_add_graphic_methods.py index 3fe16260c..3f45d9007 100644 --- a/fastplotlib/utils/generate_add_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -1,15 +1,16 @@ import inspect import pathlib +import black + +root = pathlib.Path(__file__).parent.parent.resolve() +filename = root.joinpath("fastplotlib", "layouts", "_graphic_methods_mixin.py") + # if there is an existing mixin class, replace it with an empty class # so that fastplotlib will import # hacky but it works -current_module = pathlib.Path(__file__).parent.parent.resolve() -with open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w') as f: - f.write( - f"class GraphicMethodsMixin:\n" - f" pass" - ) +with open(filename, "w") as f: + f.write(f"class GraphicMethodsMixin:\n" f" pass") from fastplotlib import graphics @@ -23,22 +24,23 @@ def generate_add_graphics_methods(): # clear file and regenerate from scratch + f = open(filename, "w", encoding="utf-8") - f = open(current_module.joinpath('layouts/graphic_methods_mixin.py'), 'w') - - f.write('# This is an auto-generated file and should not be modified directly\n\n') + f.write("# This is an auto-generated file and should not be modified directly\n\n") - f.write('from typing import *\n\n') - f.write('import numpy\n') - f.write('import weakref\n\n') - f.write('from ..graphics import *\n') - f.write('from ..graphics._base import Graphic\n\n') + f.write("from typing import *\n\n") + f.write("import numpy\n") + f.write("import weakref\n\n") + f.write("from ..graphics import *\n") + f.write("from ..graphics._base import Graphic\n\n") f.write("\nclass GraphicMethodsMixin:\n") f.write(" def __init__(self):\n") f.write(" pass\n\n") - f.write(" def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:\n") + f.write( + " def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:\n" + ) f.write(" if 'center' in kwargs.keys():\n") f.write(" center = kwargs.pop('center')\n") f.write(" else:\n") @@ -55,19 +57,35 @@ def generate_add_graphics_methods(): method_name = class_name.type class_args = inspect.getfullargspec(class_name)[0][1:] - class_args = [arg + ', ' for arg in class_args] + class_args = [arg + ", " for arg in class_args] s = "" for a in class_args: s += a - f.write(f" def add_{method_name}{inspect.signature(class_name.__init__)} -> {class_name.__name__}:\n") + f.write( + f" def add_{method_name}{inspect.signature(class_name.__init__)} -> {class_name.__name__}:\n" + ) f.write(' """\n') - f.write(f' {class_name.__init__.__doc__}\n') + f.write(f" {class_name.__init__.__doc__}\n") f.write(' """\n') - f.write(f" return self._create_graphic({class_name.__name__}, {s}*args, **kwargs)\n\n") + f.write( + f" return self._create_graphic({class_name.__name__}, {s} **kwargs)\n\n" + ) f.close() -if __name__ == '__main__': +def blacken(): + with open(filename, "r", encoding="utf-8") as f: + text = f.read() + + mode = black.FileMode(line_length=88) + text = black.format_str(text, mode=mode) + + with open(filename, "w", encoding="utf-8") as f: + f.write(text) + + +if __name__ == "__main__": generate_add_graphics_methods() + blacken() diff --git a/setup.py b/setup.py index 8e8977b57..7229dcf25 100644 --- a/setup.py +++ b/setup.py @@ -4,37 +4,38 @@ install_requires = [ "numpy>=1.23.0", - "pygfx>=0.1.14", + "wgpu<0.16.0", + "pygfx>=0.1.14,<=0.2.0", ] extras_require = { "docs": [ "sphinx", + "sphinx-gallery", "furo", "glfw", "jupyter-rfb>=0.4.1", # required so ImageWidget docs show up "ipywidgets>=8.0.0,<9", "sphinx-copybutton", "sphinx-design", - "nbsphinx", "pandoc", "jupyterlab", - "sidecar" + "sidecar", + "imageio", + "matplotlib", + "scikit-learn" ], - - "notebook": - [ + "notebook": [ "jupyterlab", "jupyter-rfb>=0.4.1", "ipywidgets>=8.0.0,<9", - "sidecar" + "sidecar", ], - - "tests": - [ - "pytest", + "tests": [ + "pytest<8.0.0", "nbmake", + "black", "scipy", "imageio[pyav]", "jupyterlab", @@ -42,17 +43,15 @@ "ipywidgets>=8.0.0,<9", "scikit-learn", "tqdm", - "sidecar" + "sidecar", ], - - "tests-desktop": - [ - "pytest", + "tests-desktop": [ + "pytest<8.0.0", "scipy", "imageio", "scikit-learn", "tqdm", - ] + ], } @@ -72,19 +71,18 @@ setup( - name='fastplotlib', + name="fastplotlib", version=ver, long_description=readme, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", packages=find_packages(), - url='https://github.com/fastplotlib/fastplotlib', - license='Apache 2.0', - author='Kushal Kolar, Caitlin Lewis', - author_email='', - python_requires='>=3.9', + url="https://github.com/fastplotlib/fastplotlib", + license="Apache 2.0", + author="Kushal Kolar, Caitlin Lewis", + author_email="", + python_requires=">=3.10", install_requires=install_requires, extras_require=extras_require, include_package_data=True, - description='A fast plotting library built using the pygfx render engine' + description="A fast plotting library built using the pygfx render engine", ) - diff --git a/examples/desktop/image/__init__.py b/tests/__init__.py similarity index 100% rename from examples/desktop/image/__init__.py rename to tests/__init__.py diff --git a/tests/events.py b/tests/events.py new file mode 100644 index 000000000..ea160dec3 --- /dev/null +++ b/tests/events.py @@ -0,0 +1,91 @@ +from functools import partial +import pytest +import numpy as np +from numpy import testing as npt +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent + + +def make_positions_data() -> np.ndarray: + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + return np.column_stack([xs, ys]) + + +def make_line_graphic() -> fpl.LineGraphic: + return fpl.LineGraphic(make_positions_data()) + + +def make_scatter_graphic() -> fpl.ScatterGraphic: + return fpl.ScatterGraphic(make_positions_data()) + + +event_instance: FeatureEvent = None + + +def event_handler(event): + global event_instance + event_instance = event + + +decorated_event_instance: FeatureEvent = None + + +@pytest.mark.parametrize("graphic", [make_line_graphic(), make_scatter_graphic()]) +def test_positions_data_event(graphic: fpl.LineGraphic | fpl.ScatterGraphic): + global decorated_event_instance + global event_instance + + value = np.cos(np.linspace(0, 10 * np.pi, 10))[3:8] + + info = {"key": (slice(3, 8, None), 1), "value": value} + + expected = FeatureEvent(type="data", info=info) + + def validate(graphic, handler, expected_feature_event, event_to_test): + assert expected_feature_event.type == event_to_test.type + assert expected_feature_event.info["key"] == event_to_test.info["key"] + + npt.assert_almost_equal( + expected_feature_event.info["value"], event_to_test.info["value"] + ) + + # should only have one event handler + assert graphic._event_handlers["data"] == {handler} + + # make sure wrappers are correct + wrapper_map = tuple(graphic._event_handler_wrappers["data"])[0] + assert wrapper_map[0] is handler + assert isinstance(wrapper_map[1], partial) + assert wrapper_map[1].func == graphic._handle_event + assert wrapper_map[1].args[0] is handler + + # test remove handler + graphic.remove_event_handler(handler, "data") + assert len(graphic._event_handlers["click"]) == 0 + assert len(graphic._event_handler_wrappers["click"]) == 0 + assert len(graphic.world_object._event_handlers["click"]) == 0 + + # reset data + graphic.data[:, :-1] = make_positions_data() + event_to_test = None + + # test decorated function + @graphic.add_event_handler("data") + def decorated_handler(event): + global decorated_event_instance + decorated_event_instance = event + + # test decorated + graphic.data[3:8, 1] = value + validate(graphic, decorated_handler, expected, decorated_event_instance) + + # test regular + graphic.add_event_handler(event_handler, "data") + graphic.data[3:8, 1] = value + + validate(graphic, event_handler, expected, event_instance) + + event_instance = None diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py new file mode 100644 index 000000000..252c6e5c3 --- /dev/null +++ b/tests/test_colors_buffer_manager.py @@ -0,0 +1,250 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import VertexColors, FeatureEvent +from .utils import ( + generate_slice_indices, + assert_pending_uploads, + generate_color_inputs, + generate_positions_spiral_data, +) + + +def make_colors_buffer() -> VertexColors: + colors = VertexColors(colors="w", n_colors=10) + return colors + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +@pytest.mark.parametrize( + "color_input", + [ + *generate_color_inputs("r"), + *generate_color_inputs("g"), + *generate_color_inputs("b"), + ], +) +def test_create_buffer(color_input): + colors = VertexColors(colors=color_input, n_colors=10) + truth = np.repeat([pygfx.Color(color_input)], 10, axis=0) + npt.assert_almost_equal(colors[:], truth) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +def test_int(test_graphic): + # setting single points + if test_graphic: + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + colors = graphic.colors + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "colors") + else: + colors = make_colors_buffer() + + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + + colors[3] = "r" + npt.assert_almost_equal(colors[3], [1.0, 0.0, 0.0, 1.0]) + assert colors.buffer._gfx_pending_uploads[-1] == (3, 1) + + if test_graphic: + # test event + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == 3 + npt.assert_almost_equal( + EVENT_RETURN_VALUE.info["value"], np.array([[1, 0, 0, 1]]) + ) + assert EVENT_RETURN_VALUE.info["user_value"] == "r" + + colors[6] = [0.0, 1.0, 1.0, 1.0] + npt.assert_almost_equal(colors[6], [0.0, 1.0, 1.0, 1.0]) + + colors[7] = (0.0, 1.0, 1.0, 1.0) + npt.assert_almost_equal(colors[6], [0.0, 1.0, 1.0, 1.0]) + + colors[8] = np.array([1, 0, 1, 1]) + npt.assert_almost_equal(colors[8], [1.0, 0.0, 1.0, 1.0]) + + colors[2] = [1, 0, 1, 0.5] + npt.assert_almost_equal(colors[2], [1.0, 0.0, 1.0, 0.5]) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(0, 16)] +) +def test_tuple(test_graphic, slice_method): + # setting entire array manually + if test_graphic: + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + colors = graphic.colors + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "colors") + else: + colors = make_colors_buffer() + + s = slice_method["slice"] + indices = slice_method["indices"] + others = slice_method["others"] + + # set all RGBA vals + colors[s, :] = 0.5 + truth = np.repeat([[0.5, 0.5, 0.5, 0.5]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + + if test_graphic: + # test event + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == (s, slice(None)) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], truth) + assert EVENT_RETURN_VALUE.info["user_value"] == 0.5 + + # check others are not modified + others_truth = np.repeat([[1.0, 1.0, 1.0, 1.0]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + if test_graphic: + # test setter + graphic.colors = "w" + else: + colors[:] = [1, 1, 1, 1] + truth = np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0) + npt.assert_almost_equal(colors[:], truth) + + if test_graphic: + # test event + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == slice(None) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], truth) + assert EVENT_RETURN_VALUE.info["user_value"] == "w" + + # set just R values + colors[s, 0] = 0.5 + truth = np.repeat([[0.5, 1.0, 1.0, 1.0]], repeats=len(indices), axis=0) + # check others not modified + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0)) + + # set green and blue + colors[s, 1:-1] = 0.7 + truth = np.repeat([[1.0, 0.7, 0.7, 1.0]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0)) + + # set only alpha + colors[s, -1] = 0.2 + truth = np.repeat([[1.0, 1.0, 1.0, 0.2]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + +@pytest.mark.parametrize("color_input", generate_color_inputs("red")) +# skip testing with int since that results in shape [1, 4] with np.repeat, int tested in independent unit test +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(1, 16)] +) +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +def test_slice(color_input, slice_method: dict, test_graphic: bool): + # slicing only first dim + if test_graphic: + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + colors = graphic.colors + + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "colors") + else: + colors = make_colors_buffer() + + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + colors[s] = color_input + truth = np.repeat([pygfx.Color(color_input)], repeats=len(indices), axis=0) + # check that correct indices are modified + npt.assert_almost_equal(colors[s], truth) + npt.assert_almost_equal(colors[indices], truth) + + # check event + if test_graphic: + global EVENT_RETURN_VALUE + + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == s + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"], s) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], truth) + if isinstance(color_input, str): + assert EVENT_RETURN_VALUE.info["user_value"] == color_input + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["user_value"], color_input) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(colors.buffer, offset, size) + + # check that others are not touched + others_truth = np.repeat([[1.0, 1.0, 1.0, 1.0]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0)) diff --git a/tests/test_common_features.py b/tests/test_common_features.py new file mode 100644 index 000000000..332ac71ae --- /dev/null +++ b/tests/test_common_features.py @@ -0,0 +1,282 @@ +import numpy +import numpy as np +from numpy import testing as npt +import pytest + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent, Name, Offset, Rotation, Visible + + +def make_graphic(kind: str, **kwargs): + match kind: + case "image": + return fpl.ImageGraphic(np.random.rand(10, 10), **kwargs) + case "line": + return fpl.LineGraphic(np.random.rand(10), **kwargs) + case "scatter": + return fpl.ScatterGraphic( + np.column_stack([np.random.rand(10), np.random.rand(10)]), **kwargs + ) + case "text": + return fpl.TextGraphic("bah", **kwargs) + + +graphic_kinds = [ + "image", + "line", + "scatter", + "text", +] + + +RETURN_EVENT_VALUE: FeatureEvent = None +DECORATED_EVENT_VALUE: FeatureEvent = None + + +def return_event(ev: FeatureEvent): + global RETURN_EVENT_VALUE + RETURN_EVENT_VALUE = ev + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_name(graphic): + assert graphic.name is None + + graphic.add_event_handler(return_event, "name") + + graphic.name = "new_name" + + assert graphic.name == "new_name" + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "name" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + assert RETURN_EVENT_VALUE.info["value"] == "new_name" + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "name") + assert len(graphic._event_handlers["name"]) == 0 + + graphic.name = "new_name2" + + assert RETURN_EVENT_VALUE is None + assert graphic.name == "new_name2" + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("name") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.name = "test_dec" + assert graphic.name == "test_dec" + + assert DECORATED_EVENT_VALUE.type == "name" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] == "test_dec" + + +@pytest.mark.parametrize( + "graphic", [make_graphic(k, name="init_name") for k in graphic_kinds] +) +def test_name_init(graphic): + assert graphic.name == "init_name" + + graphic.name = "new_name" + + assert graphic.name == "new_name" + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_offset(graphic): + npt.assert_almost_equal(graphic.offset, (0.0, 0.0, 0.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (0.0, 0.0, 0.0)) + + graphic.add_event_handler(return_event, "offset") + + graphic.offset = (1.0, 2.0, 3.0) + + npt.assert_almost_equal(graphic.offset, (1.0, 2.0, 3.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (1.0, 2.0, 3.0)) + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "offset" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + npt.assert_almost_equal(RETURN_EVENT_VALUE.info["value"], (1.0, 2.0, 3.0)) + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "offset") + assert len(graphic._event_handlers["offset"]) == 0 + + graphic.offset = (4, 5, 6) + + assert RETURN_EVENT_VALUE is None + npt.assert_almost_equal(graphic.offset, (4.0, 5.0, 6.0)) + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("offset") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.offset = (7, 8, 9) + npt.assert_almost_equal(graphic.offset, (7.0, 8.0, 9.0)) + + assert DECORATED_EVENT_VALUE.type == "offset" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] == (7.0, 8.0, 9.0) + + +@pytest.mark.parametrize( + "graphic", [make_graphic(k, offset=(3.0, 4.0, 5.0)) for k in graphic_kinds] +) +def test_offset_init(graphic): + npt.assert_almost_equal(graphic.offset, (3.0, 4.0, 5.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (3.0, 4.0, 5.0)) + + graphic.offset = (6.0, 7.0, 8.0) + + npt.assert_almost_equal(graphic.offset, (6.0, 7.0, 8.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (6.0, 7.0, 8.0)) + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_rotation(graphic): + npt.assert_almost_equal(graphic.rotation, (0, 0, 0, 1)) + npt.assert_almost_equal(graphic.world_object.world.rotation, (0, 0, 0, 1)) + + graphic.add_event_handler(return_event, "rotation") + + graphic.rotation = (0.0, 0.0, 0.30001427, 0.95393471) + + npt.assert_almost_equal(graphic.rotation, (0.0, 0.0, 0.30001427, 0.95393471)) + npt.assert_almost_equal( + graphic.world_object.world.rotation, (0.0, 0.0, 0.30001427, 0.95393471) + ) + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "rotation" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + npt.assert_almost_equal( + RETURN_EVENT_VALUE.info["value"], (0.0, 0.0, 0.30001427, 0.95393471) + ) + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "rotation") + assert len(graphic._event_handlers["rotation"]) == 0 + + graphic.rotation = (0, 0, 0, 1) + + assert RETURN_EVENT_VALUE is None + npt.assert_almost_equal(graphic.rotation, (0, 0, 0, 1)) + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("rotation") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.rotation = (0, 0, 0.6, 0.8) + npt.assert_almost_equal(graphic.rotation, (0, 0, 0.6, 0.8)) + + assert DECORATED_EVENT_VALUE.type == "rotation" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] == (0, 0, 0.6, 0.8) + + +@pytest.mark.parametrize( + "graphic", + [ + make_graphic(k, rotation=(0.0, 0.0, 0.30001427, 0.95393471)) + for k in graphic_kinds + ], +) +def test_rotation(graphic): + npt.assert_almost_equal(graphic.rotation, (0.0, 0.0, 0.30001427, 0.95393471)) + npt.assert_almost_equal( + graphic.world_object.world.rotation, (0.0, 0.0, 0.30001427, 0.95393471) + ) + + graphic.rotation = (0, 0.0, 0.6, 0.8) + + npt.assert_almost_equal(graphic.rotation, (0, 0.0, 0.6, 0.8)) + npt.assert_almost_equal(graphic.world_object.world.rotation, (0, 0.0, 0.6, 0.8)) + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_visible(graphic): + assert graphic.visible is True + assert graphic.world_object.visible is True + + graphic.add_event_handler(return_event, "rotation") + + graphic.visible = False + assert graphic.visible is False + assert graphic.world_object.visible is False + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "visible" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + assert RETURN_EVENT_VALUE.info["value"] is False + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "visible") + assert len(graphic._event_handlers["visible"]) == 0 + + graphic.visible = True + + assert RETURN_EVENT_VALUE is None + assert graphic.visible is True + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("visible") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.visible = False + assert graphic.visible is False + + assert DECORATED_EVENT_VALUE.type == "visible" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] is False + + +@pytest.mark.parametrize( + "graphic", [make_graphic(k, visible=False) for k in graphic_kinds] +) +def test_visible(graphic): + assert graphic.visible is False + assert graphic.world_object.visible is False + + graphic.visible = True + assert graphic.visible is True + assert graphic.world_object.visible is True diff --git a/tests/test_figure.py b/tests/test_figure.py new file mode 100644 index 000000000..757b1eeae --- /dev/null +++ b/tests/test_figure.py @@ -0,0 +1,172 @@ +import numpy as np +import pytest + +import fastplotlib as fpl +import pygfx + + +def test_cameras_controller_properties(): + cameras = [["2d", "3d", "3d"], ["3d", "3d", "3d"]] + + controller_types = [ + ["panzoom", "panzoom", "fly"], + ["orbit", "trackball", "panzoom"], + ] + + fig = fpl.Figure( + shape=(2, 3), + cameras=cameras, + controller_types=controller_types, + canvas="offscreen", + ) + + print(fig.canvas) + + subplot_cameras = [subplot.camera for subplot in fig] + subplot_controllers = [subplot.controller for subplot in fig] + + for c1, c2 in zip(subplot_cameras, fig.cameras.ravel()): + assert c1 is c2 + + for c1, c2 in zip(subplot_controllers, fig.controllers.ravel()): + assert c1 is c2 + + for camera_type, subplot_camera in zip( + np.asarray(cameras).ravel(), fig.cameras.ravel() + ): + if camera_type == "2d": + assert subplot_camera.fov == 0 + else: + assert subplot_camera.fov == 50 + + for controller_type, subplot_controller in zip( + np.asarray(controller_types).ravel(), fig.controllers.ravel() + ): + match controller_type: + case "panzoom": + assert isinstance(subplot_controller, pygfx.PanZoomController) + case "fly": + assert isinstance(subplot_controller, pygfx.FlyController) + case "orbit": + assert isinstance(subplot_controller, pygfx.OrbitController) + case "trackball": + assert isinstance(subplot_controller, pygfx.TrackballController) + + # check changing cameras + fig[0, 0].camera = "3d" + assert fig[0, 0].camera.fov == 50 + fig[1, 0].camera = "2d" + assert fig[1, 0].camera.fov == 0 + + # test changing controller + fig[1, 1].controller = "fly" + assert isinstance(fig[1, 1].controller, pygfx.FlyController) + assert fig[1, 1].controller is fig.controllers[1, 1] + fig[0, 2].controller = "panzoom" + assert isinstance(fig[0, 2].controller, pygfx.PanZoomController) + assert fig[0, 2].controller is fig.controllers[0, 2] + + +def test_controller_ids_int(): + ids = [[0, 1, 1], [0, 2, 3], [4, 1, 2]] + + fig = fpl.Figure(shape=(3, 3), controller_ids=ids, canvas="offscreen") + + assert fig[0, 0].controller is fig[1, 0].controller + assert fig[0, 1].controller is fig[0, 2].controller is fig[2, 1].controller + assert fig[1, 1].controller is fig[2, 2].controller + + +def test_controller_ids_int_change_controllers(): + ids = [[0, 1, 1], [0, 2, 3], [4, 1, 2]] + + cameras = [["2d", "3d", "3d"], ["2d", "3d", "2d"], ["3d", "3d", "3d"]] + + fig = fpl.Figure( + shape=(3, 3), cameras=cameras, controller_ids=ids, canvas="offscreen" + ) + + assert isinstance(fig[0, 1].controller, pygfx.FlyController) + + # changing controller when id matches should change the others too + fig[0, 1].controller = "panzoom" + assert isinstance(fig[0, 1].controller, pygfx.PanZoomController) + assert fig[0, 1].controller is fig[0, 2].controller is fig[2, 1].controller + assert set(fig[0, 1].controller.cameras) == { + fig[0, 1].camera, + fig[0, 2].camera, + fig[2, 1].camera, + } + + # change to orbit + fig[0, 1].controller = "orbit" + assert isinstance(fig[0, 1].controller, pygfx.OrbitController) + assert fig[0, 1].controller is fig[0, 2].controller is fig[2, 1].controller + assert set(fig[0, 1].controller.cameras) == { + fig[0, 1].camera, + fig[0, 2].camera, + fig[2, 1].camera, + } + + +def test_controller_ids_str(): + names = [["a", "b", "c"], ["d", "e", "f"]] + + controller_ids = [["a", "f"], ["b", "d", "e"]] + + fig = fpl.Figure( + shape=(2, 3), controller_ids=controller_ids, names=names, canvas="offscreen" + ) + + assert ( + fig[0, 0].controller + is fig[1, 2].controller + is fig["a"].controller + is fig["f"].controller + ) + assert ( + fig[0, 1].controller + is fig[1, 0].controller + is fig[1, 1].controller + is fig["b"].controller + is fig["d"].controller + is fig["e"].controller + ) + + # make sure subplot c is unique + exclude_c = [fig[n].controller for n in ["a", "b", "d", "e", "f"]] + assert fig["c"] not in exclude_c + + +def test_set_controllers_from_existing_controllers(): + fig = fpl.Figure(shape=(3, 3), canvas="offscreen") + fig2 = fpl.Figure(shape=fig.shape, controllers=fig.controllers, canvas="offscreen") + + assert fig.controllers[:-1].size == 6 + with pytest.raises(ValueError): + fig3 = fpl.Figure( + shape=fig.shape, controllers=fig.controllers[:-1], canvas="offscreen" + ) + + for fig1_subplot, fig2_subplot in zip(fig, fig2): + assert fig1_subplot.controller is fig2_subplot.controller + + cameras = [[pygfx.PerspectiveCamera(), "3d"], ["3d", "2d"]] + + controllers = [ + [pygfx.FlyController(cameras[0][0]), pygfx.TrackballController()], + [pygfx.OrbitController(), pygfx.PanZoomController()], + ] + + fig = fpl.Figure( + shape=(2, 2), cameras=cameras, controllers=controllers, canvas="offscreen" + ) + + assert fig[0, 0].controller is controllers[0][0] + assert fig[0, 1].controller is controllers[0][1] + assert fig[1, 0].controller is controllers[1][0] + assert fig[1, 1].controller is controllers[1][1] + + assert fig[0, 0].camera is cameras[0][0] + + assert fig[0, 1].camera.fov == 50 diff --git a/tests/test_image_graphic.py b/tests/test_image_graphic.py new file mode 100644 index 000000000..541129079 --- /dev/null +++ b/tests/test_image_graphic.py @@ -0,0 +1,210 @@ +import numpy as np +from numpy import testing as npt +import imageio.v3 as iio + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent +from fastplotlib.utils import make_colors + +GRAY_IMAGE = iio.imread("imageio:camera.png") +RGB_IMAGE = iio.imread("imageio:astronaut.png") + + +COFFEE_IMAGE = iio.imread("imageio:coffee.png") + +# image cmap, vmin, vmax, interpolations +# new screenshot tests too for these when in graphics + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +def check_event(graphic, feature, value): + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.type == feature + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target == graphic.world_object + if isinstance(EVENT_RETURN_VALUE.info["value"], float): + # floating point error + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], value) + else: + assert EVENT_RETURN_VALUE.info["value"] == value + + +def check_set_slice( + data: np.ndarray, + image_graphic: fpl.ImageGraphic, + row_slice: slice, + col_slice: slice, +): + image_graphic.data[row_slice, col_slice] = 1 + data_values = image_graphic.data.value + npt.assert_almost_equal(data_values[row_slice, col_slice], 1) + + # make sure other vals unchanged + npt.assert_almost_equal(data_values[: row_slice.start], data[: row_slice.start]) + npt.assert_almost_equal(data_values[row_slice.stop :], data[row_slice.stop :]) + npt.assert_almost_equal( + data_values[:, : col_slice.start], data[:, : col_slice.start] + ) + npt.assert_almost_equal(data_values[:, col_slice.stop :], data[:, col_slice.stop :]) + + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.type == "data" + assert EVENT_RETURN_VALUE.graphic == image_graphic + assert EVENT_RETURN_VALUE.target == image_graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == (row_slice, col_slice) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], 1) + + +def test_gray(): + fig = fpl.Figure() + ig = fig[0, 0].add_image(GRAY_IMAGE) + assert isinstance(ig, fpl.ImageGraphic) + + ig.add_event_handler( + event_handler, + "data", + "cmap", + "vmin", + "vmax", + "interpolation", + "cmap_interpolation", + ) + + # make sure entire data is the same + npt.assert_almost_equal(ig.data.value, GRAY_IMAGE) + + # since this entire image is under the wgpu max texture limit, + # the entire image should be in the single Texture buffer + npt.assert_almost_equal(ig.data.buffer[0, 0].data, GRAY_IMAGE) + + ig.cmap = "viridis" + assert ig.cmap == "viridis" + check_event(graphic=ig, feature="cmap", value="viridis") + + new_colors = make_colors(256, "viridis") + for child in ig.world_object.children: + npt.assert_almost_equal(child.material.map.data, new_colors) + + ig.cmap = "jet" + assert ig.cmap == "jet" + + new_colors = make_colors(256, "jet") + for child in ig.world_object.children: + npt.assert_almost_equal(child.material.map.data, new_colors) + + assert ig.interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.interpolation == "nearest" + + ig.interpolation = "linear" + assert ig.interpolation == "linear" + for child in ig.world_object.children: + assert child.material.interpolation == "linear" + check_event(graphic=ig, feature="interpolation", value="linear") + + assert ig.cmap_interpolation == "linear" + for child in ig.world_object.children: + assert child.material.map_interpolation == "linear" + + ig.cmap_interpolation = "nearest" + assert ig.cmap_interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.map_interpolation == "nearest" + check_event(graphic=ig, feature="cmap_interpolation", value="nearest") + + npt.assert_almost_equal(ig.vmin, GRAY_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, GRAY_IMAGE.max()) + + ig.vmin = 50 + assert ig.vmin == 50 + for child in ig.world_object.children: + assert child.material.clim == (50, ig.vmax) + check_event(graphic=ig, feature="vmin", value=50) + + ig.vmax = 100 + assert ig.vmax == 100 + for child in ig.world_object.children: + assert child.material.clim == (ig.vmin, 100) + check_event(graphic=ig, feature="vmax", value=100) + + # test reset + ig.reset_vmin_vmax() + npt.assert_almost_equal(ig.vmin, GRAY_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, GRAY_IMAGE.max()) + + check_set_slice(GRAY_IMAGE, ig, slice(100, 200), slice(200, 300)) + + # test setting all values + ig.data = 1 + npt.assert_almost_equal(ig.data.value, 1) + + +def test_rgb(): + fig = fpl.Figure() + ig = fig[0, 0].add_image(RGB_IMAGE) + assert isinstance(ig, fpl.ImageGraphic) + + ig.add_event_handler(event_handler, "data") + + npt.assert_almost_equal(ig.data.value, RGB_IMAGE) + + assert ig.interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.interpolation == "nearest" + + ig.interpolation = "linear" + assert ig.interpolation == "linear" + for child in ig.world_object.children: + assert child.material.interpolation == "linear" + + npt.assert_almost_equal(ig.vmin, RGB_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, RGB_IMAGE.max()) + + ig.vmin = 50 + assert ig.vmin == 50 + for child in ig.world_object.children: + assert child.material.clim == (50, ig.vmax) + + ig.vmax = 100 + assert ig.vmax == 100 + for child in ig.world_object.children: + assert child.material.clim == (ig.vmin, 100) + + # test reset + ig.reset_vmin_vmax() + npt.assert_almost_equal(ig.vmin, RGB_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, RGB_IMAGE.max()) + + check_set_slice(RGB_IMAGE, ig, slice(100, 200), slice(200, 300)) + + +def test_rgba(): + rgba = np.zeros(shape=(*COFFEE_IMAGE.shape[:2], 4), dtype=np.float32) + + fig = fpl.Figure() + ig = fig[0, 0].add_image(rgba) + assert isinstance(ig, fpl.ImageGraphic) + + npt.assert_almost_equal(ig.data.value, rgba) + + # fancy indexing + # set the blue values of some pixels with an alpha > 1 + ig.data[COFFEE_IMAGE[:, :, -1] > 200] = np.array([0.0, 0.0, 1.0, 0.6]).astype( + np.float32 + ) + + rgba[COFFEE_IMAGE[:, :, -1] > 200] = np.array([0.0, 0.0, 1.0, 0.6]).astype( + np.float32 + ) + + # check that fancy indexing works + npt.assert_almost_equal(ig.data.value, rgba) diff --git a/tests/test_plot_helpers.py b/tests/test_plot_helpers.py new file mode 100644 index 000000000..b4abe55fc --- /dev/null +++ b/tests/test_plot_helpers.py @@ -0,0 +1,33 @@ +import numpy as np +import fastplotlib as fpl + + +def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points) + xs = radius * np.sin(theta) + ys = radius * np.cos(theta) + + return np.column_stack([xs, ys]) + center + + +def test_get_nearest_graphics(): + circles = list() + + centers = [[0, 0], [0, 20], [20, 0], [20, 20]] + + for center in centers: + circles.append(make_circle(center, 5, n_points=75)) + + fig = fpl.Figure() + + lines = fig[0, 0].add_line_collection(circles, cmap="jet", thickness=5) + + fig[0, 0].add_scatter(np.array([[0, 12, 0]])) + + # check distances + nearest = fpl.utils.get_nearest_graphics((0, 12), lines) + assert nearest[0] is lines[1] # closest + assert nearest[1] is lines[0] + assert nearest[2] is lines[3] + assert nearest[3] is lines[2] # furthest + assert nearest[-1] is lines[2] diff --git a/tests/test_positions_data_buffer_manager.py b/tests/test_positions_data_buffer_manager.py new file mode 100644 index 000000000..de9d179d8 --- /dev/null +++ b/tests/test_positions_data_buffer_manager.py @@ -0,0 +1,208 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import fastplotlib as fpl +from fastplotlib.graphics._features import VertexPositions, FeatureEvent +from .utils import ( + generate_slice_indices, + assert_pending_uploads, + generate_positions_spiral_data, +) + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +@pytest.mark.parametrize( + "data", [generate_positions_spiral_data(v) for v in ["y", "xy", "xyz"]] +) +def test_create_buffer(data): + points_data = VertexPositions(data) + + if data.ndim == 1: + # only y-vals specified + npt.assert_almost_equal(points_data[:, 1], generate_positions_spiral_data("y")) + # x-vals are auto generated just using arange + npt.assert_almost_equal(points_data[:, 0], np.arange(data.size)) + + elif data.shape[1] == 2: + # test 2D + npt.assert_almost_equal( + points_data[:, :-1], generate_positions_spiral_data("xy") + ) + npt.assert_almost_equal(points_data[:, -1], 0.0) + + elif data.shape[1] == 3: + # test 3D spiral + npt.assert_almost_equal(points_data[:], generate_positions_spiral_data("xyz")) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +def test_int(test_graphic): + # test setting single points + + data = generate_positions_spiral_data("xyz") + if test_graphic: + fig = fpl.Figure() + + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + points = graphic.data + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "data") + else: + points = VertexPositions(data) + + # set all x, y, z points, create a kink in the spiral + points[2] = 1.0 + npt.assert_almost_equal(points[2], 1.0) + # make sure other points are not affected + indices = list(range(10)) + indices.pop(2) + npt.assert_almost_equal(points[indices], data[indices]) + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == 2 + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], 1.0) + + # reset + if test_graphic: + graphic.data = data + else: + points[:] = data + npt.assert_almost_equal(points[:], data) + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == slice(None) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], data) + + # just set y value + points[3, 1] = 1.0 + npt.assert_almost_equal(points[3, 1], 1.0) + # make sure others not modified + npt.assert_almost_equal(points[3, 0], data[3, 0]) + npt.assert_almost_equal(points[3, 2], data[3, 2]) + indices = list(range(10)) + indices.pop(3) + npt.assert_almost_equal(points[indices], data[indices]) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(1, 16)] +) # int tested separately +@pytest.mark.parametrize("test_axis", ["y", "xy", "xyz"]) +def test_slice(test_graphic, slice_method: dict, test_axis: str): + data = generate_positions_spiral_data("xyz") + + if test_graphic: + fig = fpl.Figure() + + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + points = graphic.data + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "data") + else: + points = VertexPositions(data) + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + # TODO: placeholder until I make a testing figure where we draw frames only on call + points.buffer._gfx_pending_uploads.clear() + + match test_axis: + case "y": + points[s, 1] = -data[s, 1] + npt.assert_almost_equal(points[s, 1], -data[s, 1]) + npt.assert_almost_equal(points[indices, 1], -data[indices, 1]) + # make sure other points are not modified + npt.assert_almost_equal( + points[others, 1], data[others, 1] + ) # other points in same dimension + npt.assert_almost_equal( + points[:, 2:], data[:, 2:] + ) # dimensions that are not sliced + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == (s, 1) + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"][0], s) + assert EVENT_RETURN_VALUE.info["key"][1] == 1 + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], -data[s, 1]) + + case "xy": + points[s, :-1] = -data[s, :-1] + npt.assert_almost_equal(points[s, :-1], -data[s, :-1]) + npt.assert_almost_equal(points[indices, :-1], -data[s, :-1]) + # make sure other points are not modified + npt.assert_almost_equal( + points[others, :-1], data[others, :-1] + ) # other points in the same dimensions + npt.assert_almost_equal( + points[:, -1], data[:, -1] + ) # dimensions that are not touched + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == (s, slice(None, -1, None)) + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"][0], s) + assert EVENT_RETURN_VALUE.info["key"][1] == slice(None, -1, None) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], -data[s, :-1]) + + case "xyz": + points[s] = -data[s] + npt.assert_almost_equal(points[s], -data[s]) + npt.assert_almost_equal(points[indices], -data[s]) + # make sure other points are not modified + npt.assert_almost_equal(points[others], data[others]) + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == s + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"], s) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], -data[s]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(points.buffer, offset, size) diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py new file mode 100644 index 000000000..d9c3a4871 --- /dev/null +++ b/tests/test_positions_graphics.py @@ -0,0 +1,446 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import ( + VertexPositions, + VertexColors, + VertexCmap, + UniformColor, + UniformSize, + PointsSizesFeature, + Thickness, + FeatureEvent, +) + +from .utils import ( + generate_positions_spiral_data, + generate_color_inputs, + MULTI_COLORS_TRUTH, + generate_slice_indices, + assert_pending_uploads, +) + + +TRUTH_CMAPS = { + "jet": np.array( + [ + [0.0, 0.0, 0.5, 1.0], + [0.0, 0.0, 0.99910873, 1.0], + [0.0, 0.37843138, 1.0, 1.0], + [0.0, 0.8333333, 1.0, 1.0], + [0.30044276, 1.0, 0.66729915, 1.0], + [0.65464896, 1.0, 0.31309298, 1.0], + [1.0, 0.90123457, 0.0, 1.0], + [1.0, 0.4945534, 0.0, 1.0], + [1.0, 0.08787218, 0.0, 1.0], + [0.5, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ), + "viridis": np.array( + [ + [0.267004, 0.004874, 0.329415, 1.0], + [0.281412, 0.155834, 0.469201, 1.0], + [0.244972, 0.287675, 0.53726, 1.0], + [0.190631, 0.407061, 0.556089, 1.0], + [0.147607, 0.511733, 0.557049, 1.0], + [0.119483, 0.614817, 0.537692, 1.0], + [0.20803, 0.718701, 0.472873, 1.0], + [0.421908, 0.805774, 0.35191, 1.0], + [0.699415, 0.867117, 0.175971, 1.0], + [0.993248, 0.906157, 0.143936, 1.0], + ], + dtype=np.float32, + ), +} + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +def test_sizes_slice(): + pass + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [None, *generate_color_inputs("b")]) +@pytest.mark.parametrize("uniform_color", [True, False]) +@pytest.mark.parametrize("alpha", [1.0, 0.5, 0.0]) +def test_uniform_color(graphic_type, colors, uniform_color, alpha): + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["colors", "uniform_color", "alpha"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + if uniform_color: + assert isinstance(graphic._colors, UniformColor) + assert isinstance(graphic.colors, pygfx.Color) + if colors is None: + # default white + assert graphic.colors == pygfx.Color([1, 1, 1, alpha]) + else: + # should be blue + assert graphic.colors == pygfx.Color([0, 0, 1, alpha]) + + # check pygfx material + npt.assert_almost_equal( + graphic.world_object.material.color, np.asarray(graphic.colors) + ) + else: + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + if colors is None: + # default white + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[1, 1, 1, alpha]], repeats=len(graphic.data), axis=0), + ) + else: + # blue + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[0, 0, 1, alpha]], repeats=len(graphic.data), axis=0), + ) + + # check geometry + npt.assert_almost_equal( + graphic.world_object.geometry.colors.data, graphic.colors.value + ) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize( + "data", [generate_positions_spiral_data(v) for v in ["y", "xy", "xyz"]] +) +def test_positions_graphics_data( + graphic_type, + data, +): + # tests with different ways of passing positions data, x, xy and xyz + fig = fpl.Figure() + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data) + + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + assert isinstance(graphic._data, VertexPositions) + assert isinstance(graphic.data, VertexPositions) + + # n_datapoints must match + assert len(graphic.data.value) == len(data) + + # make sure data is correct + match data.shape[-1]: + case 1: # only y-vals given + npt.assert_almost_equal(graphic.data[:, 1], data) # y vals must match + npt.assert_almost_equal( + graphic.data[:, 0], np.arange(data.size) + ) # VertexData makes x-vals with arange + npt.assert_almost_equal(graphic.data[:, -1], 0) # z-vals must be zeros + case 2: # xy vals given + npt.assert_almost_equal(graphic.data[:, :-1], data) # x and y must match + npt.assert_almost_equal(graphic.data[:, -1], 0) # z-vals must be zero + case 3: # xyz vals given + npt.assert_almost_equal(graphic.data[:], data[:]) # everything must match + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) +@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("alpha", [None, 0.5, 0.0]) +def test_positions_graphic_vertex_colors( + graphic_type, + colors, + uniform_color, + alpha, +): + # test different ways of passing vertex colors + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["colors", "uniform_color", "alpha"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + if alpha is None: # default arg + alpha = 1 + + # color per vertex + # uniform colors is default False, or set to False + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + assert len(graphic.colors) == len(graphic.data) + + if colors is None: + # default + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[1, 1, 1, alpha]], repeats=len(graphic.data), axis=0), + ) + else: + if len(colors) != len(graphic.data): + # should be single red, regardless of input variant (i.e. str, array, RGBA tuple, etc. + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[1, 0, 0, alpha]], repeats=len(graphic.data), axis=0), + ) + else: + # multi colors + # use the truth for multi colors test that is pre-set + npt.assert_almost_equal(graphic.colors.value, MULTI_COLORS_TRUTH) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) +@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("cmap", ["jet"]) +@pytest.mark.parametrize( + "cmap_transform", [None, [3, 5, 2, 1, 0, 6, 9, 7, 4, 8], np.arange(9, -1, -1)] +) +@pytest.mark.parametrize("alpha", [None, 0.5, 0.0]) +def test_cmap( + graphic_type, + colors, + uniform_color, + cmap, + cmap_transform, + alpha, +): + # test different ways of passing cmap args + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["cmap", "cmap_transform", "colors", "uniform_color", "alpha"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + if alpha is None: + alpha = 1.0 + + truth = TRUTH_CMAPS[cmap].copy() + truth[:, -1] = alpha + + # permute if transform is provided + if cmap_transform is not None: + truth = truth[cmap_transform] + npt.assert_almost_equal(graphic.cmap.transform, cmap_transform) + + assert isinstance(graphic._cmap, VertexCmap) + + assert graphic.cmap.name == cmap + + # make sure buffer is identical + # cmap overrides colors argument + assert graphic.colors.buffer is graphic.cmap.buffer + + npt.assert_almost_equal(graphic.cmap.value, truth) + npt.assert_almost_equal(graphic.colors.value, truth) + + # test changing cmap but not transform + graphic.cmap = "viridis" + truth = TRUTH_CMAPS["viridis"].copy() + truth[:, -1] = alpha + + if cmap_transform is not None: + truth = truth[cmap_transform] + + assert graphic.cmap.name == "viridis" + npt.assert_almost_equal(graphic.cmap.value, truth) + npt.assert_almost_equal(graphic.colors.value, truth) + + # test changing transform + cmap_transform = np.random.rand(10) + + # cmap transform is internally normalized between 0 - 1 + cmap_transform_norm = cmap_transform.copy() + cmap_transform_norm -= cmap_transform.min() + cmap_transform_norm /= cmap_transform_norm.max() + cmap_transform_norm *= 255 + + truth = fpl.utils.get_cmap("viridis", alpha=alpha) + truth = np.vstack([truth[val] for val in cmap_transform_norm.astype(int)]) + + graphic.cmap.transform = cmap_transform + npt.assert_almost_equal(graphic.cmap.transform, cmap_transform) + + npt.assert_almost_equal(graphic.cmap.value, truth) + npt.assert_almost_equal(graphic.colors.value, truth) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("cmap", ["jet"]) +@pytest.mark.parametrize( + "colors", [None, *generate_color_inputs("multi")] +) # cmap arg overrides colors +@pytest.mark.parametrize( + "uniform_color", [True] # none of these will work with a uniform buffer +) +def test_incompatible_cmap_color_args(graphic_type, cmap, colors, uniform_color): + # test incompatible cmap args + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["cmap", "colors", "uniform_color"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [*generate_color_inputs("multi")]) +@pytest.mark.parametrize( + "uniform_color", [True] # none of these will work with a uniform buffer +) +def test_incompatible_color_args(graphic_type, colors, uniform_color): + # test incompatible color args + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["colors", "uniform_color"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + +@pytest.mark.parametrize("sizes", [None, 5.0, np.linspace(3, 8, 10, dtype=np.float32)]) +@pytest.mark.parametrize("uniform_size", [None, False]) +def test_sizes(sizes, uniform_size): + # test scatter sizes + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["sizes"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + assert isinstance(graphic.sizes, PointsSizesFeature) + assert isinstance(graphic._sizes, PointsSizesFeature) + assert len(data) == len(graphic.sizes) + + if sizes is None: + sizes = 1 # default sizes + + npt.assert_almost_equal(graphic.sizes.value, sizes) + npt.assert_almost_equal( + graphic.world_object.geometry.sizes.data, graphic.sizes.value + ) + + +@pytest.mark.parametrize("sizes", [None, 5.0]) +@pytest.mark.parametrize("uniform_size", [True]) +def test_uniform_size(sizes, uniform_size): + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["sizes", "uniform_size"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + assert isinstance(graphic.sizes, (float, int)) + assert isinstance(graphic._sizes, UniformSize) + + if sizes is None: + sizes = 1 # default sizes + + npt.assert_almost_equal(graphic.sizes, sizes) + npt.assert_almost_equal(graphic.world_object.material.size, sizes) + + # test changing size + graphic.sizes = 10.0 + assert isinstance(graphic.sizes, float) + assert isinstance(graphic._sizes, UniformSize) + assert graphic.sizes == 10.0 + + +@pytest.mark.parametrize("thickness", [None, 0.5, 5.0]) +def test_thickness(thickness): + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["thickness"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_line(data=data, **kwargs) + + if thickness is None: + thickness = 2.0 # default thickness + + assert isinstance(graphic._thickness, Thickness) + + assert graphic.thickness == thickness + assert graphic.world_object.material.thickness == thickness + + if thickness == 0.5: + assert isinstance(graphic.world_object.material, pygfx.LineThinMaterial) + + else: + assert isinstance(graphic.world_object.material, pygfx.LineMaterial) diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py new file mode 100644 index 000000000..0b34f9588 --- /dev/null +++ b/tests/test_sizes_buffer_manager.py @@ -0,0 +1,76 @@ +import numpy as np +from numpy import testing as npt +import pytest + +from fastplotlib.graphics._features import PointsSizesFeature +from .utils import generate_slice_indices, assert_pending_uploads + + +def generate_data(input_type: str) -> np.ndarray | float: + """ + Point sizes varying with a sine wave + + Parameters + ---------- + input_type: str + one of "sine", "cosine", or "float" + """ + if input_type == "float": + return 10.0 + xs = np.linspace(0, 10 * np.pi, 10) + + if input_type == "sine": + return np.abs(np.sin(xs)).astype(np.float32) + + if input_type == "cosine": + return np.abs(np.cos(xs)).astype(np.float32) + + +@pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]]) +def test_create_buffer(data): + sizes = PointsSizesFeature(data, n_datapoints=10) + + if isinstance(data, float): + npt.assert_almost_equal(sizes[:], generate_data("float")) + + elif isinstance(data, np.ndarray): + npt.assert_almost_equal(sizes[:], generate_data("sine")) + + +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(0, 16)] +) +@pytest.mark.parametrize("user_input", ["float", "cosine"]) +def test_slice(slice_method: dict, user_input: str): + data = generate_data("sine") + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + sizes = PointsSizesFeature(data, n_datapoints=10) + + # TODO: placeholder until I make a testing figure where we draw frames only on call + sizes.buffer._gfx_pending_uploads.clear() + + match user_input: + case "float": + sizes[s] = 20.0 + truth = np.full(len(indices), 20.0) + npt.assert_almost_equal(sizes[s], truth) + npt.assert_almost_equal(sizes[indices], truth) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + case "cosine": + cosine = generate_data("cosine") + sizes[s] = cosine[s] + npt.assert_almost_equal(sizes[s], cosine[s]) + npt.assert_almost_equal(sizes[indices], cosine[s]) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(sizes.buffer, offset, size) diff --git a/tests/test_text_graphic.py b/tests/test_text_graphic.py new file mode 100644 index 000000000..a13dfe690 --- /dev/null +++ b/tests/test_text_graphic.py @@ -0,0 +1,101 @@ +from numpy import testing as npt + +import fastplotlib as fpl +from fastplotlib.graphics._features import ( + FeatureEvent, + TextData, + FontSize, + TextFaceColor, + TextOutlineColor, + TextOutlineThickness, +) + +import pygfx + + +def test_create_graphic(): + fig = fpl.Figure() + data = "lorem ipsum" + text = fig[0, 0].add_text(data) + + assert isinstance(text, fpl.TextGraphic) + + assert isinstance(text._text, TextData) + assert text.text == data + + assert text.font_size == 14 + assert isinstance(text._font_size, FontSize) + assert text.world_object.geometry.font_size == 14 + + assert text.face_color == pygfx.Color("w") + assert isinstance(text._face_color, TextFaceColor) + assert text.world_object.material.color == pygfx.Color("w") + + assert text.outline_color == pygfx.Color("w") + assert isinstance(text._outline_color, TextOutlineColor) + assert text.world_object.material.outline_color == pygfx.Color("w") + + assert text.outline_thickness == 0 + assert isinstance(text._outline_thickness, TextOutlineThickness) + assert text.world_object.material.outline_thickness == 0 + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +def check_event(graphic, feature, value): + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.type == feature + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target == graphic.world_object + if isinstance(EVENT_RETURN_VALUE.info["value"], float): + # floating point error + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], value) + else: + assert EVENT_RETURN_VALUE.info["value"] == value + + +def test_text_changes_events(): + fig = fpl.Figure() + data = "lorem ipsum" + text = fig[0, 0].add_text(data) + + text.add_event_handler( + event_handler, + "text", + "font_size", + "face_color", + "outline_color", + "outline_thickness", + ) + + text.text = "bah" + assert text.text == "bah" + # TODO: seems like there isn't a way in pygfx to get the current text as a str? + check_event(graphic=text, feature="text", value="bah") + + text.font_size = 10.0 + assert text.font_size == 10.0 + assert text.world_object.geometry.font_size == 10 + check_event(text, "font_size", 10) + + text.face_color = "r" + assert text.face_color == pygfx.Color("r") + assert text.world_object.material.color == pygfx.Color("r") + check_event(text, "face_color", pygfx.Color("r")) + + text.outline_color = "b" + assert text.outline_color == pygfx.Color("b") + assert text.world_object.material.outline_color == pygfx.Color("b") + check_event(text, "outline_color", pygfx.Color("b")) + + text.outline_thickness = 0.3 + npt.assert_almost_equal(text.outline_thickness, 0.3) + npt.assert_almost_equal(text.world_object.material.outline_thickness, 0.3) + check_event(text, "outline_thickness", 0.3) diff --git a/tests/test_texture_array.py b/tests/test_texture_array.py new file mode 100644 index 000000000..e1a6a1753 --- /dev/null +++ b/tests/test_texture_array.py @@ -0,0 +1,230 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import TextureArray, WGPU_MAX_TEXTURE_SIZE +from fastplotlib.graphics.image import _ImageTile + + +def make_data(n_rows: int, n_cols: int) -> np.ndarray: + """ + Makes a 2D array where the amplitude of the sine wave + is increasing along the y-direction (along rows), and + the wavelength is increasing along the x-axis (columns) + """ + xs = np.linspace(0, 1_000, n_cols) + + sine = np.sin(np.sqrt(xs)) + + return np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) + + +def check_texture_array( + data: np.ndarray, + ta: TextureArray, + buffer_size: int, + buffer_shape: tuple[int, int], + row_indices_size: int, + col_indices_size: int, + row_indices_values: np.ndarray, + col_indices_values: np.ndarray, +): + + npt.assert_almost_equal(ta.value, data) + + assert ta.buffer.size == buffer_size + assert ta.buffer.shape == buffer_shape + + assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) + + assert ta.row_indices.size == row_indices_size + assert ta.col_indices.size == col_indices_size + npt.assert_array_equal(ta.row_indices, row_indices_values) + npt.assert_array_equal(ta.col_indices, col_indices_values) + + # make sure chunking is correct + for texture, chunk_index, data_slice in ta: + assert ta.buffer[chunk_index] is texture + chunk_row, chunk_col = chunk_index + + data_row_start_index = chunk_row * WGPU_MAX_TEXTURE_SIZE + data_col_start_index = chunk_col * WGPU_MAX_TEXTURE_SIZE + + data_row_stop_index = min( + data.shape[0], data_row_start_index + WGPU_MAX_TEXTURE_SIZE + ) + data_col_stop_index = min( + data.shape[1], data_col_start_index + WGPU_MAX_TEXTURE_SIZE + ) + + row_slice = slice(data_row_start_index, data_row_stop_index) + col_slice = slice(data_col_start_index, data_col_stop_index) + + assert data_slice == (row_slice, col_slice) + + +def check_set_slice(data, ta, row_slice, col_slice): + ta[row_slice, col_slice] = 1 + npt.assert_almost_equal(ta[row_slice, col_slice], 1) + + # make sure other vals unchanged + npt.assert_almost_equal(ta[: row_slice.start], data[: row_slice.start]) + npt.assert_almost_equal(ta[row_slice.stop :], data[row_slice.stop :]) + npt.assert_almost_equal(ta[:, : col_slice.start], data[:, : col_slice.start]) + npt.assert_almost_equal(ta[:, col_slice.stop :], data[:, col_slice.stop :]) + + +def make_image_graphic(data) -> fpl.ImageGraphic: + fig = fpl.Figure() + return fig[0, 0].add_image(data) + + +def check_image_graphic(texture_array, graphic): + # make sure each ImageTile has the right texture + for (texture, chunk_index, data_slice), img in zip( + texture_array, graphic.world_object.children + ): + assert isinstance(img, _ImageTile) + assert img.geometry.grid is texture + assert img.world.x == data_slice[1].start + assert img.world.y == data_slice[0].start + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_small_texture(test_graphic): + # tests TextureArray with dims that requires only 1 texture + data = make_data(1_000, 1_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data=data, + ta=ta, + buffer_size=1, + buffer_shape=(1, 1), + row_indices_size=1, + col_indices_size=1, + row_indices_values=np.array([0]), + col_indices_values=np.array([0]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(50, 200), slice(600, 800)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_texture_at_limit(test_graphic): + # tests TextureArray with data that is 8192 x 8192 + data = make_data(WGPU_MAX_TEXTURE_SIZE, WGPU_MAX_TEXTURE_SIZE) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=1, + buffer_shape=(1, 1), + row_indices_size=1, + col_indices_size=1, + row_indices_values=np.array([0]), + col_indices_values=np.array([0]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(5000, 8000), slice(2000, 3000)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_wide(test_graphic): + data = make_data(10_000, 20_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=6, + buffer_shape=(2, 3), + row_indices_size=2, + col_indices_size=3, + row_indices_values=np.array([0, 8192]), + col_indices_values=np.array([0, 8192, 16384]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(6_000, 9_000), slice(12_000, 18_000)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_tall(test_graphic): + data = make_data(20_000, 10_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=6, + buffer_shape=(3, 2), + row_indices_size=3, + col_indices_size=2, + row_indices_values=np.array([0, 8192, 16384]), + col_indices_values=np.array([0, 8192]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(12_000, 18_000), slice(6_000, 9_000)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_square(test_graphic): + data = make_data(20_000, 20_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=9, + buffer_shape=(3, 3), + row_indices_size=3, + col_indices_size=3, + row_indices_values=np.array([0, 8192, 16384]), + col_indices_values=np.array([0, 8192, 16384]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(12_000, 18_000), slice(16_000, 19_000)) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..6a25968e1 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,185 @@ +import numpy as np + +import pygfx + + +def generate_slice_indices(kind: int): + n_elements = 10 + a = np.arange(n_elements) + + match kind: + case 0: + # simplest, just int + s = 2 + indices = [2] + + case 1: + # everything, [:] + s = slice(None, None, None) + indices = list(range(10)) + + case 2: + # positive continuous range, [1:5] + s = slice(1, 5, None) + indices = [1, 2, 3, 4] + + case 3: + # positive stepped range, [2:8:2] + s = slice(2, 8, 2) + indices = [2, 4, 6] + + case 4: + # negative continuous range, [-5:] + s = slice(-5, None, None) + indices = [5, 6, 7, 8, 9] + + case 5: + # negative backwards, [-5::-1] + s = slice(-5, None, -1) + indices = [5, 4, 3, 2, 1, 0] + + case 5: + # negative backwards stepped, [-5::-2] + s = slice(-5, None, -2) + indices = [5, 3, 1] + + case 6: + # negative stepped forward[-5::2] + s = slice(-5, None, 2) + indices = [5, 7, 9] + + case 7: + # both negative, [-8:-2] + s = slice(-8, -2, None) + indices = [2, 3, 4, 5, 6, 7] + + case 8: + # both negative and stepped, [-8:2:2] + s = slice(-8, -2, 2) + indices = [2, 4, 6] + + case 9: + # positive, negative, negative, [8:-9:-2] + s = slice(8, -9, -2) + indices = [8, 6, 4, 2] + + case 10: + # only stepped forward, [::2] + s = slice(None, None, 2) + indices = [0, 2, 4, 6, 8] + + case 11: + # only stepped backward, [::-3] + s = slice(None, None, -3) + indices = [9, 6, 3, 0] + + case 12: + # list indices + s = [2, 5, 9] + indices = [2, 5, 9] + + case 13: + # bool indices + s = a > 5 + indices = [6, 7, 8, 9] + + case 14: + # list indices with negatives + s = [1, 4, -2] + indices = [1, 4, 8] + + case 15: + # array indices + s = np.array([1, 4, -7, 9]) + indices = [1, 4, 3, 9] + + others = [i for i in a if i not in indices] + + offset, size = (min(indices), np.ptp(indices) + 1) + + return { + "slice": s, + "indices": indices, + "others": others, + "offset": offset, + "size": size, + } + + +def assert_pending_uploads(buffer: pygfx.Buffer, offset: int, size: int): + upload_offset, upload_size = buffer._gfx_pending_uploads[-1] + # sometimes when slicing with step, it will over-estimate offset + # but it overestimates to upload 1 extra point so it's fine + assert (upload_offset == offset) or (upload_offset == offset - 1) + + # sometimes when slicing with step, it will over-estimate size + # but it overestimates to upload 1 extra point so it's fine + assert (upload_size == size) or (upload_size == size + 1) + + +def generate_positions_spiral_data(inputs: str) -> np.ndarray: + """ + Generates a spiral/spring + + Only 10 points so a very pointy spiral but easier to spot changes :D + """ + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + zs = np.cos(xs) + + match inputs: + case "y": + data = ys + + case "xy": + data = np.column_stack([xs, ys]) + + case "xyz": + data = np.column_stack([xs, ys, zs]) + + return data.astype(np.float32) + + +def generate_color_inputs( + name: str, +) -> list[str, np.ndarray, list, tuple] | list[str, np.ndarray]: + if name == "multi": + s = [ + "r", + "g", + "b", + "cyan", + "magenta", + "green", + "yellow", + "white", + "purple", + "orange", + ] + array = np.vstack([pygfx.Color(c) for c in s]) + return [s, array] + + color = pygfx.Color(name) + + s = name + a = np.array(color) + l = list(color) + t = tuple(color) + + return [s, a, l, t] + + +MULTI_COLORS_TRUTH = np.array( + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [0.0, 1.0, 1.0, 1.0], + [1.0, 0.0, 1.0, 1.0], + [0.0, 0.501960813999176, 0.0, 1.0], + [1.0, 1.0, 0.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [0.501960813999176, 0.0, 0.501960813999176, 1.0], + [1.0, 0.6470588445663452, 0.0, 1.0], + ] +) pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy