diff --git a/.github/workflows/ci-pygfx-release.yml b/.github/workflows/ci-pygfx-release.yml index 87ed1a113..e1c7f09cd 100644 --- a/.github/workflows/ci-pygfx-release.yml +++ b/.github/workflows/ci-pygfx-release.yml @@ -1,4 +1,4 @@ -name: CI +name: CI-pygfx-release on: push: @@ -16,12 +16,12 @@ on: jobs: test-build-full: name: Tests - pygfx release - timeout-minutes: 25 + timeout-minutes: 20 if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false matrix: - python: ["3.11", "3.12", "3.13"] + python: ["3.11", "3.13"] imgui_dep: ["imgui", ""] notebook_dep: ["notebook", ""] os: ["ubuntu-latest", "macos-latest"] @@ -63,19 +63,20 @@ jobs: run: | pytest -v tests/ - name: Test examples + if: ${{ matrix.python == '3.13' }} env: RENDERCANVAS_FORCE_OFFSCREEN: 1 run: | pytest -v examples/ - name: Test examples notebooks, exclude ImageWidget notebook - if: ${{ matrix.notebook_dep == 'notebook' }} + if: ${{ matrix.notebook_dep == 'notebook' && matrix.python == '3.13' }} env: FASTPLOTLIB_NB_TESTS: 1 # test notebooks, exclude ImageWidget notebooks run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "*.ipynb" ! -name "image_widget*.ipynb" -print | xargs) - - name: Test ImageWidget notebooks + - name: Test ImageWidget notebook # test image widget notebooks only if imgui is installed - if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' }} + if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' && matrix.python == '3.13' }} env: FASTPLOTLIB_NB_TESTS: 1 run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "image_widget*.ipynb" -print | xargs) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 528b62772..621870329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: CI-pygfx-main on: push: @@ -15,13 +15,13 @@ on: jobs: test-build-full: - name: Tests - timeout-minutes: 25 + name: Tests - pygfx main + timeout-minutes: 20 if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false matrix: - python: ["3.11", "3.12", "3.13"] + python: ["3.11", "3.13"] imgui_dep: ["imgui", ""] notebook_dep: ["notebook", ""] os: ["ubuntu-latest", "macos-latest"] @@ -69,19 +69,20 @@ jobs: run: | pytest -v tests/ - name: Test examples + if: ${{ matrix.python == '3.13' }} env: RENDERCANVAS_FORCE_OFFSCREEN: 1 run: | pytest -v examples/ - name: Test examples notebooks, exclude ImageWidget notebook - if: ${{ matrix.notebook_dep == 'notebook' }} + if: ${{ matrix.notebook_dep == 'notebook' && matrix.python == '3.13' }} env: FASTPLOTLIB_NB_TESTS: 1 # test notebooks, exclude ImageWidget notebooks run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "*.ipynb" ! -name "image_widget*.ipynb" -print | xargs) - - name: Test ImageWidget notebooks + - name: Test ImageWidget notebook # test image widget notebooks only if imgui is installed - if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' }} + if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' && matrix.python == '3.13' }} env: FASTPLOTLIB_NB_TESTS: 1 run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "image_widget*.ipynb" -print | xargs) diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index cfaf419b8..2a9a18d86 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -14,7 +14,7 @@ jobs: screenshots: name: Regenerate runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false @@ -51,6 +51,9 @@ jobs: env: PYGFX_EXPECT_LAVAPIPE: true run: | + # delete existing screenshots + rm ./examples/screenshots/*.png + rm ./examples/notebooks/screenshots/*.png # regenerate screenshots RENDERCANVAS_FORCE_OFFSCREEN=1 REGENERATE_SCREENSHOTS=1 pytest -v examples - name: Generate screenshots notebook, exclude image widget diff --git a/.gitignore b/.gitignore index c599d5f8c..950f261c0 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/source/sg_execution_times.rst # PyBuilder target/ @@ -134,4 +135,6 @@ dmypy.json # vs code .vscode/ +# diffs from visual regression tests examples/desktop/diffs/*.png +docs/source/_gallery/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0ae81f6f0..e1b7919f2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -105,12 +105,16 @@ advice, in confidence. You can report issues to the fastplotlib core team: -[Kushal Kolar](https://github.com/kushalkolar) -[Caitlin Lewis](https://github.com/clewis7) +Kushal Kolar: +- kushal {at} fastplotlib.org + +Caitlin Lewis: +- caitlin {at} fastplotlib.org If your report involves any members of the fastplotlib core team, or if they feel they have a conflict of interest in handling it, then they will recuse themselves from -considering your report. +considering your report. You may also contact the neutral moderator as stated in our +[Governance document](https://github.com/fastplotlib/fastplotlib/blob/main/GOVERNANCE.md#neutral-moderator). # Incident reporting resolution & Code of Conduct enforcement diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 876757d40..9baaaa321 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -25,8 +25,8 @@ The maintainers are the core developers of fastplotlib and together have a compl The current maintainers are: -1. [Kushal Kolar](https://github.com/kushalkolar) -1. [Caitlin Lewis](https://github.com/clewis7) +1. [Kushal Kolar](https://github.com/kushalkolar) - kushal {at} fastplotlib.org +1. [Caitlin Lewis](https://github.com/clewis7) - caitlin {at} fastplotlib.org Responsibilities: @@ -58,7 +58,7 @@ Responsibilities: No voting power, has no stake in the fastplotlib project. -* Reagan Bullins +* Reagan Bullins - reagan {at} fastplotlib.org Responsibilities: diff --git a/README.md b/README.md index 5109d26aa..227d5bfb8 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,13 @@ You can also take a look at our [**Roadmap for 2025**](https://github.com/fastpl - [**Amol Pasarkar**](https://github.com/apasarkar) A special thanks to all of the `pygfx` developers and the amazing work they have done. + +# Sponsors + +Fastplotlib is free and open source. We would like to thank the following institutions for helping to support fastplotlib over the past few years. + +- UNC Chapel Hill, Giovannucci Lab & Hantman Lab +- Flatiron Institute CCN, Chklovskii Lab +- Duke University, Pearson Lab + +We are always open to new sponsors that can help further develop and improve the library. diff --git a/docs/source/_templates/.class_page_toc.html.swp b/docs/source/_templates/.class_page_toc.html.swp deleted file mode 100644 index d4ed35c1f..000000000 Binary files a/docs/source/_templates/.class_page_toc.html.swp and /dev/null differ diff --git a/docs/source/api/graphic_features/Alpha.rst b/docs/source/api/graphic_features/Alpha.rst new file mode 100644 index 000000000..1ee1f66ac --- /dev/null +++ b/docs/source/api/graphic_features/Alpha.rst @@ -0,0 +1,35 @@ +.. _api.Alpha: + +Alpha +***** + +===== +Alpha +===== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Alpha_api + + Alpha + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Alpha_api + + Alpha.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Alpha_api + + Alpha.add_event_handler + Alpha.block_events + Alpha.clear_event_handlers + Alpha.remove_event_handler + Alpha.set_value + diff --git a/docs/source/api/graphic_features/AlphaMode.rst b/docs/source/api/graphic_features/AlphaMode.rst new file mode 100644 index 000000000..40e58195c --- /dev/null +++ b/docs/source/api/graphic_features/AlphaMode.rst @@ -0,0 +1,35 @@ +.. _api.AlphaMode: + +AlphaMode +********* + +========= +AlphaMode +========= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: AlphaMode_api + + AlphaMode + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: AlphaMode_api + + AlphaMode.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: AlphaMode_api + + AlphaMode.add_event_handler + AlphaMode.block_events + AlphaMode.clear_event_handlers + AlphaMode.remove_event_handler + AlphaMode.set_value + diff --git a/docs/source/api/graphic_features/EdgeWidth.rst b/docs/source/api/graphic_features/EdgeWidth.rst new file mode 100644 index 000000000..ba912dc2a --- /dev/null +++ b/docs/source/api/graphic_features/EdgeWidth.rst @@ -0,0 +1,35 @@ +.. _api.EdgeWidth: + +EdgeWidth +********* + +========= +EdgeWidth +========= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: EdgeWidth_api + + EdgeWidth + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: EdgeWidth_api + + EdgeWidth.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: EdgeWidth_api + + EdgeWidth.add_event_handler + EdgeWidth.block_events + EdgeWidth.clear_event_handlers + EdgeWidth.remove_event_handler + EdgeWidth.set_value + diff --git a/docs/source/api/graphic_features/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst deleted file mode 100644 index f3f78b74b..000000000 --- a/docs/source/api/graphic_features/PointsSizesFeature.rst +++ /dev/null @@ -1,37 +0,0 @@ -.. _api.PointsSizesFeature: - -PointsSizesFeature -****************** - -================== -PointsSizesFeature -================== -.. currentmodule:: fastplotlib.graphics.features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PointsSizesFeature_api - - PointsSizesFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PointsSizesFeature_api - - PointsSizesFeature.buffer - PointsSizesFeature.shared - PointsSizesFeature.value - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PointsSizesFeature_api - - PointsSizesFeature.add_event_handler - PointsSizesFeature.block_events - PointsSizesFeature.clear_event_handlers - PointsSizesFeature.remove_event_handler - PointsSizesFeature.set_value - diff --git a/docs/source/api/graphic_features/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst index 73facc5bf..004881282 100644 --- a/docs/source/api/graphic_features/TextureArray.rst +++ b/docs/source/api/graphic_features/TextureArray.rst @@ -23,7 +23,6 @@ Properties TextureArray.buffer TextureArray.col_indices TextureArray.row_indices - TextureArray.shared TextureArray.value Methods diff --git a/docs/source/api/graphic_features/TextureArrayVolume.rst b/docs/source/api/graphic_features/TextureArrayVolume.rst new file mode 100644 index 000000000..2f8599ef7 --- /dev/null +++ b/docs/source/api/graphic_features/TextureArrayVolume.rst @@ -0,0 +1,39 @@ +.. _api.TextureArrayVolume: + +TextureArrayVolume +****************** + +================== +TextureArrayVolume +================== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextureArrayVolume_api + + TextureArrayVolume + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextureArrayVolume_api + + TextureArrayVolume.buffer + TextureArrayVolume.col_indices + TextureArrayVolume.row_indices + TextureArrayVolume.value + TextureArrayVolume.zdim_indices + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextureArrayVolume_api + + TextureArrayVolume.add_event_handler + TextureArrayVolume.block_events + TextureArrayVolume.clear_event_handlers + TextureArrayVolume.remove_event_handler + TextureArrayVolume.set_value + diff --git a/docs/source/api/graphic_features/UniformEdgeColor.rst b/docs/source/api/graphic_features/UniformEdgeColor.rst new file mode 100644 index 000000000..26489e6d7 --- /dev/null +++ b/docs/source/api/graphic_features/UniformEdgeColor.rst @@ -0,0 +1,35 @@ +.. _api.UniformEdgeColor: + +UniformEdgeColor +**************** + +================ +UniformEdgeColor +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformEdgeColor_api + + UniformEdgeColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformEdgeColor_api + + UniformEdgeColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformEdgeColor_api + + UniformEdgeColor.add_event_handler + UniformEdgeColor.block_events + UniformEdgeColor.clear_event_handlers + UniformEdgeColor.remove_event_handler + UniformEdgeColor.set_value + diff --git a/docs/source/api/graphic_features/UniformMarker.rst b/docs/source/api/graphic_features/UniformMarker.rst new file mode 100644 index 000000000..56b6c2fa4 --- /dev/null +++ b/docs/source/api/graphic_features/UniformMarker.rst @@ -0,0 +1,35 @@ +.. _api.UniformMarker: + +UniformMarker +************* + +============= +UniformMarker +============= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformMarker_api + + UniformMarker + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformMarker_api + + UniformMarker.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformMarker_api + + UniformMarker.add_event_handler + UniformMarker.block_events + UniformMarker.clear_event_handlers + UniformMarker.remove_event_handler + UniformMarker.set_value + diff --git a/docs/source/api/graphic_features/UniformRotations.rst b/docs/source/api/graphic_features/UniformRotations.rst new file mode 100644 index 000000000..f834dbe20 --- /dev/null +++ b/docs/source/api/graphic_features/UniformRotations.rst @@ -0,0 +1,35 @@ +.. _api.UniformRotations: + +UniformRotations +**************** + +================ +UniformRotations +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformRotations_api + + UniformRotations + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformRotations_api + + UniformRotations.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformRotations_api + + UniformRotations.add_event_handler + UniformRotations.block_events + UniformRotations.clear_event_handlers + UniformRotations.remove_event_handler + UniformRotations.set_value + diff --git a/docs/source/api/graphic_features/VectorDirections.rst b/docs/source/api/graphic_features/VectorDirections.rst new file mode 100644 index 000000000..99e47b4a1 --- /dev/null +++ b/docs/source/api/graphic_features/VectorDirections.rst @@ -0,0 +1,35 @@ +.. _api.VectorDirections: + +VectorDirections +**************** + +================ +VectorDirections +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorDirections_api + + VectorDirections + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorDirections_api + + VectorDirections.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorDirections_api + + VectorDirections.add_event_handler + VectorDirections.block_events + VectorDirections.clear_event_handlers + VectorDirections.remove_event_handler + VectorDirections.set_value + diff --git a/docs/source/api/graphic_features/VectorPositions.rst b/docs/source/api/graphic_features/VectorPositions.rst new file mode 100644 index 000000000..939c00e00 --- /dev/null +++ b/docs/source/api/graphic_features/VectorPositions.rst @@ -0,0 +1,35 @@ +.. _api.VectorPositions: + +VectorPositions +*************** + +=============== +VectorPositions +=============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorPositions_api + + VectorPositions + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorPositions_api + + VectorPositions.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorPositions_api + + VectorPositions.add_event_handler + VectorPositions.block_events + VectorPositions.clear_event_handlers + VectorPositions.remove_event_handler + VectorPositions.set_value + diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst index 77d96aaf6..57b9d6311 100644 --- a/docs/source/api/graphic_features/VertexCmap.rst +++ b/docs/source/api/graphic_features/VertexCmap.rst @@ -20,10 +20,8 @@ Properties .. autosummary:: :toctree: VertexCmap_api - VertexCmap.alpha VertexCmap.buffer VertexCmap.name - VertexCmap.shared VertexCmap.transform VertexCmap.value diff --git a/docs/source/api/graphic_features/VertexColors.rst b/docs/source/api/graphic_features/VertexColors.rst index d09da7a18..b72b7564a 100644 --- a/docs/source/api/graphic_features/VertexColors.rst +++ b/docs/source/api/graphic_features/VertexColors.rst @@ -21,7 +21,6 @@ Properties :toctree: VertexColors_api VertexColors.buffer - VertexColors.shared VertexColors.value Methods diff --git a/docs/source/api/graphic_features/VertexMarkers.rst b/docs/source/api/graphic_features/VertexMarkers.rst new file mode 100644 index 000000000..bea8dd346 --- /dev/null +++ b/docs/source/api/graphic_features/VertexMarkers.rst @@ -0,0 +1,37 @@ +.. _api.VertexMarkers: + +VertexMarkers +************* + +============= +VertexMarkers +============= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexMarkers_api + + VertexMarkers + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexMarkers_api + + VertexMarkers.buffer + VertexMarkers.value + VertexMarkers.value_int + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexMarkers_api + + VertexMarkers.add_event_handler + VertexMarkers.block_events + VertexMarkers.clear_event_handlers + VertexMarkers.remove_event_handler + VertexMarkers.set_value + diff --git a/docs/source/api/graphic_features/VertexPointSizes.rst b/docs/source/api/graphic_features/VertexPointSizes.rst new file mode 100644 index 000000000..07f195f6d --- /dev/null +++ b/docs/source/api/graphic_features/VertexPointSizes.rst @@ -0,0 +1,36 @@ +.. _api.VertexPointSizes: + +VertexPointSizes +**************** + +================ +VertexPointSizes +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPointSizes_api + + VertexPointSizes + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPointSizes_api + + VertexPointSizes.buffer + VertexPointSizes.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexPointSizes_api + + VertexPointSizes.add_event_handler + VertexPointSizes.block_events + VertexPointSizes.clear_event_handlers + VertexPointSizes.remove_event_handler + VertexPointSizes.set_value + diff --git a/docs/source/api/graphic_features/VertexPositions.rst b/docs/source/api/graphic_features/VertexPositions.rst index d181f07b9..95480e1d4 100644 --- a/docs/source/api/graphic_features/VertexPositions.rst +++ b/docs/source/api/graphic_features/VertexPositions.rst @@ -21,7 +21,6 @@ Properties :toctree: VertexPositions_api VertexPositions.buffer - VertexPositions.shared VertexPositions.value Methods diff --git a/docs/source/api/graphic_features/VertexRotations.rst b/docs/source/api/graphic_features/VertexRotations.rst new file mode 100644 index 000000000..97cf5f4e2 --- /dev/null +++ b/docs/source/api/graphic_features/VertexRotations.rst @@ -0,0 +1,36 @@ +.. _api.VertexRotations: + +VertexRotations +*************** + +=============== +VertexRotations +=============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexRotations_api + + VertexRotations + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexRotations_api + + VertexRotations.buffer + VertexRotations.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexRotations_api + + VertexRotations.add_event_handler + VertexRotations.block_events + VertexRotations.clear_event_handlers + VertexRotations.remove_event_handler + VertexRotations.set_value + diff --git a/docs/source/api/graphic_features/VolumeIsoEmissive.rst b/docs/source/api/graphic_features/VolumeIsoEmissive.rst new file mode 100644 index 000000000..4d7c4bf7d --- /dev/null +++ b/docs/source/api/graphic_features/VolumeIsoEmissive.rst @@ -0,0 +1,35 @@ +.. _api.VolumeIsoEmissive: + +VolumeIsoEmissive +***************** + +================= +VolumeIsoEmissive +================= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoEmissive_api + + VolumeIsoEmissive + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoEmissive_api + + VolumeIsoEmissive.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoEmissive_api + + VolumeIsoEmissive.add_event_handler + VolumeIsoEmissive.block_events + VolumeIsoEmissive.clear_event_handlers + VolumeIsoEmissive.remove_event_handler + VolumeIsoEmissive.set_value + diff --git a/docs/source/api/graphic_features/VolumeIsoShininess.rst b/docs/source/api/graphic_features/VolumeIsoShininess.rst new file mode 100644 index 000000000..0e4ed6dd3 --- /dev/null +++ b/docs/source/api/graphic_features/VolumeIsoShininess.rst @@ -0,0 +1,35 @@ +.. _api.VolumeIsoShininess: + +VolumeIsoShininess +****************** + +================== +VolumeIsoShininess +================== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoShininess_api + + VolumeIsoShininess + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoShininess_api + + VolumeIsoShininess.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoShininess_api + + VolumeIsoShininess.add_event_handler + VolumeIsoShininess.block_events + VolumeIsoShininess.clear_event_handlers + VolumeIsoShininess.remove_event_handler + VolumeIsoShininess.set_value + diff --git a/docs/source/api/graphic_features/VolumeIsoStepSize.rst b/docs/source/api/graphic_features/VolumeIsoStepSize.rst new file mode 100644 index 000000000..91f838d7a --- /dev/null +++ b/docs/source/api/graphic_features/VolumeIsoStepSize.rst @@ -0,0 +1,35 @@ +.. _api.VolumeIsoStepSize: + +VolumeIsoStepSize +***************** + +================= +VolumeIsoStepSize +================= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoStepSize_api + + VolumeIsoStepSize + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoStepSize_api + + VolumeIsoStepSize.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoStepSize_api + + VolumeIsoStepSize.add_event_handler + VolumeIsoStepSize.block_events + VolumeIsoStepSize.clear_event_handlers + VolumeIsoStepSize.remove_event_handler + VolumeIsoStepSize.set_value + diff --git a/docs/source/api/graphic_features/VolumeIsoSubStepSize.rst b/docs/source/api/graphic_features/VolumeIsoSubStepSize.rst new file mode 100644 index 000000000..db81fee8a --- /dev/null +++ b/docs/source/api/graphic_features/VolumeIsoSubStepSize.rst @@ -0,0 +1,35 @@ +.. _api.VolumeIsoSubStepSize: + +VolumeIsoSubStepSize +******************** + +==================== +VolumeIsoSubStepSize +==================== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoSubStepSize_api + + VolumeIsoSubStepSize + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoSubStepSize_api + + VolumeIsoSubStepSize.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoSubStepSize_api + + VolumeIsoSubStepSize.add_event_handler + VolumeIsoSubStepSize.block_events + VolumeIsoSubStepSize.clear_event_handlers + VolumeIsoSubStepSize.remove_event_handler + VolumeIsoSubStepSize.set_value + diff --git a/docs/source/api/graphic_features/VolumeIsoThreshold.rst b/docs/source/api/graphic_features/VolumeIsoThreshold.rst new file mode 100644 index 000000000..9fa4ab616 --- /dev/null +++ b/docs/source/api/graphic_features/VolumeIsoThreshold.rst @@ -0,0 +1,35 @@ +.. _api.VolumeIsoThreshold: + +VolumeIsoThreshold +****************** + +================== +VolumeIsoThreshold +================== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoThreshold_api + + VolumeIsoThreshold + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoThreshold_api + + VolumeIsoThreshold.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeIsoThreshold_api + + VolumeIsoThreshold.add_event_handler + VolumeIsoThreshold.block_events + VolumeIsoThreshold.clear_event_handlers + VolumeIsoThreshold.remove_event_handler + VolumeIsoThreshold.set_value + diff --git a/docs/source/api/graphic_features/VolumeRenderMode.rst b/docs/source/api/graphic_features/VolumeRenderMode.rst new file mode 100644 index 000000000..8e5c1a56c --- /dev/null +++ b/docs/source/api/graphic_features/VolumeRenderMode.rst @@ -0,0 +1,35 @@ +.. _api.VolumeRenderMode: + +VolumeRenderMode +**************** + +================ +VolumeRenderMode +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeRenderMode_api + + VolumeRenderMode + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeRenderMode_api + + VolumeRenderMode.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeRenderMode_api + + VolumeRenderMode.add_event_handler + VolumeRenderMode.block_events + VolumeRenderMode.clear_event_handlers + VolumeRenderMode.remove_event_handler + VolumeRenderMode.set_value + diff --git a/docs/source/api/graphic_features/VolumeSlicePlane.rst b/docs/source/api/graphic_features/VolumeSlicePlane.rst new file mode 100644 index 000000000..fc58ee222 --- /dev/null +++ b/docs/source/api/graphic_features/VolumeSlicePlane.rst @@ -0,0 +1,35 @@ +.. _api.VolumeSlicePlane: + +VolumeSlicePlane +**************** + +================ +VolumeSlicePlane +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeSlicePlane_api + + VolumeSlicePlane + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VolumeSlicePlane_api + + VolumeSlicePlane.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VolumeSlicePlane_api + + VolumeSlicePlane.add_event_handler + VolumeSlicePlane.block_events + VolumeSlicePlane.clear_event_handlers + VolumeSlicePlane.remove_event_handler + VolumeSlicePlane.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 90a58fe8e..5c5c2b464 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -6,18 +6,34 @@ Graphic Features VertexColors UniformColor - UniformSize SizeSpace - Thickness VertexPositions - PointsSizesFeature VertexCmap + Thickness + VertexMarkers + UniformMarker + UniformEdgeColor + EdgeWidth + UniformRotations + VertexRotations + VertexPointSizes + UniformSize TextureArray ImageCmap ImageVmin ImageVmax ImageInterpolation ImageCmapInterpolation + TextureArrayVolume + VolumeRenderMode + VolumeIsoThreshold + VolumeIsoStepSize + VolumeIsoSubStepSize + VolumeIsoEmissive + VolumeIsoShininess + VolumeSlicePlane + VectorPositions + VectorDirections TextData FontSize TextFaceColor @@ -29,6 +45,8 @@ Graphic Features Name Offset Rotation + Alpha + AlphaMode Visible Deleted GraphicFeatureEvent diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index cf68888f5..da6424e3e 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: Graphic_api + Graphic.alpha + Graphic.alpha_mode Graphic.axes Graphic.block_events Graphic.deleted diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index 27bda3d32..457ba27ee 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: ImageGraphic_api + ImageGraphic.alpha + ImageGraphic.alpha_mode ImageGraphic.axes ImageGraphic.block_events ImageGraphic.cmap @@ -47,6 +49,7 @@ Methods ImageGraphic.add_event_handler ImageGraphic.add_linear_region_selector ImageGraphic.add_linear_selector + ImageGraphic.add_polygon_selector ImageGraphic.add_rectangle_selector ImageGraphic.clear_event_handlers ImageGraphic.remove_event_handler diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst new file mode 100644 index 000000000..8adbc7ac7 --- /dev/null +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -0,0 +1,61 @@ +.. _api.ImageVolumeGraphic: + +ImageVolumeGraphic +****************** + +================== +ImageVolumeGraphic +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVolumeGraphic_api + + ImageVolumeGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVolumeGraphic_api + + ImageVolumeGraphic.alpha + ImageVolumeGraphic.alpha_mode + ImageVolumeGraphic.axes + ImageVolumeGraphic.block_events + ImageVolumeGraphic.cmap + ImageVolumeGraphic.cmap_interpolation + ImageVolumeGraphic.data + ImageVolumeGraphic.deleted + ImageVolumeGraphic.emissive + ImageVolumeGraphic.event_handlers + ImageVolumeGraphic.interpolation + ImageVolumeGraphic.mode + ImageVolumeGraphic.name + ImageVolumeGraphic.offset + ImageVolumeGraphic.plane + ImageVolumeGraphic.right_click_menu + ImageVolumeGraphic.rotation + ImageVolumeGraphic.shininess + ImageVolumeGraphic.step_size + ImageVolumeGraphic.substep_size + ImageVolumeGraphic.supported_events + ImageVolumeGraphic.threshold + ImageVolumeGraphic.visible + ImageVolumeGraphic.vmax + ImageVolumeGraphic.vmin + ImageVolumeGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVolumeGraphic_api + + ImageVolumeGraphic.add_axes + ImageVolumeGraphic.add_event_handler + ImageVolumeGraphic.clear_event_handlers + ImageVolumeGraphic.remove_event_handler + ImageVolumeGraphic.reset_vmin_vmax + ImageVolumeGraphic.rotate + diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index 12c7b5c95..ffbb52f2b 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: LineCollection_api + LineCollection.alpha + LineCollection.alpha_mode LineCollection.axes LineCollection.block_events LineCollection.cmap @@ -52,6 +54,7 @@ Methods LineCollection.add_graphic LineCollection.add_linear_region_selector LineCollection.add_linear_selector + LineCollection.add_polygon_selector LineCollection.add_rectangle_selector LineCollection.clear_event_handlers LineCollection.remove_event_handler diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index c6e18b41b..ddcb00c41 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: LineGraphic_api + LineGraphic.alpha + LineGraphic.alpha_mode LineGraphic.axes LineGraphic.block_events LineGraphic.cmap @@ -46,6 +48,7 @@ Methods LineGraphic.add_event_handler LineGraphic.add_linear_region_selector LineGraphic.add_linear_selector + LineGraphic.add_polygon_selector LineGraphic.add_rectangle_selector LineGraphic.clear_event_handlers LineGraphic.remove_event_handler diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index e1deb75ae..4373454be 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: LineStack_api + LineStack.alpha + LineStack.alpha_mode LineStack.axes LineStack.block_events LineStack.cmap @@ -52,6 +54,7 @@ Methods LineStack.add_graphic LineStack.add_linear_region_selector LineStack.add_linear_selector + LineStack.add_polygon_selector LineStack.add_rectangle_selector LineStack.clear_event_handlers LineStack.remove_event_handler diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 968f0e091..7f4336abe 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -20,15 +20,24 @@ Properties .. autosummary:: :toctree: ScatterGraphic_api + ScatterGraphic.alpha + ScatterGraphic.alpha_mode ScatterGraphic.axes ScatterGraphic.block_events ScatterGraphic.cmap ScatterGraphic.colors ScatterGraphic.data ScatterGraphic.deleted + ScatterGraphic.edge_colors + ScatterGraphic.edge_width ScatterGraphic.event_handlers + ScatterGraphic.image + ScatterGraphic.markers + ScatterGraphic.mode ScatterGraphic.name ScatterGraphic.offset + ScatterGraphic.point_rotation_mode + ScatterGraphic.point_rotations ScatterGraphic.right_click_menu ScatterGraphic.rotation ScatterGraphic.size_space diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index 60cd97f40..0de52942b 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: TextGraphic_api + TextGraphic.alpha + TextGraphic.alpha_mode TextGraphic.axes TextGraphic.block_events TextGraphic.deleted diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst new file mode 100644 index 000000000..4a629f5db --- /dev/null +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -0,0 +1,49 @@ +.. _api.VectorsGraphic: + +VectorsGraphic +************** + +============== +VectorsGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VectorsGraphic_api + + VectorsGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VectorsGraphic_api + + VectorsGraphic.alpha + VectorsGraphic.alpha_mode + VectorsGraphic.axes + VectorsGraphic.block_events + VectorsGraphic.deleted + VectorsGraphic.directions + VectorsGraphic.event_handlers + VectorsGraphic.name + VectorsGraphic.offset + VectorsGraphic.positions + VectorsGraphic.right_click_menu + VectorsGraphic.rotation + VectorsGraphic.supported_events + VectorsGraphic.visible + VectorsGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VectorsGraphic_api + + VectorsGraphic.add_axes + VectorsGraphic.add_event_handler + VectorsGraphic.clear_event_handlers + VectorsGraphic.remove_event_handler + VectorsGraphic.rotate + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index 491013dff..ac47a7dfd 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -8,6 +8,8 @@ Graphics LineGraphic ScatterGraphic ImageGraphic + ImageVolumeGraphic + VectorsGraphic TextGraphic LineCollection LineStack diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index d191fe8ce..e306710be 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -20,6 +20,7 @@ Properties .. autosummary:: :toctree: Figure_api + Figure.animations Figure.cameras Figure.canvas Figure.controllers @@ -38,6 +39,7 @@ Methods Figure.add_animations Figure.add_subplot Figure.clear + Figure.clear_animations Figure.close Figure.export Figure.export_numpy diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index 0abfcc067..959a98743 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -20,9 +20,11 @@ Properties .. autosummary:: :toctree: ImguiFigure_api + ImguiFigure.animations ImguiFigure.cameras ImguiFigure.canvas ImguiFigure.controllers + ImguiFigure.default_imgui_font ImguiFigure.guis ImguiFigure.imgui_renderer ImguiFigure.layout @@ -41,6 +43,7 @@ Methods ImguiFigure.add_gui ImguiFigure.add_subplot ImguiFigure.clear + ImguiFigure.clear_animations ImguiFigure.close ImguiFigure.export ImguiFigure.export_numpy diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index e1c55514d..4e40e8d08 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -20,6 +20,7 @@ Properties .. autosummary:: :toctree: Subplot_api + Subplot.animations Subplot.axes Subplot.background_color Subplot.camera @@ -47,15 +48,18 @@ Methods Subplot.add_animations Subplot.add_graphic Subplot.add_image + Subplot.add_image_volume Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack Subplot.add_scatter Subplot.add_text + Subplot.add_vectors Subplot.auto_scale Subplot.center_graphic Subplot.center_scene Subplot.clear + Subplot.clear_animations Subplot.delete_graphic Subplot.get_figure Subplot.insert_graphic diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index 2c23bc82a..35b5ae1f4 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: LinearRegionSelector_api + LinearRegionSelector.alpha + LinearRegionSelector.alpha_mode LinearRegionSelector.axes LinearRegionSelector.axis LinearRegionSelector.block_events diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index c7a8e978a..9cbe6fb26 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: LinearSelector_api + LinearSelector.alpha + LinearSelector.alpha_mode LinearSelector.axes LinearSelector.axis LinearSelector.block_events diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index 24928c817..dc9727069 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -20,6 +20,8 @@ Properties .. autosummary:: :toctree: RectangleSelector_api + RectangleSelector.alpha + RectangleSelector.alpha_mode RectangleSelector.axes RectangleSelector.axis RectangleSelector.block_events diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst index 7c3237490..429f958e2 100644 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ b/docs/source/api/tools/HistogramLUTTool.rst @@ -20,12 +20,14 @@ Properties .. autosummary:: :toctree: HistogramLUTTool_api + HistogramLUTTool.alpha + HistogramLUTTool.alpha_mode HistogramLUTTool.axes HistogramLUTTool.block_events HistogramLUTTool.cmap HistogramLUTTool.deleted HistogramLUTTool.event_handlers - HistogramLUTTool.image_graphic + HistogramLUTTool.images HistogramLUTTool.name HistogramLUTTool.offset HistogramLUTTool.right_click_menu @@ -44,7 +46,6 @@ Methods HistogramLUTTool.add_axes HistogramLUTTool.add_event_handler HistogramLUTTool.clear_event_handlers - HistogramLUTTool.disconnect_image_graphic HistogramLUTTool.remove_event_handler HistogramLUTTool.rotate HistogramLUTTool.set_data diff --git a/docs/source/conf.py b/docs/source/conf.py index e4ff72237..74a1fbaf9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -57,6 +57,7 @@ "subsection_order": ExplicitOrder( [ "../../examples/image", + "../../examples/image_volume", "../../examples/heatmap", "../../examples/image_widget", "../../examples/gridplot", @@ -65,6 +66,7 @@ "../../examples/line", "../../examples/line_collection", "../../examples/scatter", + "../../examples/vectors", "../../examples/text", "../../examples/events", "../../examples/selection_tools", @@ -126,6 +128,7 @@ autodoc_typehints = "description" autodoc_typehints_description_target = "documented_params" +autodoc_preserve_defaults = True intersphinx_mapping = { "python": ("https://docs.python.org/3", None), diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 1b9b2f7ec..8e942830e 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -39,11 +39,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -113,6 +113,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -195,11 +217,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -214,6 +236,80 @@ cmap | value | str | new cmap to set at given slice | +----------+-------+--------------------------------+ +markers +^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | ++----------+----------------------------------------------+------------------------------------------------+ +| value | str | np.ndarray[str] | new marker values for points that were changed | ++----------+----------------------------------------------+------------------------------------------------+ + +markers +^^^^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | str | None | new marker value | ++----------+------------+------------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+----------------+ +| dict key | type | description | ++==========+==================================================+================+ +| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | ++----------+--------------------------------------------------+----------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +edge_width +^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +image +^^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + size_space ^^^^^^^^^^ @@ -225,6 +321,30 @@ size_space | value | str | 'screen' | 'world' | 'model' | +----------+------+------------------------------+ +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------+ +| value | int | float | array-like | new rotation values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------+ + name ^^^^ @@ -258,6 +378,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -384,6 +526,355 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +ImageVolumeGraphic +------------------ + +data +^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------+---------------+ +| dict key | type | description | ++==========+======+===============+ +| value | str | new cmap name | ++----------+------+---------------+ + +vmin +^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new vmin value | ++----------+-------+----------------+ + +vmax +^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new vmax value | ++----------+-------+----------------+ + +interpolation +^^^^^^^^^^^^^ + +**event info dict** + ++----------+------+--------------------------------------------+ +| dict key | type | description | ++==========+======+============================================+ +| value | str | new interpolation method, nearest | linear | ++----------+------+--------------------------------------------+ + +cmap_interpolation +^^^^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------------------------+ +| dict key | type | description | ++==========+======+================================================+ +| value | str | new cmap interpolatio method, nearest | linear | ++----------+------+------------------------------------------------+ + +mode +^^^^ + +**event info dict** + ++----------+------+-----------------------------------------+ +| dict key | type | description | ++==========+======+=========================================+ +| value | str | volume rendering mode that has been set | ++----------+------+-----------------------------------------+ + +threshold +^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------------+ +| dict key | type | description | ++==========+=======+==========================+ +| value | float | new isosurface threshold | ++----------+-------+--------------------------+ + +step_size +^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------------+ +| dict key | type | description | ++==========+=======+==========================+ +| value | float | new isosurface step_size | ++----------+-------+--------------------------+ + +substep_size +^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+--------------------------+ +| dict key | type | description | ++==========+=======+==========================+ +| value | float | new isosurface step_size | ++----------+-------+--------------------------+ + +emissive +^^^^^^^^ + +**event info dict** + ++----------+-------------+-------------------------------+ +| dict key | type | description | ++==========+=============+===============================+ +| value | pygfx.Color | new isosurface emissive color | ++----------+-------------+-------------------------------+ + +shininess +^^^^^^^^^ + +**event info dict** + ++----------+------+--------------------------+ +| dict key | type | description | ++==========+======+==========================+ +| value | int | new isosurface shininess | ++----------+------+--------------------------+ + +plane +^^^^^ + +**event info dict** + ++----------+-----------------------------------+-----------------+ +| dict key | type | description | ++==========+===================================+=================+ +| value | tuple[float, float, float, float] | new plane slice | ++----------+-----------------------------------+-----------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +VectorsGraphic +-------------- + +positions +^^^^^^^^^ + +**event info dict** + ++----------+------------+----------------------+ +| dict key | type | description | ++==========+============+======================+ +| value | np.ndarray | new vector positions | ++----------+------------+----------------------+ + +directions +^^^^^^^^^^ + +**event info dict** + ++----------+------------+-----------------------+ +| dict key | type | description | ++==========+============+=======================+ +| value | np.ndarray | new vector directions | ++----------+------------+-----------------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -497,6 +988,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -555,11 +1068,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -629,6 +1142,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -687,11 +1222,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -761,6 +1296,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -838,6 +1395,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -917,6 +1496,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ @@ -996,6 +1597,28 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + visible ^^^^^^^ diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 4f3dc64cb..8bf255507 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -62,7 +62,7 @@ Before giving a detailed overview of the library, here is a minimal example:: fig.show() if __name__ == "__main__": - fpl.run() + fpl.loop.run() .. image:: ../_static/guide_hello_world.png @@ -71,7 +71,8 @@ This is just a simple example of how the ``fastplotlib`` API works to create a p However, we are just scratching the surface of what is possible with ``fastplotlib``. Next, let's take a look at the building blocks of ``fastplotlib`` and how they can be used to create more complex visualizations. -Aside from this user guide, the Examples Gallery is the best place to learn specific things in fastplotlib. +In addition to this user guide, the Examples Gallery is the best place to learn how to do specific things in fastplotlib. The `quickstart notebook `_ is also an excellent introduction to the API, even if you do not plan to use ``fastplotlib`` in notebooks. Remember, ``fastplotlib`` code is pretty much identical whether it's used in jupyterlab, Qt, or glfw! + If you still need help don't hesitate to post an issue or discussion post! Figure diff --git a/examples/events/drag_points.py b/examples/events/drag_points.py index 752430c7c..5a679a996 100644 --- a/examples/events/drag_points.py +++ b/examples/events/drag_points.py @@ -23,16 +23,16 @@ figure = fpl.Figure(size=(700, 560)) # add a line -line_graphic = figure[0, 0].add_line(data) +line = figure[0, 0].add_line(data) # add a scatter, share the line graphic buffer! -scatter_graphic = figure[0, 0].add_scatter(data=line_graphic.data, sizes=25, colors="r") +scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors="r") is_moving = False vertex_index = None -@scatter_graphic.add_event_handler("pointer_down") +@scatter.add_event_handler("pointer_down") def start_drag(ev: pygfx.PointerEvent): global is_moving global vertex_index @@ -42,7 +42,7 @@ def start_drag(ev: pygfx.PointerEvent): is_moving = True vertex_index = ev.pick_info["vertex_index"] - scatter_graphic.colors[vertex_index] = "cyan" + scatter.colors[vertex_index] = "cyan" @figure.renderer.add_event_handler("pointer_move") @@ -63,13 +63,13 @@ def move_point(ev): if pos is None: # end movement is_moving = False - scatter_graphic.colors[vertex_index] = "r" # reset color + scatter.colors[vertex_index] = "r" # reset color vertex_index = None return # change scatter data # since we are sharing the buffer, the line data will also change - scatter_graphic.data[vertex_index, :-1] = pos[:-1] + scatter.data[vertex_index, :-1] = pos[:-1] # re-enable controller figure[0, 0].controller.enabled = True @@ -83,7 +83,7 @@ def end_drag(ev: pygfx.PointerEvent): # end movement if is_moving: # reset color - scatter_graphic.colors[vertex_index] = "r" + scatter.colors[vertex_index] = "r" is_moving = False vertex_index = None diff --git a/examples/events/image_click.py b/examples/events/image_click.py index 729a67586..b783e1ee0 100644 --- a/examples/events/image_click.py +++ b/examples/events/image_click.py @@ -18,14 +18,14 @@ figure = fpl.Figure(size=(700, 560)) # create image graphic -image_graphic = figure[0, 0].add_image(data=data) +image = figure[0, 0].add_image(data=data) # show the plot figure.show() # adding a click event, we can also use decorators to add event handlers -@image_graphic.add_event_handler("click") +@image.add_event_handler("click") def click_event(ev: pygfx.PointerEvent): # get the click location in screen coordinates xy = (ev.x, ev.y) diff --git a/examples/events/line_data_thickness_event.py b/examples/events/line_data_thickness_event.py index 83f9322cb..d28471644 100644 --- a/examples/events/line_data_thickness_event.py +++ b/examples/events/line_data_thickness_event.py @@ -16,18 +16,18 @@ xs = np.linspace(0, 4 * np.pi, 100) # sine wave ys = np.sin(xs) -sine = np.column_stack([xs, ys]) +sine_data = np.column_stack([xs, ys]) # cosine wave ys = np.cos(xs) -cosine = np.column_stack([xs, ys]) +cosine_data = np.column_stack([xs, ys]) # create line graphics -sine_graphic = figure[0, 0].add_line(data=sine) -cosine_graphic = figure[0, 0].add_line(data=cosine, offset=(0, 4, 0)) +sine = figure[0, 0].add_line(data=sine_data) +cosine = figure[0, 0].add_line(data=cosine_data, offset=(0, 4, 0)) # make a list of the line graphics for convenience -lines = [sine_graphic, cosine_graphic] +lines = [sine, cosine] def change_thickness(ev: fpl.GraphicFeatureEvent): @@ -66,11 +66,11 @@ def change_data(ev: fpl.GraphicFeatureEvent): # set the y-value of the middle 40 points of the sine graphic to 1 # after the sine_graphic sets its data, the event handlers will be called # and therefore the cosine graphic will also set its data using the event data -sine_graphic.data[30:70, 1] = np.ones(40) +sine.data[30:70, 1] = np.ones(40) # set the thickness of the cosine graphic, this will trigger an event # that causes the sine graphic's thickness to also be set from this value -cosine_graphic.thickness = 10 +cosine.thickness = 10 # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide diff --git a/examples/events/scatter_hover_transforms.py b/examples/events/scatter_hover_transforms.py index 18e6f3de5..f7b733109 100644 --- a/examples/events/scatter_hover_transforms.py +++ b/examples/events/scatter_hover_transforms.py @@ -13,9 +13,9 @@ # test_example = false # sphinx_gallery_pygfx_docs = 'screenshot' -from sklearn.datasets import fetch_california_housing +from sklearn.datasets import load_diabetes from sklearn.preprocessing import ( - Normalizer, + StandardScaler, QuantileTransformer, PowerTransformer, ) @@ -24,30 +24,16 @@ import pygfx # get the dataset -dataset = fetch_california_housing(n_retries=5, delay=20) -X_full, y = dataset.data, dataset.target -feature_names = dataset.feature_names - -feature_mapping = { - "MedInc": "Median income in block", - "HouseAge": "Median house age in block", - "AveRooms": "Average number of rooms", - "AveBedrms": "Average number of bedrooms", - "Population": "Block population", - "AveOccup": "Average house occupancy", - "Latitude": "House block latitude", - "Longitude": "House block longitude", -} +dataset = load_diabetes(scaled=False) + # Take only 2 features to make visualization easier -# Feature MedInc has a long tail distribution. -# Feature AveOccup has a few but very large outliers. -features = ["MedInc", "AveOccup"] -features_idx = [feature_names.index(feature) for feature in features] -X = X_full[:, features_idx] +X = dataset["data"][:, (2, 6)] +# target +y = dataset["target"] # list of our scalers and their names as strings -scalers = [PowerTransformer, QuantileTransformer, Normalizer] +scalers = [PowerTransformer, QuantileTransformer, StandardScaler] names = ["Original Data", *[s.__name__ for s in scalers]] # fastplotlib code starts here, make a figure diff --git a/examples/gridplot/gridplot.py b/examples/gridplot/gridplot.py index 5edd6a845..1aa8c8083 100644 --- a/examples/gridplot/gridplot.py +++ b/examples/gridplot/gridplot.py @@ -30,4 +30,4 @@ # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": print(__doc__) - fpl.loop.run() + fpl.loop.run() \ No newline at end of file diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index d89168ec9..cbf546e2a 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -17,7 +17,7 @@ figure = fpl.Figure( shape=(2, 2), names=[["image-overlay", "circles"], ["line-stack", "scatter"]], - size=(700, 560) + size=(700, 560), ) img = iio.imread("imageio:coffee.png") @@ -36,6 +36,7 @@ # 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) @@ -55,10 +56,10 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: # 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 + [0, 1, 1, 2], + [0, 0, 1, 1], + [2, 2, 8, 3], + [1, 9, 1, 5], ] # add an image to overlay the circles on @@ -70,10 +71,10 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: figure["circles"].add_line_collection( circles, cmap="tab10", - cmap_transform=cmap_transform, + cmap_transform=np.asarray(cmap_transform).ravel(), thickness=3, alpha=0.5, - name="circles-graphic" + name="circles-graphic", ) # move the circles graphic so that it is centered over the image @@ -84,7 +85,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] +sine = np.column_stack([xs, ys]) # make 10 identical waves sine_waves = 10 * [sine] @@ -115,4 +116,3 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: if __name__ == "__main__": print(__doc__) fpl.loop.run() - diff --git a/examples/guis/sine_cosine_funcs.py b/examples/guis/sine_cosine_funcs.py index 09a5ec990..f7dd064cf 100644 --- a/examples/guis/sine_cosine_funcs.py +++ b/examples/guis/sine_cosine_funcs.py @@ -61,55 +61,55 @@ def make_circle(center, radius: float, p, q, n_points: int) -> np.ndarray: # create sine and cosine data xs = np.linspace(0, 2 * np.pi, 360) -sine = np.sin(xs * P) -cosine = np.cos(xs * Q) +sine_data = np.sin(xs * P) +cosine_data = np.cos(xs * Q) # circle data circle_data = make_circle(center=(0, 0), p=P, q=Q, radius=1, n_points=360) # make the circle line graphic, set the cmap transform using the sine function -circle_graphic = figure["circle"].add_line( - circle_data, thickness=4, cmap="bwr", cmap_transform=sine +circle = figure["circle"].add_line( + circle_data, thickness=4, cmap="bwr", cmap_transform=sine_data ) # line to show the circle radius # use it to indicate the current position of the sine and cosine selctors (below) radius_data = np.array([[0, 0, 0], [*circle_data[0], 0]]) -circle_radius_graphic = figure["circle"].add_line( +circle_radius = figure["circle"].add_line( radius_data, thickness=6, colors="magenta" ) # sine line graphic, cmap transform set from the sine function -sine_graphic = figure["sin"].add_line( - sine, thickness=10, cmap="bwr", cmap_transform=sine +sine = figure["sin"].add_line( + sine_data, thickness=10, cmap="bwr", cmap_transform=sine_data ) # cosine line graphic, cmap transform set from the sine function # illustrates the sine function values on the cosine graphic -cosine_graphic = figure["cos"].add_line( - cosine, thickness=10, cmap="bwr", cmap_transform=sine +cosine = figure["cos"].add_line( + cosine_data, thickness=10, cmap="bwr", cmap_transform=sine_data ) # add linear selectors to the sine and cosine line graphics -sine_selector = sine_graphic.add_linear_selector() -cosine_selector = cosine_graphic.add_linear_selector() +sine_selector = sine.add_linear_selector() +cosine_selector = cosine.add_linear_selector() def set_circle_cmap(ev): # sets the cmap transforms cmap_transform = ev.graphic.data[:, 1] # y-val data of the sine or cosine graphic - for g in [sine_graphic, cosine_graphic]: + for g in [sine, cosine]: g.cmap.transform = cmap_transform # set circle cmap transform - circle_graphic.cmap.transform = cmap_transform + circle.cmap.transform = cmap_transform # when the sine or cosine graphic is clicked, the cmap_transform # of the sine, cosine and circle line graphics are all set from # the y-values of the clicked line -sine_graphic.add_event_handler(set_circle_cmap, "click") -cosine_graphic.add_event_handler(set_circle_cmap, "click") +sine.add_event_handler(set_circle_cmap, "click") +cosine.add_event_handler(set_circle_cmap, "click") def set_x_val(ev): @@ -120,7 +120,7 @@ def set_x_val(ev): sine_selector.selection = value cosine_selector.selection = value - circle_radius_graphic.data[1, :-1] = circle_data[index] + circle_radius.data[1, :-1] = circle_data[index] # add same event handler to both graphics sine_selector.add_event_handler(set_x_val, "selection") @@ -138,19 +138,19 @@ def __init__(self, figure, size, location, title): self._q = 1 def _set_data(self): - global sine_graphic, cosine_graphic, circle_graphic, circle_radius_graphic, circle_data + global sine, cosine, circle, circle_radius, circle_data # make new data - sine = np.sin(xs * self._p) - cosine = np.cos(xs * self._q) + sine_data = np.sin(xs * self._p) + cosine_data = np.cos(xs * self._q) circle_data = make_circle(center=(0, 0), p=self._p, q=self._q, radius=1, n_points=360) # set the graphics - sine_graphic.data[:, 1] = sine - cosine_graphic.data[:, 1] = cosine - circle_graphic.data[:, :2] = circle_data - circle_radius_graphic.data[1, :-1] = circle_data[sine_selector.get_selected_index()] + sine.data[:, 1] = sine_data + cosine.data[:, 1] = cosine_data + circle.data[:, :2] = circle_data + circle_radius.data[1, :-1] = circle_data[sine_selector.get_selected_index()] def update(self): flag_set_data = False diff --git a/examples/heatmap/heatmap.py b/examples/heatmap/heatmap.py index 38c9b51a7..3f02206f6 100644 --- a/examples/heatmap/heatmap.py +++ b/examples/heatmap/heatmap.py @@ -21,7 +21,7 @@ data = np.vstack([sine * i for i in range(2_300)]) # plot the image data -img = figure[0, 0].add_image(data=data, name="heatmap") +image = figure[0, 0].add_image(data=data, name="heatmap") del data figure.show() diff --git a/examples/image/image_cmap.py b/examples/image/image_cmap.py index f651f438c..8c94c6f17 100644 --- a/examples/image/image_cmap.py +++ b/examples/image/image_cmap.py @@ -8,19 +8,20 @@ # test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' -import fastplotlib as fpl import imageio.v3 as iio +import fastplotlib as fpl + im = iio.imread("imageio:camera.png") figure = fpl.Figure(size=(700, 560)) # plot the image data -image_graphic = figure[0, 0].add_image(data=im, name="random-image") +image = figure[0, 0].add_image(data=im, name="random-image") figure.show() -image_graphic.cmap = "viridis" +image.cmap = "viridis" # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide diff --git a/examples/image/image_rgb.py b/examples/image/image_rgb.py index 187dac553..569c09f0b 100644 --- a/examples/image/image_rgb.py +++ b/examples/image/image_rgb.py @@ -16,7 +16,7 @@ figure = fpl.Figure(size=(700, 560)) # plot the image data -image_graphic = figure[0, 0].add_image(data=im, name="iio astronaut") +image = figure[0, 0].add_image(data=im, name="iio astronaut") figure.show() diff --git a/examples/image/image_rgbvminvmax.py b/examples/image/image_rgbvminvmax.py index 02635f134..bf2963daf 100644 --- a/examples/image/image_rgbvminvmax.py +++ b/examples/image/image_rgbvminvmax.py @@ -16,12 +16,12 @@ figure = fpl.Figure(size=(700, 560)) # plot the image data -image_graphic = figure[0, 0].add_image(data=im, name="iio astronaut") +image = figure[0, 0].add_image(data=im, name="iio astronaut") figure.show() -image_graphic.vmin = 0.5 -image_graphic.vmax = 0.75 +image.vmin = 0.5 +image.vmax = 0.75 # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide diff --git a/examples/image/image_simple.py b/examples/image/image_simple.py index d0910fb82..2fd7f694c 100644 --- a/examples/image/image_simple.py +++ b/examples/image/image_simple.py @@ -16,7 +16,7 @@ data = iio.imread("imageio:camera.png") # plot the image data -image_graphic = figure[0, 0].add_image(data=data, name="iio camera") +image = figure[0, 0].add_image(data=data, name="iio camera") figure.show() diff --git a/examples/image/image_small.py b/examples/image/image_small.py index 732d61d74..6acfd7250 100644 --- a/examples/image/image_small.py +++ b/examples/image/image_small.py @@ -18,7 +18,7 @@ [[0, 1, 2], [3, 4, 5]] ) -image_graphic = figure[0, 0].add_image(data) +image = figure[0, 0].add_image(data) figure.show() diff --git a/examples/image/image_vminvmax.py b/examples/image/image_vminvmax.py index e2d1c7743..1c290c587 100644 --- a/examples/image/image_vminvmax.py +++ b/examples/image/image_vminvmax.py @@ -16,12 +16,12 @@ data = iio.imread("imageio:astronaut.png") # plot the image data -image_graphic = figure[0, 0].add_image(data=data, name="iio astronaut") +image = figure[0, 0].add_image(data=data, name="iio astronaut") figure.show() -image_graphic.vmin = 0.5 -image_graphic.vmax = 0.75 +image.vmin = 0.5 +image.vmax = 0.75 # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide diff --git a/examples/image_volume/README.rst b/examples/image_volume/README.rst new file mode 100644 index 000000000..6c349ebfa --- /dev/null +++ b/examples/image_volume/README.rst @@ -0,0 +1,2 @@ +Image Volume Examples +===================== diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py new file mode 100644 index 000000000..34bf9b903 --- /dev/null +++ b/examples/image_volume/image_volume_4d.py @@ -0,0 +1,115 @@ +""" +Volume movie +============ + +View 4D data of a volume over time by updating the volume data. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 5s' + +import numpy as np +from scipy.ndimage import gaussian_filter +import fastplotlib as fpl + + +def generate_data( + p=1, + noise=0.5, + T=128, + framerate=10, + firerate=2.0, +): + gamma = np.array([0.9]) + dims = (128, 128, 30) # size of image + sig = (4, 4, 2) # neurons size + bkgrd = 10 + N = 150 # number of neurons + np.random.seed(0) + centers = np.asarray( + [[np.random.randint(s, x - s) for x, s in zip(dims, sig)] for i in range(N)] + ) + Y = np.zeros((T,) + dims, dtype=np.float32) + trueSpikes = np.random.rand(N, T) < firerate / float(framerate) + trueSpikes[:, 0] = 0 + truth = trueSpikes.astype(np.float32) + for i in range(2, T): + if p == 2: + truth[:, i] += gamma[0] * truth[:, i - 1] + gamma[1] * truth[:, i - 2] + else: + truth[:, i] += gamma[0] * truth[:, i - 1] + for i in range(N): + Y[:, centers[i, 0], centers[i, 1], centers[i, 2]] = truth[i] + tmp = np.zeros(dims) + tmp[tuple(np.array(dims) // 2)] = 1.0 + print("gaussing filtering") + z = np.linalg.norm(gaussian_filter(tmp, sig).ravel()) + + print("finishing") + Y = ( + bkgrd + + noise * np.random.randn(*Y.shape) + + 10 * gaussian_filter(Y, (0,) + sig) / z + ) + + return Y + + +voldata = generate_data() + +figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560)) + +volume = figure[0, 0].add_image_volume( + voldata[0], + vmin=10, + vmax=15, + cmap="gnuplot2", + alpha_mode="add", +) + +hlut = fpl.HistogramLUTTool(voldata, volume) + +figure[0, 0].docks["right"].size = 100 +figure[0, 0].docks["right"].controller.enabled = False +figure[0, 0].docks["right"].add_graphic(hlut) +figure[0, 0].docks["right"].auto_scale(maintain_aspect=False) + +figure.show() + +# load a pre-saved camera state +state = { + "position": np.array([-70, 90, 150]), + "rotation": np.array([-0.09210227, -0.47460177, -0.05001713, 0.87393857]), + "scale": np.array([1.0, 1.0, 1.0]), + "reference_up": np.array([0.0, 1.0, 0.0]), + "fov": 50.0, + "width": 27.605629518746266, + "height": 117.78401927998402, + "depth": 183.4884192530962, + "zoom": 0.75, + "maintain_aspect": True, + "depth_range": None, +} + +figure[0, 0].camera.set_state(state) + + +i = 0 +def update(): + global i + + volume.data = voldata[i] + + i += 1 + if i == voldata.shape[0]: + i = 0 + + +figure.add_animations(update) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_mip.py b/examples/image_volume/image_volume_mip.py new file mode 100644 index 000000000..73ae7803f --- /dev/null +++ b/examples/image_volume/image_volume_mip.py @@ -0,0 +1,47 @@ +""" +Volume Mip mode +=============== + +View a volume using MIP (Maximum Intensity Projection) rendering. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560)) + +figure[0, 0].add_image_volume(voldata, mode="mip", alpha_mode="add") + +figure.show() + + +# load a pre-saved camera state +state = { + "position": np.array([-120, 90, 330]), + "rotation": np.array([-0.07280538, -0.41100206, -0.03295049, 0.90812496]), + "scale": np.array([1.0, 1.0, 1.0]), + "reference_up": np.array([0.0, 1.0, 0.0]), + "fov": 50.0, + "width": 128.0, + "height": 128.0, + "depth": 313, + "zoom": 0.75, + "maintain_aspect": True, + "depth_range": None, +} + + +figure[0, 0].camera.set_state(state) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_multi_channel.py b/examples/image_volume/image_volume_multi_channel.py new file mode 100644 index 000000000..01fc27ac6 --- /dev/null +++ b/examples/image_volume/image_volume_multi_channel.py @@ -0,0 +1,48 @@ +""" +Multi channel volumes +===================== + +Example with multi-channel volume images. Use alpha_mode "add" for additive blending. +""" + +# test_example = false +# run_example = false +# sphinx_gallery_pygfx_docs = 'code' + +import fastplotlib as fpl +from ome_zarr.io import parse_url +from ome_zarr.reader import Reader + + +# load data +url = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr" + +# read the image data +reader = Reader(parse_url(url)) +# first node is image data +image_node = next(reader()) + +dask_data = image_node.data + +# use the highest resolution image in the pyramid zarr +voldata = dask_data[0] + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 700) +) + +# add first channel, use cyan colormap +vol_ch0 = figure[0, 0].add_image_volume(voldata[0], cmap="cyan", alpha_mode="add") +# add another channel, use magenta cmap +vol_ch1 = figure[0, 0].add_image_volume(voldata[1], cmap="magenta", alpha_mode="add") + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_non_orthogonal_slicing.py b/examples/image_volume/image_volume_non_orthogonal_slicing.py new file mode 100644 index 000000000..dc74a5e0a --- /dev/null +++ b/examples/image_volume/image_volume_non_orthogonal_slicing.py @@ -0,0 +1,56 @@ +""" +Volume non-orthogonal slicing +============================= + +Perform non-orthogonal slicing of image volumes. + +For an example with UI sliders see the "Volume modes" example. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560) +) + +vol = figure[0, 0].add_image_volume(voldata, mode="slice") + +# a plane is defined by ax + by + cz + d = 0 +# the plane property sets (a, b, c, d) +vol.plane = (0, 0.5, 0.5, -70) + +# just a pre-saved camera state to view the plot area +state = { + "position": np.array([-160.0, 105.0, 205.0]), + "rotation": np.array([-0.1, -0.6, -0.07, 0.8]), + "scale": np.array([1., 1., 1.]), + "reference_up": np.array([0., 1., 0.]), + "fov": 50.0, + "width": 128.0, + "height": 128.0, + "depth": 315, + "zoom": 0.75, + "maintain_aspect": True, + "depth_range": None +} + +figure.show() + +figure[0, 0].camera.set_state(state) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_render_modes.py b/examples/image_volume/image_volume_render_modes.py new file mode 100644 index 000000000..d29e3b166 --- /dev/null +++ b/examples/image_volume/image_volume_render_modes.py @@ -0,0 +1,86 @@ +""" +Volume modes +============ + +View a volume using different rendering modes. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from fastplotlib.ui import EdgeWindow +from fastplotlib.graphics.features import VOLUME_RENDER_MODES +import imageio.v3 as iio +from imgui_bundle import imgui + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560) +) + +figure[0, 0].add_image_volume(voldata, name="vol-img") + +# add an hlut tool +hlut = fpl.HistogramLUTTool(voldata, figure[0, 0]["vol-img"]) + +figure[0, 0].docks["right"].size = 80 +figure[0, 0].docks["right"].controller.enabled = False +figure[0, 0].docks["right"].add_graphic(hlut) +figure[0, 0].docks["right"].auto_scale(maintain_aspect=False) + + +class GUI(EdgeWindow): + def __init__(self, figure, title="Render options", location="right", size=300): + super().__init__(figure, title=title, location=location, size=size) + + # reference to the graphic for convenience + self.graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] + + def update(self): + imgui.text("Switch render mode:") + + # add buttons to switch between modes + for mode in VOLUME_RENDER_MODES.keys(): + if imgui.button(mode): + self.graphic.mode = mode + + # add sliders to change iso rendering properties + if self.graphic.mode == "iso": + _, self.graphic.threshold = imgui.slider_float( + "threshold", v=self.graphic.threshold, v_max=255, v_min=1, + ) + _, self.graphic.step_size = imgui.slider_float( + "step_size", v=self.graphic.step_size, v_max=10.0, v_min=0.1, + ) + _, self.graphic.substep_size = imgui.slider_float( + "substep_size", v=self.graphic.substep_size, v_max=10.0, v_min=0.1, + ) + _, self.graphic.emissive = imgui.color_picker3("emissive color", col=self.graphic.emissive.rgb) + + if self.graphic.mode == "slice": + imgui.text("Select plane defined by:\nax + by + cz + d = 0") + _, a = imgui.slider_float("a", v=self.graphic.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=self.graphic.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=self.graphic.plane[2], v_min=-1, v_max=1.0) + + largest_dim = max(self.graphic.data.value.shape) + _, d = imgui.slider_float("d", v=self.graphic.plane[3], v_min=0, v_max=largest_dim * 2) + + self.graphic.plane = (a, b, c, d) + +gui = GUI(figure=figure) +figure.add_gui(gui) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_share_buffer.py b/examples/image_volume/image_volume_share_buffer.py new file mode 100644 index 000000000..cc9f07915 --- /dev/null +++ b/examples/image_volume/image_volume_share_buffer.py @@ -0,0 +1,75 @@ +""" +Volume share buffers +==================== + +Share the data buffer between two graphics. This example creates one Graphic using MIP rendering, and another graphic +to display a slice of the volume. We can share the data buffer on the GPU between these graphics. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +from imgui_bundle import imgui +import fastplotlib as fpl +from fastplotlib.ui import EdgeWindow +import imageio.v3 as iio +from skimage.filters import gaussian + + +data = iio.imread("imageio:stent.npz") + + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560), +) + +# MIP rendering is the default `mode` +vol_mip = figure[0, 0].add_image_volume(gaussian(data, sigma=2.0)) + +# make another graphic to show a slice of the volume +vol_slice = figure[0, 0].add_image_volume( + vol_mip.data, # pass the data property from the previous volume so they share the same buffer on the GPU + mode="slice", + plane=(0, -0.5, -0.5, 50), + offset=(150, 0, 0) # place the graphic at x=150 +) + + +class GUI(EdgeWindow): + def __init__(self, figure, title="change data buffer", location="right", size=200): + super().__init__(figure, title=title, location=location, size=size) + self._sigma = 2 + + def update(self): + changed, self._sigma = imgui.slider_int("sigma", v=self._sigma, v_min=0, v_max=5) + + if changed: + vol_mip.data = gaussian(data, sigma=self._sigma) + vol_mip.reset_vmin_vmax() + vol_slice.reset_vmin_vmax() + + imgui.text("Select plane defined by:\nax + by + cz + d = 0") + _, a = imgui.slider_float("a", v=vol_slice.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=vol_slice.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=vol_slice.plane[2], v_min=-1, v_max=1.0) + + largest_dim = max(vol_slice.data.value.shape) + _, d = imgui.slider_float( + "d", v=vol_slice.plane[3], v_min=0, v_max=largest_dim * 2 + ) + + vol_slice.plane = (a, b, c, d) + +gui = GUI(figure) +figure.add_gui(gui) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_slicing_animation.py b/examples/image_volume/image_volume_slicing_animation.py new file mode 100644 index 000000000..ab671eec6 --- /dev/null +++ b/examples/image_volume/image_volume_slicing_animation.py @@ -0,0 +1,64 @@ +""" +Volume non-orthogonal slicing animation +======================================= + +Perform non-orthogonal slicing of image volumes. + +For an example with UI sliders see the "Volume modes" example. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 8s' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560) +) + +vol = figure[0, 0].add_image_volume(voldata, mode="slice") + +# a plane is defined by ax + by + cz + d = 0 +# the plane property sets (a, b, c, d) +vol.plane = (0, 0.5, 0.5, -20) + +# just a pre-saved camera state to view the plot area +state = { + "position": np.array([-110.0, 160.0, 240.0]), + "rotation": np.array([-0.25, -0.5, -0.15, 0.85]), + "scale": np.array([1., 1., 1.]), + "reference_up": np.array([0., 1., 0.]), + "fov": 50.0, + "width": 128.0, + "height": 128.0, + "depth": 315, + "zoom": 0.75, + "maintain_aspect": True, + "depth_range": None +} + +def update(): + # increase d by 1 + vol.plane = (0, 0.5, 0.5, vol.plane[-1] - 1) + if vol.plane[-1] < -200: + vol.plane = (0, 0.5, 0.5, -20) + +figure[0, 0].add_animations(update) + +figure.show() + +figure[0, 0].camera.set_state(state) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_toy_data.py b/examples/image_volume/image_volume_toy_data.py new file mode 100644 index 000000000..5c081542d --- /dev/null +++ b/examples/image_volume/image_volume_toy_data.py @@ -0,0 +1,31 @@ +""" +Volume rendering of toy data +============================ + +Volume rendering of toy trig data +""" + +import fastplotlib as fpl +import numpy as np + +n_cols = 100 +n_rows = 100 +z = 50 + +xs = np.linspace(0, 1_000, n_cols) + +sine = np.sin(np.sqrt(xs)) + +data = np.dstack([np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) * j for j in range(z)]) + +figure = fpl.Figure(cameras="3d", controller_types="orbit") + +volume = figure[0, 0].add_image_volume(data) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/line/line.py b/examples/line/line.py index fb8834759..f7839a1c4 100644 --- a/examples/line/line.py +++ b/examples/line/line.py @@ -16,25 +16,25 @@ xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] +sine_data = np.column_stack([xs, ys]) # cosine wave ys = np.cos(xs) + 5 -cosine = np.dstack([xs, ys])[0] +cosine_data = np.column_stack([xs, ys]) # sinc function a = 0.5 ys = np.sinc(xs) * 3 + 8 -sinc = np.dstack([xs, ys])[0] +sinc_data = np.column_stack([xs, ys]) -sine_graphic = figure[0, 0].add_line(data=sine, thickness=5, colors="magenta") +sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine_graphic = figure[0, 0].add_line(data=cosine, thickness=12, cmap="autumn") +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = figure[0, 0].add_line(data=sinc, thickness=5, colors=colors) +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) figure[0, 0].axes.grids.xy.visible = True figure.show() diff --git a/examples/line/line_cmap.py b/examples/line/line_cmap.py index af24f1c63..3d2b5e8c9 100644 --- a/examples/line/line_cmap.py +++ b/examples/line/line_cmap.py @@ -16,24 +16,24 @@ xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] +sine_data = np.column_stack([xs, ys]) # cosine wave ys = np.cos(xs) - 5 -cosine = np.dstack([xs, ys])[0] +cosine_data = np.column_stack([xs, ys]) # 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, +sine = figure[0, 0].add_line( + data=sine_data, thickness=10, cmap="plasma", - cmap_transform=sine[:, 1] + cmap_transform=sine_data[:, 1] ) # qualitative colormaps, useful for cluster labels or other types of categorical labels labels = [0] * 25 + [5] * 10 + [1] * 35 + [2] * 30 -cosine_graphic = figure[0, 0].add_line( - data=cosine, +cosine = figure[0, 0].add_line( + data=cosine_data, thickness=10, cmap="tab10", cmap_transform=labels diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py index 2d4c0dcaa..b6865eadb 100644 --- a/examples/line/line_colorslice.py +++ b/examples/line/line_colorslice.py @@ -16,26 +16,26 @@ xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.column_stack([xs, ys]) +sine_data = np.column_stack([xs, ys]) # cosine wave ys = np.cos(xs) -cosine = np.column_stack([xs, ys]) +cosine_data = np.column_stack([xs, ys]) # sinc function a = 0.5 ys = np.sinc(xs) * 3 -sinc = np.column_stack([xs, ys]) +sinc_data = np.column_stack([xs, ys]) -sine_graphic = figure[0, 0].add_line( - data=sine, +sine = figure[0, 0].add_line( + data=sine_data, thickness=5, colors="magenta" ) # you can also use colormaps for lines! -cosine_graphic = figure[0, 0].add_line( - data=cosine, +cosine = figure[0, 0].add_line( + data=cosine_data, thickness=12, cmap="autumn", offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data @@ -43,8 +43,8 @@ # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = figure[0, 0].add_line( - data=sinc, +sinc = figure[0, 0].add_line( + data=sinc_data, thickness=5, colors=colors, offset=(0, 6, 0) @@ -52,7 +52,7 @@ zeros = np.zeros(xs.size) zeros_data = np.column_stack([xs, zeros]) -zeros_graphic = figure[0, 0].add_line( +zeros = figure[0, 0].add_line( data=zeros_data, thickness=8, colors="w", @@ -62,25 +62,25 @@ figure.show() # indexing of colors -cosine_graphic.colors[:15] = "magenta" -cosine_graphic.colors[90:] = "red" -cosine_graphic.colors[60] = "w" +cosine.colors[:15] = "magenta" +cosine.colors[90:] = "red" +cosine.colors[60] = "w" # more complex indexing, set the blue value directly from an array -cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65) +cosine.colors[65:90, 0] = np.linspace(0, 1, 90 - 65) # additional fancy indexing using numpy 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" +sinc.colors[key] = "Red" # boolean fancy indexing -zeros_graphic.colors[xs < -5] = "green" +zeros.colors[xs < -5] = "green" # assign colormap to an entire line -sine_graphic.cmap = "seismic" +sine.cmap = "seismic" # or to segments of a line -zeros_graphic.cmap[50:75] = "jet" -zeros_graphic.cmap[75:] = "viridis" +zeros.cmap[50:75] = "jet" +zeros.cmap[75:] = "viridis" # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/line/line_dataslice.py b/examples/line/line_dataslice.py index ca0f48518..6ef9d0d90 100644 --- a/examples/line/line_dataslice.py +++ b/examples/line/line_dataslice.py @@ -16,35 +16,35 @@ xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] +sine_data = np.column_stack([xs, ys]) # cosine wave ys = np.cos(xs) + 5 -cosine = np.dstack([xs, ys])[0] +cosine_data = np.column_stack([xs, ys]) # sinc function a = 0.5 ys = np.sinc(xs) * 3 + 8 -sinc = np.dstack([xs, ys])[0] +sinc_data = np.column_stack([xs, ys]) -sine_graphic = figure[0, 0].add_line(data=sine, thickness=5, colors="magenta") +sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta") # you can also use colormaps for lines! -cosine_graphic = figure[0, 0].add_line(data=cosine, thickness=12, cmap="autumn") +cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn") # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = figure[0, 0].add_line(data=sinc, thickness=5, colors=colors) +sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors) 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]]) +cosine.data[10:50:5, :2] = sine_data[10:50:5] +cosine.data[90:, 1] = 7 +cosine.data[0] = np.array([[-10, 0, 0]]) # 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 +sinc.data[bool_key, 1] = 7 # y vals to 1 # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/misc-dev/garbage_collection.py b/examples/misc-dev/garbage_collection.py index baa92e848..56ef0792b 100644 --- a/examples/misc-dev/garbage_collection.py +++ b/examples/misc-dev/garbage_collection.py @@ -1,9 +1,9 @@ import numpy as np -from wgpu.gui.auto import WgpuCanvas, run +from rendercanvas.auto import RenderCanvas, loop import pygfx as gfx import subprocess -canvas = WgpuCanvas() +canvas = RenderCanvas() renderer = gfx.WgpuRenderer(canvas) scene = gfx.Scene() camera = gfx.OrthographicCamera(5000, 5000) @@ -28,7 +28,11 @@ def draw(): def print_nvidia(msg=""): print(msg) print( - subprocess.check_output(["nvidia-smi", "--format=csv", "--query-gpu=memory.used"]).decode().split("\n")[1] + subprocess.check_output( + ["nvidia-smi", "--format=csv", "--query-gpu=memory.used"] + ) + .decode() + .split("\n")[1] ) print() @@ -57,4 +61,4 @@ def remove_img(*args): renderer.add_event_handler(add_img, "double_click") draw() -run() +loop.run() diff --git a/examples/misc-dev/selector_performance.ipynb b/examples/misc-dev/selector_performance.ipynb index 39dbba6b8..bbbe8c40b 100644 --- a/examples/misc-dev/selector_performance.ipynb +++ b/examples/misc-dev/selector_performance.ipynb @@ -32,10 +32,10 @@ " xs = radius * np.sin(theta)\n", " ys = radius * np.cos(theta)\n", " zs = np.zeros(xs.size)\n", - " \n", + "\n", " xs += center[0]\n", " ys += center[1]\n", - " \n", + "\n", " return np.ascontiguousarray(np.column_stack([xs, ys, zs]).astype(np.float32))" ] }, @@ -76,7 +76,7 @@ }, "outputs": [], "source": [ - "from wgpu.gui.auto import WgpuCanvas, run\n", + "from rendercanvas.auto import RenderCanvas\n", "import pygfx as gfx" ] }, @@ -89,7 +89,7 @@ }, "outputs": [], "source": [ - "canvas = WgpuCanvas()\n", + "canvas = RenderCanvas()\n", "renderer = gfx.WgpuRenderer(canvas)" ] }, @@ -163,7 +163,7 @@ "\n", "for l in lines[100:1000]:\n", " l.visible = False\n", - " \n", + "\n", "# canvas.request_draw()\n", "\n", "time() - t1" @@ -186,7 +186,7 @@ " ys = np.sin(xs) * 10\n", " else:\n", " ys = np.cos(xs) * 10\n", - " \n", + "\n", " temporal.append(ys)" ] }, @@ -233,7 +233,7 @@ "def update_visible(ev):\n", " ixs_visible = ev.pick_info[\"selected_indices\"]\n", " ixs_hide = np.setdiff1d(np.arange(len(circles)), ixs_visible)\n", - " \n", + "\n", " # very fast, 20 ms to change 1,000\n", " for i, g in enumerate(contours.graphics):\n", " if not g.visible and i in ixs_visible:\n", @@ -320,10 +320,10 @@ "\n", "for c in contours.graphics[100:1000]:\n", " c.visible = True\n", - " \n", + "\n", "# for i in range(100, 1000):\n", "# contours.graphics[i].world_object.visible = True\n", - " \n", + "\n", "time() - t1" ] }, @@ -342,7 +342,7 @@ "for c in circles:\n", " start_offset += c.shape[0]\n", " zero_alpha_ixs += [start_offset - 1, start_offset]\n", - " \n", + "\n", "zero_alpha_ixs = zero_alpha_ixs[:-1]" ] }, @@ -370,13 +370,13 @@ "def set_visible_alpha(ev):\n", " ixs_visible = ev.pick_info[\"selected_indices\"]\n", " ixs_hide = np.setdiff1d(np.arange(len(circles)), ixs_visible)\n", - " \n", + "\n", " for i in ixs_visible:\n", " contours.world_object.geometry.colors.data[(i * 75) + 1:(i * 75) + 74, -1] = 1\n", - " \n", + "\n", " for i in ixs_hide:\n", " contours.world_object.geometry.colors.data[(i * 75) + 1:(i * 75) + 74, -1] = 0\n", - " \n", + "\n", " contours.world_object.geometry.colors.update_range()" ] }, diff --git a/examples/misc/cycle_animation.py b/examples/misc/cycle_animation.py index 833321453..d1a369c79 100644 --- a/examples/misc/cycle_animation.py +++ b/examples/misc/cycle_animation.py @@ -37,16 +37,16 @@ figure = fpl.Figure(size=(700, 560)) 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) +scatter = 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)) + scatter.colors[n_points * 2:, 0] = np.abs(np.sin(i)) + scatter.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4))) + scatter.colors[n_points * 2:, 2] = np.abs(np.cos(i)) i += 0.05 subplot_scatter.add_animations(cycle_colors) diff --git a/examples/misc/image_animation.py b/examples/misc/image_animation.py index 1f7ff6109..324a8e727 100644 --- a/examples/misc/image_animation.py +++ b/examples/misc/image_animation.py @@ -16,10 +16,10 @@ figure = fpl.Figure(size=(700, 560)) # plot the image data -image_graphic = figure[0, 0].add_image(data=data, name="random-image") +image = figure[0, 0].add_image(data=data, name="random-image") -# a function to update the image_graphic +# a function to update the image # 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) diff --git a/examples/misc/line3d_animation.py b/examples/misc/line3d_animation.py index c1d903e02..f718fff0a 100644 --- a/examples/misc/line3d_animation.py +++ b/examples/misc/line3d_animation.py @@ -19,11 +19,11 @@ zs = phi # make data 3d, with shape [, 3] -spiral = np.dstack([xs, ys, zs])[0] +spiral = np.column_stack([xs, ys, zs]) figure = fpl.Figure(cameras="3d", size=(700, 560)) -line_graphic = figure[0,0].add_line(data=spiral, thickness=3, cmap='jet') +line = figure[0,0].add_line(data=spiral, thickness=3, cmap='jet') marker = figure[0,0].add_scatter(data=spiral[0], sizes=10, name="marker") diff --git a/examples/misc/scatter_animation.py b/examples/misc/scatter_animation.py index ee8d2a10a..d37aea976 100644 --- a/examples/misc/scatter_animation.py +++ b/examples/misc/scatter_animation.py @@ -37,13 +37,13 @@ figure = fpl.Figure(size=(700, 560)) 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) +scatter = 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 + deltas = np.random.normal(size=scatter.data.value.shape, loc=0, scale=0.15) + scatter.data = scatter.data.value + deltas subplot_scatter.add_animations(update_points) diff --git a/examples/misc/tooltips_custom.py b/examples/misc/tooltips_custom.py index a62190906..d1cc1e297 100644 --- a/examples/misc/tooltips_custom.py +++ b/examples/misc/tooltips_custom.py @@ -23,7 +23,7 @@ agg = AgglomerativeClustering(n_clusters=3) agg.fit_predict(data) -scatter_graphic = figure[0, 0].add_scatter( +scatter = figure[0, 0].add_scatter( data=data[:, :-1], # use only xy data sizes=15, cmap="Set1", @@ -44,7 +44,7 @@ def tooltip_info(ev) -> str: return info -figure.tooltip_manager.register(scatter_graphic, custom_info=tooltip_info) +figure.tooltip_manager.register(scatter, custom_info=tooltip_info) figure.show() diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb index 0d8fc3c31..7b7551588 100644 --- a/examples/notebooks/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -99,7 +99,7 @@ "data = iio.imread(\"imageio:camera.png\")\n", "\n", "# plot the image data\n", - "image_graphic = fig[0, 0].add_image(data=data, name=\"sample-image\")\n", + "image = fig[0, 0].add_image(data=data, name=\"sample-image\")\n", "\n", "# show the plot\n", "fig.show(sidecar=True)" @@ -132,7 +132,7 @@ }, "outputs": [], "source": [ - "image_graphic.cmap = \"viridis\"" + "image.cmap = \"viridis\"" ] }, { @@ -158,7 +158,7 @@ "source": [ "# some graphic properties behave like arrays\n", "# access the underlying array using .values\n", - "image_graphic.data.value.shape" + "image.data.value.shape" ] }, { @@ -170,8 +170,8 @@ }, "outputs": [], "source": [ - "image_graphic.data[::15, :] = 1\n", - "image_graphic.data[:, ::15] = 1" + "image.data[::15, :] = 1\n", + "image.data[:, ::15] = 1" ] }, { @@ -191,7 +191,7 @@ }, "outputs": [], "source": [ - "image_graphic.data[data > 175] = 255" + "image.data[data > 175] = 255" ] }, { @@ -211,8 +211,8 @@ }, "outputs": [], "source": [ - "image_graphic.vmin = 50\n", - "image_graphic.vmax = 150" + "image.vmin = 50\n", + "image.vmax = 150" ] }, { @@ -281,7 +281,7 @@ }, "outputs": [], "source": [ - "image_graphic.data = gray" + "image.data = gray" ] }, { @@ -303,7 +303,7 @@ }, "outputs": [], "source": [ - "image_graphic.reset_vmin_vmax()" + "image.reset_vmin_vmax()" ] }, { @@ -432,7 +432,7 @@ }, "outputs": [], "source": [ - "image_graphic" + "image" ] }, { @@ -444,7 +444,7 @@ }, "outputs": [], "source": [ - "image_graphic == fig[0, 0][\"sample-image\"]" + "image == fig[0, 0][\"sample-image\"]" ] }, { @@ -561,7 +561,7 @@ "# plot the data\n", "fig_v[0, 0].add_image(data=data, name=\"random-image\")\n", "\n", - "# a function to update the image_graphic\n", + "# a function to update the image\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", @@ -602,13 +602,13 @@ "\n", "data = np.random.rand(512, 512)\n", "\n", - "image_graphic_instance = fig_sync[0, 0].add_image(data=data, cmap=\"viridis\")\n", + "image = 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 subplot\n", - " image_graphic_instance.data = new_data\n", + " image.data = new_data\n", "\n", "fig_sync.add_animations(update_data_2)\n", "\n", @@ -620,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 it is convenient to keep your own reference to a `Graphic`" + "#### Keeping a reference to the Graphic instance, as shown above `image`, is useful if you're creating something where it is convenient to keep your own reference to a `Graphic`" ] }, { @@ -678,7 +678,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8e8280da-b421-43a5-a1a6-2a196a408e9a", + "id": "72a665d195e61427", "metadata": {}, "outputs": [], "source": [ @@ -686,16 +686,16 @@ "xs = np.linspace(-10, 10, 100)\n", "# sine wave\n", "ys = np.sin(xs)\n", - "sine = np.column_stack([xs, ys])\n", + "sine_data = np.column_stack([xs, ys])\n", "\n", "# cosine wave\n", "ys = np.cos(xs) + 5\n", - "cosine = np.column_stack([xs, ys])\n", + "cosine_data = np.column_stack([xs, ys])\n", "\n", "# sinc function\n", "a = 0.5\n", "ys = np.sinc(xs) * 3 + 8\n", - "sinc = np.column_stack([xs, ys])" + "sinc_data = np.column_stack([xs, ys])" ] }, { @@ -709,7 +709,7 @@ { "cell_type": "code", "execution_count": null, - "id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7", + "id": "647c268622cc813", "metadata": {}, "outputs": [], "source": [ @@ -720,14 +720,14 @@ "subplot = fig_lines[0, 0]\n", "\n", "# plot sine wave, use a single color\n", - "sine_graphic = subplot.add_line(data=sine, thickness=5, colors=\"magenta\")\n", + "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\")\n", "\n", "# you can also use colormaps for lines!\n", - "cosine_graphic = subplot.add_line(data=cosine, thickness=12, cmap=\"autumn\")\n", + "cosine = subplot.add_line(data=cosine_data, 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 = subplot.add_line(data=sinc, thickness=5, colors = colors)\n", + "sinc = subplot.add_line(data=sinc_data, thickness=5, colors = colors)\n", "\n", "# show the plot\n", "fig_lines.show(sidecar=True, sidecar_kwargs={\"title\": \"lines\"})" @@ -736,10 +736,8 @@ { "cell_type": "code", "execution_count": null, - "id": "a4060576-2f29-4e4b-a86a-0410c766bd98", - "metadata": { - "tags": [] - }, + "id": "80e1f1bd7ee957e9", + "metadata": {}, "outputs": [], "source": [ "# testing cell, ignore\n", @@ -791,21 +789,21 @@ { "cell_type": "code", "execution_count": null, - "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", + "id": "6b7b377fcf487815", "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", + "cosine.colors[:15] = \"magenta\"\n", + "cosine.colors[90:] = \"red\"\n", + "cosine.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", + "sinc.cmap[10:50] = \"gray\"\n", + "sine.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)" + "cosine.colors[65:90, 0] = np.linspace(0, 1, 90 - 65)" ] }, { @@ -827,7 +825,7 @@ " print(event_data)\n", "\n", "# Will print event data when the color changes\n", - "cosine_graphic.add_event_handler(callback_func, \"colors\")" + "cosine.add_event_handler(callback_func, \"colors\")" ] }, { @@ -839,7 +837,7 @@ "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\"" + "cosine.colors[15:50:3] = \"cyan\"" ] }, { @@ -866,22 +864,22 @@ { "cell_type": "code", "execution_count": null, - "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", + "id": "2b2d68d0d8cc321a", "metadata": {}, "outputs": [], "source": [ - "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", - "cosine_graphic.data[90:, 1] = 7" + "cosine.data[10:50:5, :2] = sine_data[10:50:5]\n", + "cosine.data[90:, 1] = 7" ] }, { "cell_type": "code", "execution_count": null, - "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", + "id": "d43b9aeca4bb8ddd", "metadata": {}, "outputs": [], "source": [ - "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" + "cosine.data[0] = np.array([[-10, 0, 0]])" ] }, { @@ -1356,7 +1354,7 @@ "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 = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)\n", + "scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)\n", "\n", "fig_scatter.show(sidecar=True)" ] @@ -1377,7 +1375,7 @@ "outputs": [], "source": [ "# half of the first cloud's points to red\n", - "scatter_graphic.colors[:n_points:2] = \"r\"" + "scatter.colors[:n_points:2] = \"r\"" ] }, { @@ -1388,7 +1386,7 @@ "outputs": [], "source": [ "# other half of the first cloud's points to purple\n", - "scatter_graphic.colors[1:n_points:2] = \"purple\"" + "scatter.colors[1:n_points:2] = \"purple\"" ] }, { @@ -1399,7 +1397,7 @@ "outputs": [], "source": [ "# set the green value directly\n", - "scatter_graphic.colors[n_points:n_points * 2, 1] = 0.3" + "scatter.colors[n_points:n_points * 2, 1] = 0.3" ] }, { @@ -1410,7 +1408,7 @@ "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)" + "scatter.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)" ] }, { @@ -1421,7 +1419,7 @@ "outputs": [], "source": [ "# change the data, change y-values\n", - "scatter_graphic.data[n_points:n_points * 2, 1] += 15" + "scatter.data[n_points:n_points * 2, 1] += 15" ] }, { @@ -1432,7 +1430,7 @@ "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)" + "scatter.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)" ] }, { @@ -1473,8 +1471,8 @@ "source": [ "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", + " deltas = np.random.normal(size=scatter.data.value.shape, loc=0, scale=0.15)\n", + " scatter.data = scatter.data[:] + deltas\n", "\n", "subplot_scatter.add_animations(update_points)" ] @@ -1498,9 +1496,9 @@ "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", + " scatter.colors[n_points * 2:, 0] = np.abs(np.sin(i))\n", + " scatter.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4)))\n", + " scatter.colors[n_points * 2:, 2] = np.abs(np.cos(i))\n", " i += 0.05\n", "\n", "subplot_scatter.add_animations(cycle_colors)" @@ -1979,7 +1977,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.11.12" } }, "nbformat": 4, diff --git a/examples/notebooks/screenshots/nb-astronaut.png b/examples/notebooks/screenshots/nb-astronaut.png index 2370c5988..405b26e14 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:0a6e8bb3c72f1be6915e8e78c9a4f269419cfb4faded16e39b5cb11d70bec247 -size 64185 +oid sha256:dbcc22edca9b0dd74da7754a8515c9000d6e651af39135ab90d5a1eaf438d324 +size 66559 diff --git a/examples/notebooks/screenshots/nb-astronaut_RGB.png b/examples/notebooks/screenshots/nb-astronaut_RGB.png index 2a7eac585..0e3d21ef6 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:9f9f32e86018f87057435f7121b02bbe98823444babb330645bab618e1d586b7 -size 63838 +oid sha256:1b8f9fc3d5e14c1f90c48128be5599d64e994dcce8579bf9276453d68dfdf022 +size 64354 diff --git a/examples/notebooks/screenshots/nb-camera.png b/examples/notebooks/screenshots/nb-camera.png index bfe226ca4..f58781bb1 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:2964d0150b38f990a7b804e9057f99505e8c99bb04538a13137989d540704593 -size 47456 +oid sha256:e744172658cf49cd7725c8ee1f3e983a3284848f10fc5805b17ae655e439cbda +size 50393 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 2578ad028..181b9cb64 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:78e7e99fafc15cc6edf53cfb2e5b679623ad14e0d594e0ad615088e623be22e1 -size 60988 +oid sha256:29b42b78551ad2e986eb01d996f261ef9499f059ba285cc6e37fb903df090826 +size 64007 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 0129cb423..c8761fc8a 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:d3f5a721456b5a54e819fc987b8fa1f61d638f578339a7332ad46a22e7aa8fc0 -size 112674 +oid sha256:c9f6c3d3a76f2fbbbdcf8107e8b2db7900079d252384f7f722649c3ebbe92993 +size 115525 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 0129cb423..c8761fc8a 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:d3f5a721456b5a54e819fc987b8fa1f61d638f578339a7332ad46a22e7aa8fc0 -size 112674 +oid sha256:c9f6c3d3a76f2fbbbdcf8107e8b2db7900079d252384f7f722649c3ebbe92993 +size 115525 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 4908c8b59..7ded7f21b 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:4511a28e728af412f5006bb456f133aea1fdc9c1922c3174f127c79d9878401d -size 133635 +oid sha256:45c6c01e5fd26b06af55c0e2b98d8abd40603599dd53be3838479a447e099af8 +size 137111 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 cfdc3c8a9..381ea8d70 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:c6910106cd799a4327a6650edbc956ddb9b6a489760b86b279c593575ae805b8 -size 120114 +oid sha256:415ef8938bc982678e1402da5ab40e991f600302c13a8beda9690baa672e2289 +size 124095 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 92513cf5b..451d1060a 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:8233dfc429a7fefe96f0fdb89eb2c57188b7963c16db5d1d08f7faefb45d8cb7 -size 105755 +oid sha256:9439c5a18046ee0a9125de2fd5ecf5b5e605579d844dab9a741a1fd0833d32d3 +size 108554 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 8bce59baf..a4031beb3 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:a4af684cdaec8f98081862eb8a377cd419efec64cdf08b662a456276b78f1fb5 -size 98091 +oid sha256:926298948f78d7f205cd95dffc1623f2980a7a3667c45e752daa96b1f64d7cf1 +size 101047 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 61c3c4f6c..264ab5c2e 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:133dfe6b0028dda6248df1afde1288c57625be99b25c8224673597de4d4f70fc -size 118588 +oid sha256:1a682eba89f4456cf381679da67113741bcaace7a540b68b31e6276e6a8f95ce +size 121705 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 61c3c4f6c..264ab5c2e 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:133dfe6b0028dda6248df1afde1288c57625be99b25c8224673597de4d4f70fc -size 118588 +oid sha256:1a682eba89f4456cf381679da67113741bcaace7a540b68b31e6276e6a8f95ce +size 121705 diff --git a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png index e8c02adfe..147f852bb 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:8c8562f8e1178cf21af98af635006c64010f3c5fc615533d1df8c49479232843 -size 217693 +oid sha256:ae9f5efe64ff9ef3be7cb22444f4afd6430642b653c6b1337167aca4e06c7d81 +size 225650 diff --git a/examples/notebooks/screenshots/nb-image-widget-single.png b/examples/notebooks/screenshots/nb-image-widget-single.png index 8de4099fb..4d86e17aa 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:5c9bae3c9c5521a4054288be7ae548204fc7b0eafbc3e99cb6b649e0be797169 -size 207176 +oid sha256:0dc443374648f10112d4afa323a1e2ffff170ebbcbf7b640fc4dc07379f14d5d +size 216362 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 29fe20f44..ca40fe5ed 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:87a3947d6c59c7f67acca25911e0ab93ddc9231a8c3060d2fffe3c53f39055f2 -size 62263 +oid sha256:015933efd89517a65d73d9c91423e438260b7115fbf55386ad4b35ec72f39cb6 +size 64820 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 c7944f591..fe1f9d0ad 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:b57c65974362d258ec7be8de391c41d7909ed260b92411f4b0ed8ed03b886a29 -size 73040 +oid sha256:97dcdc4ab1a4307507b090dc2e8f7d2c5998828000be4e3b67fcf3941b835473 +size 69210 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 eb9c9059d..640bed79b 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:008381b267ae26e8693ae51e7a4fabc464288ec8aa911ff3a1deb37543cc4fbe -size 115543 +oid sha256:6ba7d7fe7daae9e553f5ca45029822ebb432953c62cf301e9922a752ea438f17 +size 113828 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 8b887f5fd..9198e2ad6 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:fedfec781724d4731f8cc34ffc39388d14dc60dad4a9fae9ff56625edf11f87a -size 94178 +oid sha256:374bbf072443b243c0393d5311fb361a6f089b22e5b60b6a80c90da20ac18a8d +size 97296 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 ef3aa7a92..ce3f25a67 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:08e8379187754fa14f360ed54f2ed8cf61b3df71a8b6f2e95ff1ed27aa435d60 -size 90105 +oid sha256:eb0c0814509246ca64f9a820dac4617d55fa6be28ec59e787719b653f60d5818 +size 89409 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 c7944f591..fe1f9d0ad 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:b57c65974362d258ec7be8de391c41d7909ed260b92411f4b0ed8ed03b886a29 -size 73040 +oid sha256:97dcdc4ab1a4307507b090dc2e8f7d2c5998828000be4e3b67fcf3941b835473 +size 69210 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 0d19a35ce..19e84868b 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:848e89e38b9b5ef97d6bb4b301c0ae10cc29f438518721663ae52fa42f492408 -size 65267 +oid sha256:9d286be8d9ed81b84d2ebfb308287ae7df17dfad4860fde8faf41687958a6f40 +size 60572 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 96a3b12c8..20b5c0a50 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:17cd05ae14cacdef6aa1eca3544246b814ef21762a33f6e785f6d621ea30ff96 -size 80570 +oid sha256:7c86f37cd4ad4e4a34e402008ec52fbe965d52e79b5ad8210af8813f8e4e3ed3 +size 85103 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 1df19c904..9e368022a 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:a673fa1ffa6f746ab9f462b4d592492ec02bfdd3fb53bdf1f71fb9427f8d6d23 -size 105798 +oid sha256:c6801347572dfd3a4be34cdd18915dc8dd4787764d3c8634b34b66b2c3f130d7 +size 102913 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 43230f8be..486934731 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:446d54cea3d54b0fd92b70abcc090cfee30b19454dce118d9875fbeb8b40b4a8 -size 141294 +oid sha256:02e91b1c0a327c6784387d279c34550bd74c5ed7e4c6587a2ef20f6f00204ff1 +size 143096 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 0841a8e08..05a88a73e 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:99d3706d5574a1236264f556eb3ce6d71e81b65bd8dcce1c1415e5f139316c23 -size 107894 +oid sha256:e30d25ac2af4d897e2e562b31bf1b92d199e34b1da4baad35d110dc8dcb042ba +size 115631 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 28bab9f02..25c537801 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:ffa17fc1b71c5146cae88493ed40c606dd0a99f3e10f3827ac349d5a5d6f6108 -size 112702 +oid sha256:9d74bacc8c31e9b7896f46230a7734488c1d1ee5f833584f6be265d04b28860d +size 117556 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 1df19c904..9e368022a 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:a673fa1ffa6f746ab9f462b4d592492ec02bfdd3fb53bdf1f71fb9427f8d6d23 -size 105798 +oid sha256:c6801347572dfd3a4be34cdd18915dc8dd4787764d3c8634b34b66b2c3f130d7 +size 102913 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 06ed02628..659eec9de 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:4d3e88eee05bc68dd17918197602fb5c0a959ad74a4f592aea4514e570d29232 -size 103431 +oid sha256:9ec15239b78880daafe3b5c18101019a30cd51db465c515bfc275bce9729eb5a +size 100085 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 61702a6d9..e73518f67 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:272156c4261bba40eba92f953a0f5078ad8ff2aa80f06a53f73a3572eb537dd5 -size 111155 +oid sha256:5bc163776b12df7289bcf6e4ed160c843cd7bae12d48c8de90e2fda7039b1755 +size 111929 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 412822a40..b0d74d7e1 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:8203f859fe54e2b59a143a9a569c2854640b1501b9ab4f8512520bbf73dae3c6 -size 105658 +oid sha256:d4a1e4b59103361750bb76e113db7b2a49f508e53c45086c11dcea01b72c40a6 +size 102983 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 234924487..a7cba3900 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:8ca187ba67e7928c8f96b1f9a0a18bec65f81352701e60c33d47aaadb2756d5c -size 106446 +oid sha256:db7ca9b37fffdfbebe31a439b12c4b83a73c7893634ddfb676640035b01105da +size 104387 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 870945ce7..b288984e2 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:f42367c833a23d3fe10c6fb0d754338c12a30288d9769ad3f8b1159505abf8ff -size 78796 +oid sha256:fd8b96126d72687ef5ef79990f096faff8317d8be84181ee666677e92b303677 +size 76841 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 index 7880fc1d8..27b6d6df4 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb99cd81a18fa2f8986c5f00071c45dc778c8aa177f4b02dca6bc5fab122b054 -size 114825 +oid sha256:987eb20fcfdfb95a60e3c5cf8094bbcf4d899e6b1416ce17fc9f924e71330da4 +size 112164 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 index 82f3d0a9b..67500d292 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31b2b92b9d983950b58b90a09f16199740e35a0737fc1b18904f507ea322d8f2 -size 111118 +oid sha256:8eeb0548062ce9498b38b4f876d3d81f7de4f02b1b4fd5a47744007852d546bf +size 107522 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 index 1446c8941..1740e97de 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fb724e005c6e081ae3bf235e155f3f526c3480facac7479d9b9452aae81baf0 -size 111437 +oid sha256:33e57f3dba3a865f433ddc4c613f302f7b5b34053b5eb05d0b9c6d5003ef6194 +size 109368 diff --git a/examples/notebooks/screenshots/nb-lines-3d.png b/examples/notebooks/screenshots/nb-lines-3d.png index fb84ef21a..e54c6fede 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:c70c01b3ade199864df227a44fb28a53626df3beecee722a7b782c9a9f4658d8 -size 19907 +oid sha256:e09b9a15d83d3c0ca45a136642f5c9da0fcf2b5fee494a522fbbe9fa6b9db9a5 +size 18097 diff --git a/examples/notebooks/screenshots/nb-lines-colors.png b/examples/notebooks/screenshots/nb-lines-colors.png index ab221d83f..ca21cdd76 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:3b238b085eddb664ff56bd265423d85b35fc70769ebec050b27fefa8fe6380de -size 35055 +oid sha256:a40192f07f4bce34832aae2c11b28806547e15fccf543e90c09f457954ef422f +size 31081 diff --git a/examples/notebooks/screenshots/nb-lines-data.png b/examples/notebooks/screenshots/nb-lines-data.png index 44b142f55..022d660c8 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:4df736ec3ea90478930a77437949977f8e30f7d9272f65ef9f4908f2103dd11e -size 40679 +oid sha256:c790c3b23101a04bbab300b55d6bfdd1421fb0a0e81c5bff5f75a271e32ccab9 +size 37643 diff --git a/examples/notebooks/screenshots/nb-lines-underlay.png b/examples/notebooks/screenshots/nb-lines-underlay.png index f4a5b4e76..f738b2293 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:3a8b59386015b4c1eaa85c33c7b041d566ac1ac76fbba829075e9a3af021bedf -size 46228 +oid sha256:fda16460bb690ef30bfc43a3cbb4336f5bbd190ef592381269cde5f250ae7a36 +size 50977 diff --git a/examples/notebooks/screenshots/nb-lines.png b/examples/notebooks/screenshots/nb-lines.png index 8c86b48d0..2adc08fd6 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:823558e877830b816cc87df0776a92d5316d98a4f40e475cbf997b597c5eb8de -size 30338 +oid sha256:ecb09d9358e54a56fdd92c02d8e0f43886babfad5053086c50063bc7c8ff409c +size 23595 diff --git a/examples/notebooks/screenshots/no-imgui-nb-astronaut.png b/examples/notebooks/screenshots/no-imgui-nb-astronaut.png index 9f9e2013a..25a1ebb6b 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-astronaut.png +++ b/examples/notebooks/screenshots/no-imgui-nb-astronaut.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4758a94e6c066d95569515c0bff8e4c9ec383c65c5928a827550c142214df085 -size 72372 +oid sha256:08ec733c8e28069d226ded849d9dbd2fb2fea0dac91dd4691823a2bf077cc6b0 +size 73878 diff --git a/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png b/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png index 23d1bd906..f1cc52d2b 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png +++ b/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb3c72edc6f41d6f77e44bc68e7f5277525d2548d369925827c14d855dc33bbd -size 71588 +oid sha256:40ed1cf02ff2f8d715a2fe0dfa0589f7f7daee043ab3bd1bfa4279b8356483b4 +size 71445 diff --git a/examples/notebooks/screenshots/no-imgui-nb-camera.png b/examples/notebooks/screenshots/no-imgui-nb-camera.png index 22c70a760..be5a96a55 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-camera.png +++ b/examples/notebooks/screenshots/no-imgui-nb-camera.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6de3880cc22a8f6cdb77305e4d5be520fe92fd54a9a107bdbddf1e6f72c19262 -size 52157 +oid sha256:6a7b1e20fa0f6c70aaf6458c20b2fec6ffff399493ad3d15c7fa706f08adb0c0 +size 54983 diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png b/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png index 1a5a7b548..f405fb9b0 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png +++ b/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0e63c918aac713af2015cb85289c9451be181400834b0f60bcbb50564551f08 -size 20546 +oid sha256:d3ebb5155b5451ff86ac15a141303964c90fee662d4c8291dec80410ebfc6dd8 +size 17898 diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png b/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png index cdce4bf46..435e1fe96 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png +++ b/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bd481f558907ac1af97bd7ee08d58951bada758cc32467c73483fa66e4602f8 -size 36206 +oid sha256:31d330b3d1c48156223c3b744a2997ca33db8a86dffae2717e3f2dc231182732 +size 31672 diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-data.png b/examples/notebooks/screenshots/no-imgui-nb-lines-data.png index 8923be766..a02431728 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-lines-data.png +++ b/examples/notebooks/screenshots/no-imgui-nb-lines-data.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea39e2651408431ad5e49af378828a41b7b377f7f0098adc8ce2c7b5e10d0234 -size 43681 +oid sha256:33aa8cded99e514a582d6fd92a310504ec186760029b398d23eace4a9eb9f0a8 +size 39445 diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png b/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png index b6b4cf340..426003b01 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png +++ b/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a8d4aba2411598ecae1b7f202fbb1a1fa7416a814b7b4c5fdd1e0e584cdb06a -size 49343 +oid sha256:2a4af7eb6420b8c25cc6ed173df66f8fd90fafbfa7e17c08e2d8ff028c2a160b +size 53702 diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines.png b/examples/notebooks/screenshots/no-imgui-nb-lines.png index 5d03421a4..51578e9dd 100644 --- a/examples/notebooks/screenshots/no-imgui-nb-lines.png +++ b/examples/notebooks/screenshots/no-imgui-nb-lines.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2fdaf79703c475521184ab9dc948d3e817160b0162e9d88fcb20207225d0233 -size 31153 +oid sha256:94c64dfe5bef35e68e1800f5d65e2453ea35378ac44de73958bcc6083d57ea0d +size 23544 diff --git a/examples/notebooks/test_gc.ipynb b/examples/notebooks/test_gc.ipynb index df08e7a2d..92000f27e 100644 --- a/examples/notebooks/test_gc.ipynb +++ b/examples/notebooks/test_gc.ipynb @@ -120,6 +120,11 @@ "\n", "for g in objects:\n", " for feature in g._features:\n", + " if not hasattr(g, f\"_{feature}\"):\n", + " continue\n", + "\n", + " if getattr(g, f\"_{feature}\") is None:\n", + " continue # not in the right mode to support this feature\n", " g.add_event_handler(feature_changed_handler, feature)" ] }, diff --git a/examples/scatter/scatter_cmap_iris.py b/examples/scatter/scatter_cmap_iris.py index 139554dae..24be0c13c 100644 --- a/examples/scatter/scatter_cmap_iris.py +++ b/examples/scatter/scatter_cmap_iris.py @@ -20,7 +20,7 @@ agg = AgglomerativeClustering(n_clusters=3) agg.fit_predict(data) -scatter_graphic = figure[0, 0].add_scatter( +scatter = figure[0, 0].add_scatter( data=data[:, :-1], # use only xy data sizes=15, alpha=0.7, @@ -30,7 +30,7 @@ figure.show() -scatter_graphic.cmap = "tab10" +scatter.cmap = "tab10" if __name__ == "__main__": diff --git a/examples/scatter/scatter_colorslice.py b/examples/scatter/scatter_colorslice.py index a3cacee55..5b18d2e0e 100644 --- a/examples/scatter/scatter_colorslice.py +++ b/examples/scatter/scatter_colorslice.py @@ -36,15 +36,17 @@ 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) +figure[0, 0].add_scatter( + data=cloud, sizes=3, colors=colors, alpha_mode="weighted_blend", alpha=0.6 +) figure.show() -scatter_graphic = figure[0, 0].graphics[0] +scatter = figure[0, 0].graphics[0] -scatter_graphic.colors[0:75] = "red" -scatter_graphic.colors[75:150] = "white" -scatter_graphic.colors[::2] = "blue" +scatter.colors[0:75] = "red" +scatter.colors[75:150] = "white" +scatter.colors[::2] = "blue" # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide diff --git a/examples/scatter/scatter_colorslice_iris.py b/examples/scatter/scatter_colorslice_iris.py index 725374ef7..d9dc3053f 100644 --- a/examples/scatter/scatter_colorslice_iris.py +++ b/examples/scatter/scatter_colorslice_iris.py @@ -19,18 +19,19 @@ n_points = 50 colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points -scatter_graphic = figure[0, 0].add_scatter( +scatter = figure[0, 0].add_scatter( data=data[:, :-1], sizes=6, alpha=0.7, - colors=colors # use colors from the list of strings + alpha_mode="weighted_blend", # blend overlapping dots + colors=colors, # use colors from the list of strings ) figure.show() -scatter_graphic.colors[0:75] = "red" -scatter_graphic.colors[75:150] = "white" -scatter_graphic.colors[::2] = "blue" +scatter.colors[0:75] = "red" +scatter.colors[75:150] = "white" +scatter.colors[::2] = "blue" if __name__ == "__main__": diff --git a/examples/scatter/scatter_dataslice_iris.py b/examples/scatter/scatter_dataslice_iris.py index cc688eeb4..81df632ae 100644 --- a/examples/scatter/scatter_dataslice_iris.py +++ b/examples/scatter/scatter_dataslice_iris.py @@ -20,16 +20,16 @@ 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) +scatter = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) 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]]) +scatter.data[0] = np.array([[5, 3, 1.5]]) +scatter.data[1] = np.array([[4.3, 3.2, 1.3]]) +scatter.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]) +scatter.data[10:15] = scatter.data[0:5] + np.array([1, 1, 1]) +scatter.data[50:100:2] = scatter.data[100:150:2] + np.array([1, 1, 0]) if __name__ == "__main__": diff --git a/examples/scatter/scatter_image_as_points.py b/examples/scatter/scatter_image_as_points.py new file mode 100644 index 000000000..aeae30bd0 --- /dev/null +++ b/examples/scatter/scatter_image_as_points.py @@ -0,0 +1,55 @@ +""" +Scatter image as points +======================= + +Display a scatter using an image as the points. These are also called sprites. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +xs = np.linspace(0, 2 * np.pi, 10) + +# make sine and cosine data +sine = np.column_stack([xs, np.sin(xs)]) +cosine = np.column_stack([xs, np.cos(xs)]) + +# a simple image to display as the points +array = np.array([ + [1, 0, 1], + [0, 1, 0], + [1, 1, 1], +]) + +# load an image of Almar's cat +wikkie = np.flipud(iio.imread("imageio:wikkie.png")) + +figure = fpl.Figure(size=(700, 350)) + +scatter = figure[0, 0].add_scatter( + data=sine, + mode="image", # mode must be "image", otherwise the `image` arg is ignored and markers are used + image=array, + cmap="jet", # the image is multiplied by the scatter point colors if provided + sizes=25, +) + +scatter2 = figure[0, 0].add_scatter( + data=cosine, + mode="image", + image=wikkie, # if an RGB(A) image is provided and no colors are provided, then the image is shown as-is + sizes=40, +) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py index 94c8acca1..b9df16026 100644 --- a/examples/scatter/scatter_iris.py +++ b/examples/scatter/scatter_iris.py @@ -6,24 +6,36 @@ """ # test_example = true -# sphinx_gallery_pygfx_docs = 'hidden' +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -from pathlib import Path -import sys +from sklearn.cluster import AgglomerativeClustering +from sklearn import datasets + figure = fpl.Figure(size=(700, 560)) -current_file = Path(sys.argv[0]).resolve() +data, target = datasets.load_iris(return_X_y=True) +data = data[:, :2] # use only first 2 features + +# map target class to scatter point marker +markers_map = {0: "o", 1: "s", 2: "+"} +markers = list(map(markers_map.get, target)) -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) +agg = AgglomerativeClustering(n_clusters=3) +agg.fit_predict(data) -n_points = 50 -colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points +clusters_labels = agg.labels_ -scatter_graphic = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) +scatter = figure[0, 0].add_scatter( + data=data, + sizes=10, + alpha=0.7, + cmap="tab10", + cmap_transform=clusters_labels, + markers=markers, +) figure.show() diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py new file mode 100644 index 000000000..abddffee0 --- /dev/null +++ b/examples/scatter/scatter_validate.py @@ -0,0 +1,77 @@ +""" +Scatter validation +================== + +Example that shows some scatter plot features for test validation. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + +xs = np.linspace(0, 2 * np.pi, 10) + +# make sine and cosine data +sine = np.column_stack([xs, np.sin(xs)]) +cosine = np.column_stack([xs, np.cos(xs)]) + +# a simple image to display as the points +array = np.array([ + [1, 0, 1], + [0, 1, 0], + [1, 1, 1], +]) + +# load an image of Almar's cat +wikkie = np.flipud(iio.imread("imageio:wikkie.png")) + +figure = fpl.Figure( + size=(700, 560) +) + +figure[0, 0].add_scatter(sine) + +# combinations of per-point markers, colors and edge colors +figure[0, 0].add_scatter( + sine, + colors=["magenta"] * 3 + ["cyan"] * 3 + ["yellow"] * 3 + ["purple"], + uniform_edge_color=False, + edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"], + markers=list("osD+x^v<>*"), + edge_width=2.0, + sizes=20, + uniform_size=True, +) + + +# per-point rotations +figure[0, 0].add_scatter( + sine, + markers="^", + sizes=20, + point_rotation_mode="vertex", + point_rotations=xs, + uniform_size=True, + offset=(0, 1, 0) +) + + +# point sizes +figure[0, 0].add_scatter( + sine, + markers="s", + sizes=xs * 5, + offset=(0, 2, 0) +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/scatter/spinning_spiral.py b/examples/scatter/spinning_spiral.py index 80e893301..89e74eaec 100644 --- a/examples/scatter/spinning_spiral.py +++ b/examples/scatter/spinning_spiral.py @@ -34,7 +34,7 @@ canvas_kwargs={"max_fps": 500, "vsync": False} ) -spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", alpha=0.5, sizes=sizes) +spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", edge_colors=None, alpha=0.5, sizes=sizes) # pre-generate normally distributed data to jitter the points before each render jitter = np.random.normal(scale=0.001, size=n * 3).reshape((n, 3)) diff --git a/examples/screenshots/extent_frac_layout.png b/examples/screenshots/extent_frac_layout.png index 7fe6d3d37..1ee20fa64 100644 --- a/examples/screenshots/extent_frac_layout.png +++ b/examples/screenshots/extent_frac_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5991b755432318310cfc2b4826bd9639cc234883aa06f1895817f710714cb58f -size 156297 +oid sha256:23cd21882c3599d7b911c70df62d38aac9bc2d180dbbf05dd40b9561c6c2329a +size 144438 diff --git a/examples/screenshots/extent_layout.png b/examples/screenshots/extent_layout.png index dec391ac2..27eb67784 100644 --- a/examples/screenshots/extent_layout.png +++ b/examples/screenshots/extent_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cf23f845932023789e0823a105910e9f701d0f03c04e3c18488f0da62420921 -size 123409 +oid sha256:e5316e61ae1fb48acc439f38f77306d17834e8dd295c98a58980a803354651dd +size 117570 diff --git a/examples/screenshots/gridplot.png b/examples/screenshots/gridplot.png index 08e6d6b78..6853e1274 100644 --- a/examples/screenshots/gridplot.png +++ b/examples/screenshots/gridplot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f424ec68dbc0761566cd147f3bf5b8f15e4126c3b30b2ff47b6fb48f04d512a -size 252269 +oid sha256:51c44285622a9abd974a7d5abcc5506778252fbcdf68d874a73aa58e820b9655 +size 260627 diff --git a/examples/screenshots/gridplot_non_square.png b/examples/screenshots/gridplot_non_square.png index 781de8749..4138827bd 100644 --- a/examples/screenshots/gridplot_non_square.png +++ b/examples/screenshots/gridplot_non_square.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ac9ee6fd1118a06a1f0de4eee73e7b6bee188c533da872c5cbaf7119114414f -size 194385 +oid sha256:49b726b8a48445936ed6ecd104a7221d11549efe09a75c9deb44679a4fe0b195 +size 198943 diff --git a/examples/screenshots/gridplot_viewports_check.png b/examples/screenshots/gridplot_viewports_check.png index b1faf9b69..77793538a 100644 --- a/examples/screenshots/gridplot_viewports_check.png +++ b/examples/screenshots/gridplot_viewports_check.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67dd50d61a0caaf563d95110f99fa24c567ddd778a697715247d697a1b5bb1ac -size 46667 +oid sha256:d1b31b17a2ea1d13560752a9881b0698e5edb46d9271d91cce47d2becf267888 +size 46471 diff --git a/examples/screenshots/heatmap.png b/examples/screenshots/heatmap.png index defcca301..be0f95af9 100644 --- a/examples/screenshots/heatmap.png +++ b/examples/screenshots/heatmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0789d249cb4cfad21c9f1629721ade26ed734e05b1b13c3a5871793f6271362b -size 91831 +oid sha256:4ff60beab679b2e922cee1ce2525a65e14f599bf54344a4b3457c57d7afd054f +size 89365 diff --git a/examples/screenshots/image_cmap.png b/examples/screenshots/image_cmap.png index 0301d2ed4..6576061f8 100644 --- a/examples/screenshots/image_cmap.png +++ b/examples/screenshots/image_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d2bbb79716fecce08479fbe7977565daccadf4688c8a99e155db297ecce4c484 -size 199979 +oid sha256:c7c252a2af881092557ec77f00832a55a2a7fc5b7bd6bb940050bb7054daff14 +size 209290 diff --git a/examples/screenshots/image_rgb.png b/examples/screenshots/image_rgb.png index 11129ceaa..79e3f2690 100644 --- a/examples/screenshots/image_rgb.png +++ b/examples/screenshots/image_rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:23024936931651cdf4761f2cafcd8002bb12ab86e9efb13ddc99a9bf659c3935 -size 226879 +oid sha256:c6842168344b5eda6d33f5a0bfc7524bdada6faee333597f11684ab35ae9bba3 +size 234360 diff --git a/examples/screenshots/image_rgbvminvmax.png b/examples/screenshots/image_rgbvminvmax.png index afe4de6f7..b5e5395c3 100644 --- a/examples/screenshots/image_rgbvminvmax.png +++ b/examples/screenshots/image_rgbvminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fb9cd6d32813df6a9e3bf183f73cb69fdb61d290d7f2a4cc223ab34301351a1 -size 50231 +oid sha256:bf2310bab00658375cee4f0c9627c280afa59f9bbae06a1447ba5ae4188885c6 +size 43386 diff --git a/examples/screenshots/image_simple.png b/examples/screenshots/image_simple.png index 702a1ac5c..0402fd9d7 100644 --- a/examples/screenshots/image_simple.png +++ b/examples/screenshots/image_simple.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3eb6f03364226e9f1aae72f6414ad05b0239a15c2a0fbcd71d3718fee477e2c -size 199468 +oid sha256:0fca2ebadff656b3e89e67e9288c4c2eb33b0933c9913ec615d62a167579ccdf +size 209081 diff --git a/examples/screenshots/image_small.png b/examples/screenshots/image_small.png index d17cb7ab2..eedd4b5ca 100644 --- a/examples/screenshots/image_small.png +++ b/examples/screenshots/image_small.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2dcfc7b8a964db9a950bf4d3217fb171d081251b107977f9acd612fcd5fb0be1 -size 14453 +oid sha256:2e8e8a4a4302d1e6bbba477755c11cfa57765a87c0a9c69bc186bb8a7d7ea009 +size 13079 diff --git a/examples/screenshots/image_vminvmax.png b/examples/screenshots/image_vminvmax.png index afe4de6f7..b5e5395c3 100644 --- a/examples/screenshots/image_vminvmax.png +++ b/examples/screenshots/image_vminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fb9cd6d32813df6a9e3bf183f73cb69fdb61d290d7f2a4cc223ab34301351a1 -size 50231 +oid sha256:bf2310bab00658375cee4f0c9627c280afa59f9bbae06a1447ba5ae4188885c6 +size 43386 diff --git a/examples/screenshots/image_volume_mip.png b/examples/screenshots/image_volume_mip.png new file mode 100644 index 000000000..200685634 --- /dev/null +++ b/examples/screenshots/image_volume_mip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13a2911b6f0d105d777400305f01edf60243a6d4efd4eea5d23ccb6b1a8eb79d +size 161588 diff --git a/examples/screenshots/image_volume_non_orthogonal_slicing.png b/examples/screenshots/image_volume_non_orthogonal_slicing.png new file mode 100644 index 000000000..7f59f073e --- /dev/null +++ b/examples/screenshots/image_volume_non_orthogonal_slicing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4d0260a8366e7387a164537acae61159ac7c96a80fc0dea2c529fe63fa583eb +size 51823 diff --git a/examples/screenshots/image_volume_render_modes.png b/examples/screenshots/image_volume_render_modes.png new file mode 100644 index 000000000..f478d0e55 --- /dev/null +++ b/examples/screenshots/image_volume_render_modes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:022f475b961a962a03f4e83bd2544c5ef92a81d69df72745f66a8c4c8df4a174 +size 58892 diff --git a/examples/screenshots/image_volume_share_buffer.png b/examples/screenshots/image_volume_share_buffer.png new file mode 100644 index 000000000..c4fbda272 --- /dev/null +++ b/examples/screenshots/image_volume_share_buffer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7f5a7244fb20ee64246ec07e5785650f4a600480ac5578d6726af84886d2056 +size 42816 diff --git a/examples/screenshots/image_widget.png b/examples/screenshots/image_widget.png index 23d34ae50..89638797b 100644 --- a/examples/screenshots/image_widget.png +++ b/examples/screenshots/image_widget.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:220ebb5286b48426f9457b62d6e7f9fe61b5a62b8874c7e010e07e146ae205a5 -size 184633 +oid sha256:4164e333375b56b1e9ea8264df820709f4cea5844460bc544e2b6dda9c26bd9a +size 188062 diff --git a/examples/screenshots/image_widget_grid.png b/examples/screenshots/image_widget_grid.png index a6ccd144a..db24f0148 100644 --- a/examples/screenshots/image_widget_grid.png +++ b/examples/screenshots/image_widget_grid.png @@ -1,4 +1,3 @@ version https://git-lfs.github.com/spec/v1 - -oid sha256:430cd0ee5c05221c42073345480acbeee672c299311f239dc0790a9495d0d758 -size 248046 +oid sha256:fe4a0dd3f7140a652ff82b49aa2090b8b1fd0641dc45be0a2f1a5f788ce113f2 +size 242559 diff --git a/examples/screenshots/image_widget_imgui.png b/examples/screenshots/image_widget_imgui.png index cb165cc86..908226978 100644 --- a/examples/screenshots/image_widget_imgui.png +++ b/examples/screenshots/image_widget_imgui.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7522a35768d013a257e3cf3b00cce626b023b169484e035f46c635efc553b0bf -size 165747 +oid sha256:683262b7317cc3facd9b5906d3d79c177e2cec8a85dc6143c8cfceda5a6970d3 +size 173641 diff --git a/examples/screenshots/image_widget_single_video.png b/examples/screenshots/image_widget_single_video.png index aa757a950..00f537b89 100644 --- a/examples/screenshots/image_widget_single_video.png +++ b/examples/screenshots/image_widget_single_video.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f0843f4693460ae985c1f33d84936fbcc943d0405e0893186cbee7a5765dbc0 -size 90283 +oid sha256:205d3a66cc30663459f282bdde263ff1fe6358abcdaa602412e85d746b86ca78 +size 93048 diff --git a/examples/screenshots/image_widget_videos.png b/examples/screenshots/image_widget_videos.png index 2e289ae3c..ef858f85f 100644 --- a/examples/screenshots/image_widget_videos.png +++ b/examples/screenshots/image_widget_videos.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eec22392f85db1fd375d7ffa995a2719cf86821fe3fe85913f4ab66084eccbf9 -size 290587 +oid sha256:833063e4af85870420108f51f8c2e4accfb645e2c16eefb2aab6c8d3c951938a +size 310096 diff --git a/examples/screenshots/image_widget_viewports_check.png b/examples/screenshots/image_widget_viewports_check.png index 662432e59..31e30edf3 100644 --- a/examples/screenshots/image_widget_viewports_check.png +++ b/examples/screenshots/image_widget_viewports_check.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c4449f7e97375aa9d7fe1d00364945fc86b568303022157621de21a20d1d13e -size 93914 +oid sha256:93980b00802158ea9eeb74828adb7ae63a03b8339335e2771a39117470788b19 +size 82255 diff --git a/examples/screenshots/imgui_basic.png b/examples/screenshots/imgui_basic.png index 1ff9952a9..32e1b52c2 100644 --- a/examples/screenshots/imgui_basic.png +++ b/examples/screenshots/imgui_basic.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09cc7b0680e53ae1a2689b63f9b0ed641535fcffc99443cd455cc8d9b6923229 -size 36218 +oid sha256:5194566726b85eb2e5cfbe04785f86698f0bfe1f0bd4cc39bbca1102f5da655b +size 35790 diff --git a/examples/screenshots/line.png b/examples/screenshots/line.png index 02603b692..1f57f04ea 100644 --- a/examples/screenshots/line.png +++ b/examples/screenshots/line.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9bfaa54bde0967463413ecd2defa8ca18169d534163cc8b297879900e812fee8 -size 167012 +oid sha256:10a0c3dadb2b161f51c7dc6efada4c812dddaf17888ead4ddf31f6fd9846c9f4 +size 152396 diff --git a/examples/screenshots/line_cmap.png b/examples/screenshots/line_cmap.png index 1ecc930e4..02527b7cd 100644 --- a/examples/screenshots/line_cmap.png +++ b/examples/screenshots/line_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0503c008f8869dcf83793c21b15169a93558988c1a5c4edfd2aa93c549d25e1 -size 49343 +oid sha256:d103a8a054254a093500c16e8feb5f7786b2d304fd0316b7bd9c98a3a7c34949 +size 37964 diff --git a/examples/screenshots/line_cmap_more.png b/examples/screenshots/line_cmap_more.png index 4bf597e8b..8962cbb6b 100644 --- a/examples/screenshots/line_cmap_more.png +++ b/examples/screenshots/line_cmap_more.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab4d759dd679a2959c0fda724e7b7a1b7593d6f67ce797f08a5292dd0eb74fb1 -size 125023 +oid sha256:546dc7884850f620c789b103ab3fceb2d154cd78fafea427d6f380b1ec6c1160 +size 90061 diff --git a/examples/screenshots/line_collection.png b/examples/screenshots/line_collection.png index 382132770..908c87729 100644 --- a/examples/screenshots/line_collection.png +++ b/examples/screenshots/line_collection.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3b6b973a52f7088536a4f437be2a7f6ebb2787756f9170145a945c53e90093c -size 98950 +oid sha256:232820431835de3a7795724827e8a179e5e47e32d7fabbef702b57497f16a917 +size 69488 diff --git a/examples/screenshots/line_collection_cmap_values.png b/examples/screenshots/line_collection_cmap_values.png index c00bffdb6..4bbdaa0fe 100644 --- a/examples/screenshots/line_collection_cmap_values.png +++ b/examples/screenshots/line_collection_cmap_values.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:45bb6652f477ab0165bf59e504c1935e5781bceea9a891fcfa9975dec92eef4b -size 64720 +oid sha256:a3e27e891b1a3232780f159aa75592886f34492b4df1256b465379c3e4593552 +size 41492 diff --git a/examples/screenshots/line_collection_cmap_values_qualitative.png b/examples/screenshots/line_collection_cmap_values_qualitative.png index 662d3254d..e746c2884 100644 --- a/examples/screenshots/line_collection_cmap_values_qualitative.png +++ b/examples/screenshots/line_collection_cmap_values_qualitative.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e5b5cb45e78ae24d72f3cb84e482fac7bf0a98cd9b9b934444d2e67c9910d57 -size 66565 +oid sha256:964d2bccf0bd64de80c0617ddbf432ae3006ba27823a9b2107d42a2d4d6173b5 +size 44534 diff --git a/examples/screenshots/line_collection_colors.png b/examples/screenshots/line_collection_colors.png index 3b90e5b4c..39c3ad9e0 100644 --- a/examples/screenshots/line_collection_colors.png +++ b/examples/screenshots/line_collection_colors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4edf84af27535e4a30b48906ab3cacaeb38d073290828df3c5707620e222b4d3 -size 58635 +oid sha256:d3475c7485cc00633208be388b148564aea1d1883786e5402ea03bb576a8483f +size 33098 diff --git a/examples/screenshots/line_collection_slicing.png b/examples/screenshots/line_collection_slicing.png index e0537a261..b5f4db0bf 100644 --- a/examples/screenshots/line_collection_slicing.png +++ b/examples/screenshots/line_collection_slicing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66933c1fa349ebb4dd69b9bf396acb8f0aeeabbf17a3b7054d1f1e038a6e04be -size 129484 +oid sha256:651dd9c28fb6f4548384571bb011a4e2dc8591d6da1cc0f3fb0d5defe0fcd498 +size 75078 diff --git a/examples/screenshots/line_colorslice.png b/examples/screenshots/line_colorslice.png index f3374e221..ea11d63aa 100644 --- a/examples/screenshots/line_colorslice.png +++ b/examples/screenshots/line_colorslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d654aa666ac1f4cfbf228fc4c5fbd2f68eed841c7cc6265637d5b836b918314c -size 57989 +oid sha256:9dd1fab17becdae1b118f9a19e32a4f5b2ca9945da0db00394faef26fa9a6c46 +size 43271 diff --git a/examples/screenshots/line_dataslice.png b/examples/screenshots/line_dataslice.png index 6ecf63b26..b1db2cf6f 100644 --- a/examples/screenshots/line_dataslice.png +++ b/examples/screenshots/line_dataslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9b93af2028eb0186dd75d74c079d5effdb284a8677e6eec1a7fd2c8de4c8498 -size 70489 +oid sha256:ed1d8c7d20dc80433d7bf167eb769e96d49e47ceaf380b7e3602e7c8331e2b99 +size 44842 diff --git a/examples/screenshots/line_stack.png b/examples/screenshots/line_stack.png index 9a9ad4fd6..f3ab2474b 100644 --- a/examples/screenshots/line_stack.png +++ b/examples/screenshots/line_stack.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b6c2d1ee4c49ff5b193b5105b2794c6b5bd7a089a8a2c6fa03e09e02352aa65 -size 121462 +oid sha256:79825955b81f94f70625ee0845911bd13bd2726667a6b2db2896df1852aab944 +size 55457 diff --git a/examples/screenshots/linear_region_selectors_match_offsets.png b/examples/screenshots/linear_region_selectors_match_offsets.png index e6fab4c4d..8bffafba1 100644 --- a/examples/screenshots/linear_region_selectors_match_offsets.png +++ b/examples/screenshots/linear_region_selectors_match_offsets.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2eac8ffeb8cd35a0c65d51b0952defea61928abb53c865e681fa72af4ac4347 -size 95750 +oid sha256:6243ceed25fbb7932c3b5f8a6400c6049bdb074a7dddfab5ed620e75c1e39680 +size 62841 diff --git a/examples/screenshots/linear_selector.png b/examples/screenshots/linear_selector.png index 8571d664b..122960918 100644 --- a/examples/screenshots/linear_selector.png +++ b/examples/screenshots/linear_selector.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62ded18658bc5cb41129d27eb21f47f029cf7c75bb6388b5d72af6fe9c5cada9 -size 130919 +oid sha256:fe3b3ea8beb0d4a50f24a748842e4b3eebc1ebbb7824caf3625c55f38a553c80 +size 106794 diff --git a/examples/screenshots/no-imgui-extent_frac_layout.png b/examples/screenshots/no-imgui-extent_frac_layout.png index 4dc3b2aa6..e19263a19 100644 --- a/examples/screenshots/no-imgui-extent_frac_layout.png +++ b/examples/screenshots/no-imgui-extent_frac_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5923e8b9f687f97d488b282b35f16234898ed1038b0737b7b57fb9cbd72ebf34 -size 157321 +oid sha256:c2994279094daf35ec203df4b7951e496aaf12767d570f81a792df1172a412a8 +size 144324 diff --git a/examples/screenshots/no-imgui-extent_layout.png b/examples/screenshots/no-imgui-extent_layout.png index 16d1ff446..d400171a2 100644 --- a/examples/screenshots/no-imgui-extent_layout.png +++ b/examples/screenshots/no-imgui-extent_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2ffe0a8d625322cc22d2abdde80a3f179f01552dde974bbbd49f9e371ab39aa -size 138936 +oid sha256:5ffa8ba36146fefb70ffb7c0555f0068c159bbca93fe3736bb73437c25ca8dd4 +size 128833 diff --git a/examples/screenshots/no-imgui-gridplot.png b/examples/screenshots/no-imgui-gridplot.png index 7f870cf76..752afa712 100644 --- a/examples/screenshots/no-imgui-gridplot.png +++ b/examples/screenshots/no-imgui-gridplot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b31f2002053b5934ae78393214e67717d10bd567e590212eaff4062440657acd -size 292558 +oid sha256:ac8a61bb685f201dfcabe48c8241dbb41c5f824213dd8399117cad8e2f178906 +size 298976 diff --git a/examples/screenshots/no-imgui-gridplot_non_square.png b/examples/screenshots/no-imgui-gridplot_non_square.png index e08d64805..f6b2b8adb 100644 --- a/examples/screenshots/no-imgui-gridplot_non_square.png +++ b/examples/screenshots/no-imgui-gridplot_non_square.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9ef00db82a3559b4d7c77b68838f5876f98a2b9e80ef9ecb257f32c62161b5e -size 216512 +oid sha256:af867dcb5ac5bcd677c31780c07b950493d404f432be7ad7853290ae827cec01 +size 219680 diff --git a/examples/screenshots/no-imgui-gridplot_viewports_check.png b/examples/screenshots/no-imgui-gridplot_viewports_check.png index 2a8c0dc6f..61f0c5148 100644 --- a/examples/screenshots/no-imgui-gridplot_viewports_check.png +++ b/examples/screenshots/no-imgui-gridplot_viewports_check.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6818a7c8bdb29567bb09cfe00acaa6872a046d4d35a87ef2be7afa06c2a8a089 -size 44869 +oid sha256:8e5f030bb76739370c581715277e3da47d38ade0a041eec9b5e0d9ff9e311115 +size 41800 diff --git a/examples/screenshots/no-imgui-heatmap.png b/examples/screenshots/no-imgui-heatmap.png index e91d06c4f..105370f32 100644 --- a/examples/screenshots/no-imgui-heatmap.png +++ b/examples/screenshots/no-imgui-heatmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:875c15e74e7ea2eaa6b00ddbdd80b4775ecb1fe0002a5122371d49f975369cce -size 95553 +oid sha256:86ba296fc5282f664a9057eb29f99c20601c025fd937540c95f60465510ece96 +size 92928 diff --git a/examples/screenshots/no-imgui-image_cmap.png b/examples/screenshots/no-imgui-image_cmap.png index 2d42899fc..e1c95ee70 100644 --- a/examples/screenshots/no-imgui-image_cmap.png +++ b/examples/screenshots/no-imgui-image_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b43bd64ceec8c5c1287a2df57abf7bd148955d6ba97a425b32ae53bad03a051 -size 216050 +oid sha256:e1851126cacf5482954f5ce3933cbcbc3253f2ff1cc6c103de69eb42cec9061e +size 224864 diff --git a/examples/screenshots/no-imgui-image_rgb.png b/examples/screenshots/no-imgui-image_rgb.png index 6be5205ac..4d5efaccc 100644 --- a/examples/screenshots/no-imgui-image_rgb.png +++ b/examples/screenshots/no-imgui-image_rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42516cd0719d5b33ec32523dd2efe7874398bac6d0aecb5163ff1cb5c105135f -size 244717 +oid sha256:61092536672c350a27f6b41a6ea9da1d5154dead93a244b2c7fc8550870c69a8 +size 250491 diff --git a/examples/screenshots/no-imgui-image_rgbvminvmax.png b/examples/screenshots/no-imgui-image_rgbvminvmax.png index 48d8fff95..ca8659db1 100644 --- a/examples/screenshots/no-imgui-image_rgbvminvmax.png +++ b/examples/screenshots/no-imgui-image_rgbvminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f8a99a9172ae5edf98f0d189455fad2074a99f2280c9352675bab8d4c0e3491 -size 50751 +oid sha256:1d5ff797c68119cbe7ea5c6ec03aa95c38b80ffce3d48c15c1f77fad2a858d42 +size 43103 diff --git a/examples/screenshots/no-imgui-image_simple.png b/examples/screenshots/no-imgui-image_simple.png index 1e4487757..e008ba01f 100644 --- a/examples/screenshots/no-imgui-image_simple.png +++ b/examples/screenshots/no-imgui-image_simple.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cfa6469803f44a682c9ce7337ae265a8d60749070991e6f3a723eb37c5a9a23 -size 215410 +oid sha256:aae7bfe952402c9a0d4e9ace56736eea1e9ea7900ab90289bc62605ba33aca11 +size 223774 diff --git a/examples/screenshots/no-imgui-image_small.png b/examples/screenshots/no-imgui-image_small.png index 3613a8139..31e1421b2 100644 --- a/examples/screenshots/no-imgui-image_small.png +++ b/examples/screenshots/no-imgui-image_small.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17ccf0014c7ba7054440e3daf8d4e2a397e9013d1aea804c40dc7302dad4171e -size 13327 +oid sha256:d844ad71bf2afe250fc5d681fc78aedd4b9879593d07aff5aa5092e03141da16 +size 11558 diff --git a/examples/screenshots/no-imgui-image_vminvmax.png b/examples/screenshots/no-imgui-image_vminvmax.png index 48d8fff95..ca8659db1 100644 --- a/examples/screenshots/no-imgui-image_vminvmax.png +++ b/examples/screenshots/no-imgui-image_vminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f8a99a9172ae5edf98f0d189455fad2074a99f2280c9352675bab8d4c0e3491 -size 50751 +oid sha256:1d5ff797c68119cbe7ea5c6ec03aa95c38b80ffce3d48c15c1f77fad2a858d42 +size 43103 diff --git a/examples/screenshots/no-imgui-image_volume_mip.png b/examples/screenshots/no-imgui-image_volume_mip.png new file mode 100644 index 000000000..f35dd7c33 --- /dev/null +++ b/examples/screenshots/no-imgui-image_volume_mip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39c5da8bb512f2200f53a183030ee5071ee4bdd0462a2ab5a79979816b3c8086 +size 171440 diff --git a/examples/screenshots/no-imgui-image_volume_non_orthogonal_slicing.png b/examples/screenshots/no-imgui-image_volume_non_orthogonal_slicing.png new file mode 100644 index 000000000..be8aaab55 --- /dev/null +++ b/examples/screenshots/no-imgui-image_volume_non_orthogonal_slicing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af508ae7f7252e96891a055e130eb2e2ce52417b7c3b25ac402ef86d6dad4c4a +size 59065 diff --git a/examples/screenshots/no-imgui-line.png b/examples/screenshots/no-imgui-line.png index cdc24e382..3ba59c9d7 100644 --- a/examples/screenshots/no-imgui-line.png +++ b/examples/screenshots/no-imgui-line.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3952cf9b0c9d008a885dc4abb3aeaaed6fd94a5db05ba83c6f4c4c76fe6e925 -size 171519 +oid sha256:8fe7456b142edf45e8f51f82ea7a80af2152a9d22b485d77804e56bc1768412f +size 154361 diff --git a/examples/screenshots/no-imgui-line_cmap.png b/examples/screenshots/no-imgui-line_cmap.png index 4f2bbba43..ede3a56f3 100644 --- a/examples/screenshots/no-imgui-line_cmap.png +++ b/examples/screenshots/no-imgui-line_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3c9ac8d2b8157ffd575e5ad2b2bb23b684b52403c2f4f021c52d100cfb28a83 -size 49048 +oid sha256:82312d1eebf0f31ba3d9d409f15e8e5dafb5fe4ccce64e7cb941f0780861f73d +size 36599 diff --git a/examples/screenshots/no-imgui-line_cmap_more.png b/examples/screenshots/no-imgui-line_cmap_more.png index 8125be49f..c977cc430 100644 --- a/examples/screenshots/no-imgui-line_cmap_more.png +++ b/examples/screenshots/no-imgui-line_cmap_more.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ddd88200aa824d4e05ba3f94fdb4216a1e7c7137b202cd8fb47997453dfd5a6 -size 126830 +oid sha256:0772acc1c85b3c23dfa7bd17c2b9208d7e2705b17c22e7b8c0000b4f0273f9f0 +size 90814 diff --git a/examples/screenshots/no-imgui-line_collection.png b/examples/screenshots/no-imgui-line_collection.png index a31cf55fe..1ce43345f 100644 --- a/examples/screenshots/no-imgui-line_collection.png +++ b/examples/screenshots/no-imgui-line_collection.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d807f770c118e668c6bda1919856d7804f716a2bf95a5ae060345df1cd2b3c7 -size 102703 +oid sha256:4a31775cb50544545d90db48f77d2c3eba761e1dad16aed60ebf7ef2e4064625 +size 73001 diff --git a/examples/screenshots/no-imgui-line_collection_cmap_values.png b/examples/screenshots/no-imgui-line_collection_cmap_values.png index c909c766f..f7a10c187 100644 --- a/examples/screenshots/no-imgui-line_collection_cmap_values.png +++ b/examples/screenshots/no-imgui-line_collection_cmap_values.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e8612de5c3ee252ce9c8cc8afd5bd6075d5e242e8a93cd025e28ec82526120f -size 64698 +oid sha256:1072328cb684bd785fea7726c68633205e91f5d7b1545acc61e9affe9c318e76 +size 37900 diff --git a/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png b/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png index 61d5a21d0..6f359a994 100644 --- a/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png +++ b/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7847cd4399ce5b43bda9985eb72467ad292744aaeb9e8d210dd6c86c4eb1a090 -size 67959 +oid sha256:f595b2e350cc646629bddd3df257d6fed37c60e588366b1e2c5e0ecfa84015a8 +size 40677 diff --git a/examples/screenshots/no-imgui-line_collection_colors.png b/examples/screenshots/no-imgui-line_collection_colors.png index 567bb4d06..b150d6225 100644 --- a/examples/screenshots/no-imgui-line_collection_colors.png +++ b/examples/screenshots/no-imgui-line_collection_colors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15216a0900bcaef492e5d9e3380db9f28d7b7e4bd11b26eb87ce956666dcd2b1 -size 58414 +oid sha256:48351bdfefead7fd809564508bf5196edc8805b750a09b913f1b71651ca11cea +size 28560 diff --git a/examples/screenshots/no-imgui-line_collection_slicing.png b/examples/screenshots/no-imgui-line_collection_slicing.png index c9bc6d931..7be9a32fd 100644 --- a/examples/screenshots/no-imgui-line_collection_slicing.png +++ b/examples/screenshots/no-imgui-line_collection_slicing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8d3d7813580be188766c2d0200bcbff28122758d36d0faa846b0bb4dceac654 -size 130453 +oid sha256:7c7e6d0a301ab18640ae98867610549af33ef2b9e89b76d306a5e0d276906e99 +size 73997 diff --git a/examples/screenshots/no-imgui-line_colorslice.png b/examples/screenshots/no-imgui-line_colorslice.png index fe54de5d6..4e6168bd4 100644 --- a/examples/screenshots/no-imgui-line_colorslice.png +++ b/examples/screenshots/no-imgui-line_colorslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be429bf910979cf4c9483b8ae1f7aa877fde64fb6ec8a4cf32be143f282c9103 -size 57353 +oid sha256:424040bc42e702b0adc4da95c854edbfbb8c15b598fb9254d4daa9590e9ea0ad +size 41087 diff --git a/examples/screenshots/no-imgui-line_dataslice.png b/examples/screenshots/no-imgui-line_dataslice.png index 649a9df59..e755fc270 100644 --- a/examples/screenshots/no-imgui-line_dataslice.png +++ b/examples/screenshots/no-imgui-line_dataslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf873f1479cec065f0062ce58ce78ddfbd5673654aacf0ecdbd559747ae741cb -size 69381 +oid sha256:cf7cca31763750345014ee4314e915e2cd76245ae9d489a05a9983235a932df2 +size 43566 diff --git a/examples/screenshots/no-imgui-line_stack.png b/examples/screenshots/no-imgui-line_stack.png index 3ef24e73a..aab512b83 100644 --- a/examples/screenshots/no-imgui-line_stack.png +++ b/examples/screenshots/no-imgui-line_stack.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b9d02719e7051c2a0e848cc828f21be52ac108c6f9be16795d1150a1e215371 -size 123674 +oid sha256:54ec3441c4f71632e04510428cac038a1c88e4c529880804f31feb50eb6c5de6 +size 55293 diff --git a/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png b/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png index d82efa849..cec12c158 100644 --- a/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png +++ b/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b22ee4506bc532344cfcbd5daa0c4e90d9a831d59f1d916bd28534786947771 -size 97036 +oid sha256:bbdc5f9047465d256ee1358bf9f1ecc679f1f7484b50f369442d64bf7f18da3a +size 60305 diff --git a/examples/screenshots/no-imgui-linear_selector.png b/examples/screenshots/no-imgui-linear_selector.png index 4416cb4d5..1580027a8 100644 --- a/examples/screenshots/no-imgui-linear_selector.png +++ b/examples/screenshots/no-imgui-linear_selector.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f1a323dec6d50d1c701632aadbd17d87ee3b3b42171046ca9b1284f93576a3b -size 131922 +oid sha256:6490694a4c7f266e62e953b3430a30511f3761077a961ce2547ab6d758ad9180 +size 105556 diff --git a/examples/screenshots/no-imgui-rect_frac_layout.png b/examples/screenshots/no-imgui-rect_frac_layout.png index 4dc3b2aa6..e19263a19 100644 --- a/examples/screenshots/no-imgui-rect_frac_layout.png +++ b/examples/screenshots/no-imgui-rect_frac_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5923e8b9f687f97d488b282b35f16234898ed1038b0737b7b57fb9cbd72ebf34 -size 157321 +oid sha256:c2994279094daf35ec203df4b7951e496aaf12767d570f81a792df1172a412a8 +size 144324 diff --git a/examples/screenshots/no-imgui-rect_layout.png b/examples/screenshots/no-imgui-rect_layout.png index 16d1ff446..d400171a2 100644 --- a/examples/screenshots/no-imgui-rect_layout.png +++ b/examples/screenshots/no-imgui-rect_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2ffe0a8d625322cc22d2abdde80a3f179f01552dde974bbbd49f9e371ab39aa -size 138936 +oid sha256:5ffa8ba36146fefb70ffb7c0555f0068c159bbca93fe3736bb73437c25ca8dd4 +size 128833 diff --git a/examples/screenshots/no-imgui-scatter_cmap_iris.png b/examples/screenshots/no-imgui-scatter_cmap_iris.png index 0d1f8dbb0..60ef9f37c 100644 --- a/examples/screenshots/no-imgui-scatter_cmap_iris.png +++ b/examples/screenshots/no-imgui-scatter_cmap_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e197c84911cf7711d09653d6c54d7a756fbe4fe80daa84f0cf1a1d516217423 -size 60341 +oid sha256:4dccc0e78ec14b320491155fe4d3bed0b0acebc6bf25ca1d569ebffd84e74cf1 +size 42745 diff --git a/examples/screenshots/no-imgui-scatter_colorslice_iris.png b/examples/screenshots/no-imgui-scatter_colorslice_iris.png index 84447c70f..baa7189b6 100644 --- a/examples/screenshots/no-imgui-scatter_colorslice_iris.png +++ b/examples/screenshots/no-imgui-scatter_colorslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:780b680de7d3a22d2cb73a6829cad1e1066163e084b8daa9e8362f2543ba62eb -size 36881 +oid sha256:c337c47f63837df1ee22801321e092341d00e241e3624916d4a5bd7a99280419 +size 24092 diff --git a/examples/screenshots/no-imgui-scatter_dataslice_iris.png b/examples/screenshots/no-imgui-scatter_dataslice_iris.png index a19d66270..4b8f048fe 100644 --- a/examples/screenshots/no-imgui-scatter_dataslice_iris.png +++ b/examples/screenshots/no-imgui-scatter_dataslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b4f6635f48e047944c923ac46a9bd5b77e736f26421978ff74cd37a9677c622 -size 39457 +oid sha256:a40aa8b1b9a57aaf41bbf0d79f7772d34851558a34a47cb1c30847035d0302a4 +size 24451 diff --git a/examples/screenshots/no-imgui-scatter_image_as_points.png b/examples/screenshots/no-imgui-scatter_image_as_points.png new file mode 100644 index 000000000..4ed688e7d --- /dev/null +++ b/examples/screenshots/no-imgui-scatter_image_as_points.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d8d71ee1382ce87b25afeeb2759d1e8474699f5c86a93cffacb5c7b22e787a0 +size 58639 diff --git a/examples/screenshots/no-imgui-scatter_iris.png b/examples/screenshots/no-imgui-scatter_iris.png index 631672504..5cec5446d 100644 --- a/examples/screenshots/no-imgui-scatter_iris.png +++ b/examples/screenshots/no-imgui-scatter_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80cc8c1ed5276b0b8cbd5aeb3151182a73984829f889195b57442a58c3124a43 -size 38488 +oid sha256:d473448543c094e30fb4fbf602c6a5a84995ac671b1fc05a34bd1a4ee9cb1734 +size 30225 diff --git a/examples/screenshots/no-imgui-scatter_size.png b/examples/screenshots/no-imgui-scatter_size.png index 241e38ad5..cf5b140d7 100644 --- a/examples/screenshots/no-imgui-scatter_size.png +++ b/examples/screenshots/no-imgui-scatter_size.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71f3db93ea28e773c708093319985fb0fe04fae9a8a78d4f4f764f0417979b72 -size 68596 +oid sha256:bf652dda41ab68d04fd98bb1a1569a2d704333181d0018957403c238ab8e8e4d +size 43341 diff --git a/examples/screenshots/no-imgui-scatter_validate.png b/examples/screenshots/no-imgui-scatter_validate.png new file mode 100644 index 000000000..59919b51e --- /dev/null +++ b/examples/screenshots/no-imgui-scatter_validate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a2646bb946948ba5ce9af0e0f0adaca745c39a2770ca27af7fcd0830350fd1b +size 19108 diff --git a/examples/screenshots/rect_frac_layout.png b/examples/screenshots/rect_frac_layout.png index 7fe6d3d37..1ee20fa64 100644 --- a/examples/screenshots/rect_frac_layout.png +++ b/examples/screenshots/rect_frac_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5991b755432318310cfc2b4826bd9639cc234883aa06f1895817f710714cb58f -size 156297 +oid sha256:23cd21882c3599d7b911c70df62d38aac9bc2d180dbbf05dd40b9561c6c2329a +size 144438 diff --git a/examples/screenshots/rect_layout.png b/examples/screenshots/rect_layout.png index dec391ac2..27eb67784 100644 --- a/examples/screenshots/rect_layout.png +++ b/examples/screenshots/rect_layout.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cf23f845932023789e0823a105910e9f701d0f03c04e3c18488f0da62420921 -size 123409 +oid sha256:e5316e61ae1fb48acc439f38f77306d17834e8dd295c98a58980a803354651dd +size 117570 diff --git a/examples/screenshots/scatter_cmap_iris.png b/examples/screenshots/scatter_cmap_iris.png index c069d6b11..74fae9f55 100644 --- a/examples/screenshots/scatter_cmap_iris.png +++ b/examples/screenshots/scatter_cmap_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fad40cf8004e31f7d30f4bb552ee1c7f79a499d3bad310c0eac83396f0aabd62 -size 61193 +oid sha256:641ff08c68450320aaa31f53035d69938e4e0347273e576fc2847e3eb050e1f5 +size 44445 diff --git a/examples/screenshots/scatter_colorslice_iris.png b/examples/screenshots/scatter_colorslice_iris.png index 58c2b61fe..821784a26 100644 --- a/examples/screenshots/scatter_colorslice_iris.png +++ b/examples/screenshots/scatter_colorslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:427587ef9a73bf9c3ea6e739b61d5af7380a5488c454a9d3653019b40d569292 -size 37589 +oid sha256:6df93416cee2dc13b5435ef56a0dcfe02e3b8d087ca28077693c0cf270881bf5 +size 25438 diff --git a/examples/screenshots/scatter_dataslice_iris.png b/examples/screenshots/scatter_dataslice_iris.png index ab61f0405..55f376161 100644 --- a/examples/screenshots/scatter_dataslice_iris.png +++ b/examples/screenshots/scatter_dataslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3dd9ad854f41386d353ca0dae689a263eff942817727e328690427e2e62e2f3 -size 40112 +oid sha256:7c49cb755a020fedc9de46256357321a2bc4170a57ba96030cdbd4f0abffec6d +size 25765 diff --git a/examples/screenshots/scatter_image_as_points.png b/examples/screenshots/scatter_image_as_points.png new file mode 100644 index 000000000..d2a3e1821 --- /dev/null +++ b/examples/screenshots/scatter_image_as_points.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69d84b6e7da872fc6cce3838fb1eb2ad87fe5d846962ef264d8390904ca53abb +size 60287 diff --git a/examples/screenshots/scatter_iris.png b/examples/screenshots/scatter_iris.png index 01bd5cacd..8c6f8d402 100644 --- a/examples/screenshots/scatter_iris.png +++ b/examples/screenshots/scatter_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7978b93f7eac8176c54ed0e39178424d9cb6474c73e9013d5164d3e88d54c95 -size 39147 +oid sha256:5d78bbf60c03baf857413ca0fa0c189a5c9cdf90a7c0a1bc443b6ea7fa4d8f6b +size 31881 diff --git a/examples/screenshots/scatter_size.png b/examples/screenshots/scatter_size.png index 2f6c045f3..9da829f65 100644 --- a/examples/screenshots/scatter_size.png +++ b/examples/screenshots/scatter_size.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb05b8378d94e16094738850dca6328caf7477c641bf474b9deae426344bc7a4 -size 70898 +oid sha256:8351d9c26f034e04d5ce7b205337e48b6e827a0ee9ecd52bf69935af7d79f9af +size 47048 diff --git a/examples/screenshots/scatter_validate.png b/examples/screenshots/scatter_validate.png new file mode 100644 index 000000000..1ce5e0f1d --- /dev/null +++ b/examples/screenshots/scatter_validate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:680542f32e591dbf0426e5f550e26ab7a031aae52a6303528ccde7fae524124e +size 20288 diff --git a/examples/selection_tools/linear_region_selector.py b/examples/selection_tools/linear_region_selector.py index 5c6d6e01b..7a9114beb 100644 --- a/examples/selection_tools/linear_region_selector.py +++ b/examples/selection_tools/linear_region_selector.py @@ -37,22 +37,22 @@ ys = np.sin(xs) + np.random.normal(scale=0.2, size=10000) # make sine along x axis -sine_graphic_x = figure[0, 0].add_line(np.column_stack([xs, ys]), thickness=1) +sine_x = figure[0, 0].add_line(np.column_stack([xs, ys]), thickness=1) # x = sine(y), sine(y) > 0 = 0 -sine_y = ys -sine_y[sine_y > 0] = 0 +sine_y_data = ys +sine_y_data[sine_y_data > 0] = 0 # sine along y axis -sine_graphic_y = figure[0, 1].add_line(np.column_stack([ys, xs])) +sine_y = figure[0, 1].add_line(np.column_stack([ys, xs])) # offset the position of the graphic to demonstrate `get_selected_data()` later -sine_graphic_y.position_x = 50 -sine_graphic_y.position_y = 50 +sine_y.position_x = 50 +sine_y.position_y = 50 # add linear selectors -selector_x = sine_graphic_x.add_linear_region_selector((0, 100)) # default axis is "x" -selector_y = sine_graphic_y.add_linear_region_selector(axis="y") +selector_x = sine_x.add_linear_region_selector((0, 100)) # default axis is "x" +selector_y = sine_y.add_linear_region_selector(axis="y") # preallocate array for storing zoomed in data zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)]) diff --git a/examples/selection_tools/linear_region_selectors_match_offsets.py b/examples/selection_tools/linear_region_selectors_match_offsets.py index 7ac9cc486..042207152 100644 --- a/examples/selection_tools/linear_region_selectors_match_offsets.py +++ b/examples/selection_tools/linear_region_selectors_match_offsets.py @@ -32,22 +32,22 @@ ys = np.sin(xs) # y = sine(x) # make sine along x axis -sine_graphic_x = figure[0, 0].add_line(np.column_stack([xs, ys]), offset=(10, 10, 0)) +sine_x = figure[0, 0].add_line(np.column_stack([xs, ys]), offset=(10, 10, 0)) # x = sine(y), sine(y) > 0 = 0 -sine_y = ys -sine_y[sine_y > 0] = 0 +sine_y_data = ys +sine_y_data[sine_y_data > 0] = 0 # sine along y axis -sine_graphic_y = figure[0, 1].add_line(np.column_stack([ys, xs]), offset=(10, 10, 0)) +sine_y = figure[0, 1].add_line(np.column_stack([ys, xs]), offset=(10, 10, 0)) # offset the position of the graphic to demonstrate `get_selected_data()` later -sine_graphic_y.position_x = 50 -sine_graphic_y.position_y = 50 +sine_y.position_x = 50 +sine_y.position_y = 50 # add linear selectors -selector_x = sine_graphic_x.add_linear_region_selector() # default axis is "x" -selector_y = sine_graphic_y.add_linear_region_selector(axis="y") +selector_x = sine_x.add_linear_region_selector() # default axis is "x" +selector_y = sine_y.add_linear_region_selector(axis="y") # preallocate array for storing zoomed in data zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)]) diff --git a/examples/selection_tools/linear_selector.py b/examples/selection_tools/linear_selector.py index 65fd8f1b1..8b442db20 100644 --- a/examples/selection_tools/linear_selector.py +++ b/examples/selection_tools/linear_selector.py @@ -2,7 +2,8 @@ Linear Selectors ================ -Example showing how to use a `LinearSelector` with lines and line collections. +Example showing how to use a `LinearSelector` with lines and line collections. The linear selector is the yellow +vertical line. """ # test_example = true diff --git a/examples/selection_tools/linear_selector_image.py b/examples/selection_tools/linear_selector_image.py index 04844b568..657d5ae5e 100644 --- a/examples/selection_tools/linear_selector_image.py +++ b/examples/selection_tools/linear_selector_image.py @@ -2,8 +2,9 @@ Linear Selectors Image ====================== -Example showing how to use a `LinearSelector` to selector rows or columns of an image. The subplot on the right -displays the data for the selector row and column. +Example showing how to use a `LinearSelector` to select rows or columns of an image. The subplot on the right +displays the data for the selector row and column. Move the selectors independently or click the middle mouse +button to move both selectors to the clicked location. """ # test_example = false @@ -24,10 +25,10 @@ image = figure[0, 0].add_image(image_data) # add a row selector -image_row_selector = image.add_linear_selector(axis="y") +image_row_selector = image.add_linear_selector(axis="y", edge_color="cyan") # add column selector -image_col_selector = image.add_linear_selector() +image_col_selector = image.add_linear_selector(edge_color="cyan") # make a line to indicate row data line_image_row = figure[0, 1].add_line(image.data[0]) diff --git a/examples/selection_tools/polygon_selector.py b/examples/selection_tools/polygon_selector.py new file mode 100644 index 000000000..b43b34811 --- /dev/null +++ b/examples/selection_tools/polygon_selector.py @@ -0,0 +1,65 @@ +""" +Polygon Selectors +================= + +Example showing how to use a `PolygonSelector` (a.k.a. lasso selector) with line collections +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from itertools import product + +# create a figure +figure = fpl.Figure(size=(700, 560)) + + +# generate some data +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) + +circles = list() +for center in product(range(0, spatial_dims[0], 9), range(0, spatial_dims[1], 9)): + circles.append(make_circle(center, 3, n_points=75)) + +pos_xy = np.vstack(circles) + +# add image +line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) + +# add polygon selector to image graphic +polygon_selector = line_collection.add_polygon_selector( + fill_color="#ff00ff", edge_color="#FFF", vertex_color="#FFF" +) + + +# add event handler to highlight selected indices +@polygon_selector.add_event_handler("selection") +def color_indices(ev): + line_collection.cmap = "jet" + ixs = ev.get_selected_indices() + # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection + selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0] + line_collection[selected_line_ixs].colors = "w" + + +# # manually move selector to make a nice gallery image :D +# polygon_selector.selection = (15, 30, 15, 30) + + +figure.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.loop.run() diff --git a/examples/selection_tools/rectangle_selector_zoom.py b/examples/selection_tools/rectangle_selector_zoom.py index 61e38ffc9..f8ebd975e 100644 --- a/examples/selection_tools/rectangle_selector_zoom.py +++ b/examples/selection_tools/rectangle_selector_zoom.py @@ -18,10 +18,10 @@ ) # add image -image_graphic = figure[0, 0].add_image(data=iio.imread("imageio:camera.png")) +image = figure[0, 0].add_image(data=iio.imread("imageio:camera.png")) # add rectangle selector to image graphic -rectangle_selector = image_graphic.add_rectangle_selector() +rectangle_selector = image.add_rectangle_selector() # add a zoomed plot of the selected data zoom_ig = figure[1, 0].add_image(rectangle_selector.get_selected_data()) diff --git a/examples/selection_tools/unit_circle.py b/examples/selection_tools/unit_circle.py index b068d1bc7..b2ba772e4 100644 --- a/examples/selection_tools/unit_circle.py +++ b/examples/selection_tools/unit_circle.py @@ -67,15 +67,15 @@ def make_circle(center, radius: float, n_points: int) -> np.ndarray: # create sine and cosine data xs = np.linspace(0, 2 * np.pi, 360) -sine = np.sin(xs) -cosine = np.cos(xs) +sine_data = np.sin(xs) +cosine_data = np.cos(xs) # circle data circle_data = make_circle(center=(0, 0), radius=1, n_points=360) # make the circle line graphic, set the cmap transform using the sine function -circle_graphic = figure["unit circle"].add_line( - circle_data, thickness=4, cmap="bwr", cmap_transform=sine +circle = figure["unit circle"].add_line( + circle_data, thickness=4, cmap="bwr", cmap_transform=sine_data ) # line to show the circle radius @@ -86,36 +86,36 @@ def make_circle(center, radius: float, n_points: int) -> np.ndarray: ) # sine line graphic, cmap transform set from the sine function -sine_graphic = figure["sin(x)"].add_line( - sine, thickness=10, cmap="bwr", cmap_transform=sine +sine = figure["sin(x)"].add_line( + sine_data, thickness=10, cmap="bwr", cmap_transform=sine_data ) # cosine line graphic, cmap transform set from the sine function # illustrates the sine function values on the cosine graphic -cosine_graphic = figure["cos(x)"].add_line( - cosine, thickness=10, cmap="bwr", cmap_transform=sine +cosine = figure["cos(x)"].add_line( + cosine_data, thickness=10, cmap="bwr", cmap_transform=sine_data ) # add linear selectors to the sine and cosine line graphics -sine_selector = sine_graphic.add_linear_selector() -cosine_selector = cosine_graphic.add_linear_selector() +sine_selector = sine.add_linear_selector() +cosine_selector = cosine.add_linear_selector() def set_circle_cmap(ev): # sets the cmap transforms cmap_transform = ev.graphic.data[:, 1] # y-val data of the sine or cosine graphic - for g in [sine_graphic, cosine_graphic]: + for g in [sine, cosine]: g.cmap.transform = cmap_transform # set circle cmap transform - circle_graphic.cmap.transform = cmap_transform + circle.cmap.transform = cmap_transform # when the sine or cosine graphic is clicked, the cmap_transform # of the sine, cosine and circle line graphics are all set from # the y-values of the clicked line -sine_graphic.add_event_handler(set_circle_cmap, "click") -cosine_graphic.add_event_handler(set_circle_cmap, "click") +sine.add_event_handler(set_circle_cmap, "click") +cosine.add_event_handler(set_circle_cmap, "click") def set_x_val(ev): @@ -132,6 +132,9 @@ def set_x_val(ev): sine_selector.add_event_handler(set_x_val, "selection") cosine_selector.add_event_handler(set_x_val, "selection") +# set initial position of the selector so it's not just overlapping the y-axis +sine_selector.selection = 100 + figure.show() diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py index 7fbd32e2f..dd3a3260d 100644 --- a/examples/tests/test_examples.py +++ b/examples/tests/test_examples.py @@ -47,7 +47,7 @@ def check_skip_imgui(module): @pytest.mark.parametrize("module", examples_to_run, ids=lambda x: x.stem) -def test_examples_run(module, force_offscreen): +def test_examples_run(module, prep_environment): """Run every example marked to see if they run without error.""" if not fpl.IMGUI: check_skip_imgui(module) @@ -56,13 +56,17 @@ def test_examples_run(module, force_offscreen): @pytest.fixture -def force_offscreen(): +def prep_environment(): """Force the offscreen canvas to be selected by the auto gui module.""" + # Make that examples using rendercanvas.auto, will use the offscreen backend os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true" + # Disable ppaa on the renderer by default. Otherwise all screenshots change when the ppaa shaders are updated. + os.environ["PYGFX_DEFAULT_PPAA"] = "none" try: yield finally: del os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] + del os.environ["PYGFX_DEFAULT_PPAA"] def test_that_we_are_on_lavapipe(): @@ -86,7 +90,7 @@ def import_from_path(module_name, filename): @pytest.mark.parametrize("module", examples_to_test, ids=lambda x: x.stem) -def test_example_screenshots(module, force_offscreen): +def test_example_screenshots(module, prep_environment): """Make sure that every example marked outputs the expected.""" if not fpl.IMGUI: @@ -98,7 +102,9 @@ def test_example_screenshots(module, force_offscreen): if fpl.IMGUI: # there doesn't seem to be a resize event for the manual offscreen canvas - example.figure.imgui_renderer._backend.io.display_size = example.figure.canvas.get_logical_size() + example.figure.imgui_renderer._backend.io.display_size = ( + example.figure.canvas.get_logical_size() + ) # run this once so any edge widgets set their sizes and therefore the subplots get the correct rect # hacky but it works for now example.figure.imgui_renderer.render() @@ -148,16 +154,16 @@ def test_example_screenshots(module, force_offscreen): if os.environ["REGENERATE_SCREENSHOTS"] == "1": iio.imwrite(screenshot_path, rgb) - assert ( - screenshot_path.exists() - ), "found # test_example = true but no reference screenshot available" + assert screenshot_path.exists(), ( + "found # test_example = true but no reference screenshot available" + ) 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.05) + similar, rmse = image_similarity(rgb, ref_img) update_diffs(module.stem, similar, rgb, ref_img) assert similar, ( @@ -200,5 +206,7 @@ def get_diffs_rgba(slicer): if __name__ == "__main__": + os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true" + os.environ["PYGFX_DEFAULT_PPAA"] = "none" test_examples_run("simple") test_example_screenshots("simple") diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 4c23b3481..7b70defdb 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -18,11 +18,13 @@ # examples live in themed sub-folders example_globs = [ "image/*.py", + "image_volume/*.py", "image_widget/*.py", "heatmap/*.py", "scatter/*.py", "line/*.py", "line_collection/*.py", + "vectors/*.py" "gridplot/*.py", "window_layouts/*.py", "events/*.py", diff --git a/examples/vectors/README.rst b/examples/vectors/README.rst new file mode 100644 index 000000000..a35457409 --- /dev/null +++ b/examples/vectors/README.rst @@ -0,0 +1,2 @@ +Vector Examples +=============== diff --git a/examples/vectors/vectors_interact_electric_charges.py b/examples/vectors/vectors_interact_electric_charges.py new file mode 100644 index 000000000..4aa1d41b6 --- /dev/null +++ b/examples/vectors/vectors_interact_electric_charges.py @@ -0,0 +1,182 @@ +""" +Static Electric Field +===================== + +Interactively move the charges around by clicking and dragging the mouse to see +the static field with the charges at their new positions. This is just computing +static fields, no electrodynamics or magnetic field effects are taken into account. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import pygfx + + +# based on vacuum permittivity, 1/4πε from wikipedia: https://en.wikipedia.org/wiki/Coulomb%27s_law#Coulomb_constant +k_e = 8.98755 * 10**9 + + +def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]: + """ + Compute force on a unit charge at a distance ``r`` from a particle of charge ``q``. + Broadcasts over ``r`` array. + + q: charge in coulombs + r: 2D array of distance vectors, shape [n, 2] + + Returns force vector at each distance ``r`` provided, shape [n, 2] + """ + r_cap = r / np.linalg.norm(r, ord=2, axis=1)[:, None] + F = k_e * ((q * r_cap) / ((np.linalg.norm(r, ord=2, axis=1))**2)[:, None]) + + return F + + +figure = fpl.Figure(size=(700, 750)) + +# positions of 3 particles in a 2d plane +positions = np.array([ + [3, 3], + [8, 5], + [4, 8], +]) + +# charges of the 3 particles +charges = np.array([ + 3.5 * 10**-10, + 1 * 10**-10, + -3.5 * 10**-10, +]) + +# red to indicate positive charge, blue to indicate negative charge +colors = ["r", "r", "b"] + +# scatter point to indicate particle positions +particles = figure[0, 0].add_scatter( + data=positions, + colors=colors, + sizes=1, + edge_width=0.05, + uniform_edge_color=False, + alpha=0.7, + size_space="model", + metadata={"charges": charges}, # you can store anything as arbitrary metadata + alpha_mode="blend", +) + +xs = np.linspace(0, 10, num=20) +ys = np.linspace(0, 10, num=20) + +x, y = np.meshgrid(xs, ys) + +# display vectors at these positions in the field +field_positions = np.column_stack([x.ravel(), y.ravel()]) + +# allocate array to store direction of the field at every position due to the charge of the 3 particles +# i.e., the force felt by a unit charge at a given position in the field +field_directions = np.zeros(field_positions.shape, dtype=np.float32) + +vectors = figure[0, 0].add_vectors( + positions=field_positions, + directions=field_directions, + alpha=0.7, + alpha_mode="blend", +) + + +def update_field(): + """update the static field w.r.t. the new positions of the particles""" + + # get force vectors due to each charge and add them up + force_vectors_total = np.zeros(field_positions.shape) + + for i in range(particles.data.value.shape[0]): + force_vectors = coulombs_law( + q=particles.metadata["charges"][i], # force due to one of the charges + r=field_positions - particles.data[:, :-1][i] + ) + + force_vectors_total = force_vectors_total + force_vectors + + # zero out when the force is too large to display + # large vectors will otherwise take up the entire plot area + force_vectors_total[np.linalg.norm(force_vectors_total, axis=1, ord=2) > 3.5] = 0 + + # update the graphic + vectors.directions = force_vectors_total + + +update_field() + +# render particles on top of field +particles.world_object.material.render_queue = vectors.world_object.material.render_queue + 1 + +# interactivity code, very similar to the "Drag points" example +is_moving = False +particle_index = None +# interact with particles by moving them with mouse +@particles.add_event_handler("pointer_down") +def start_drag(ev: pygfx.PointerEvent): + global is_moving + global particle_index + + if ev.button != 1: # check for left mouse button + return + + is_moving = True + particle_index = ev.pick_info["vertex_index"] + # set edge color to indicate this particle has been selected + particles.edge_colors[particle_index] = "y" + + +@figure.renderer.add_event_handler("pointer_move") +def move_point(ev): + global is_moving + global particle_index + + # if not moving, return + if not is_moving: + return + + # pause controller so mouse events move the scatter and not the camera + with figure[0, 0].controller.pause(): + # map x, y from screen space to world space + pos = figure[0, 0].map_screen_to_world(ev) + + if pos is None: + # end movement + is_moving = False + particle_index = None + return + + # change scatter data + particles.data[particle_index, :-1] = pos[:-1] + # update field + update_field() + + +@figure.renderer.add_event_handler("pointer_up") +def end_drag(ev: pygfx.PointerEvent): + global is_moving + global particle_index + + # end movement + if is_moving: + # reset color + particles.edge_colors[particle_index] = "k" + + is_moving = False + particle_index = None + + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/vectors/vectors_simple.py b/examples/vectors/vectors_simple.py new file mode 100644 index 000000000..e26d6da71 --- /dev/null +++ b/examples/vectors/vectors_simple.py @@ -0,0 +1,43 @@ +""" +Simple Vectors +============== + +Simple example with vectors. Similar to matplotlib quiver. + +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 700)) + +start, stop, step = 0, 2 * np.pi, 0.2 + +# get uniform x, y positions +x, y = np.meshgrid(np.arange(start, stop, step), np.arange(start, stop, step)) + +# vectors, u and v are x and y components indicating directions +u = np.cos(x) +v = np.sin(y) + +# positions of each vector as [n_points, 2] array +positions = np.column_stack([x.ravel(), y.ravel()]) +# directions of each vector as a [n_points, 2] array +directions = np.column_stack([u.ravel(), v.ravel()]) + + +vectors = figure[0, 0].add_vectors( + positions=positions, + directions=directions, +) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/vectors/vectors_swirl.py b/examples/vectors/vectors_swirl.py new file mode 100644 index 000000000..fcfcb86b0 --- /dev/null +++ b/examples/vectors/vectors_swirl.py @@ -0,0 +1,47 @@ +""" +Swirling vectors +================ + +Example showing swirling vectors. Similar to matplotlib quiver. + +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 700)) + +start, stop, step = -1, 1, 0.3 + +# Make the grid +x, y, z = np.meshgrid( + np.arange(start, stop, step), + np.arange(start, stop, step), + np.arange(start, stop, step), +) + +# Make the direction data for the arrows +u = np.sin(np.pi * x) * np.cos(np.pi * y) * np.cos(np.pi * z) +v = -np.cos(np.pi * x) * np.sin(np.pi * y) * np.cos(np.pi * z) +w = np.sqrt(2.0 / 3.0) * np.cos(np.pi * x) * np.cos(np.pi * y) * np.sin(np.pi * z) + +positions = np.column_stack([x.ravel(), y.ravel(), z.ravel()]) +directions = np.column_stack([u.ravel(), v.ravel(), w.ravel()]) + + +vectors = figure[0, 0].add_vectors( + positions=positions, + directions=directions, +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/_version.py b/fastplotlib/_version.py index e6ac4d62d..1cd4ca99b 100644 --- a/fastplotlib/_version.py +++ b/fastplotlib/_version.py @@ -10,7 +10,7 @@ # This is the reference version number, to be bumped before each release. # The build system detects this definition when building a distribution. -__version__ = "0.5.1" +__version__ = "0.6.0" # Allow using nearly the same code in different projects project_name = "fastplotlib" diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index b458a8c48..46051479d 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -2,6 +2,8 @@ from .line import LineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic +from .image_volume import ImageVolumeGraphic +from ._vectors import VectorsGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -11,6 +13,8 @@ "LineGraphic", "ScatterGraphic", "ImageGraphic", + "ImageVolumeGraphic", + "VectorsGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/graphics/_axes.py index 10774fc2a..5b4c21682 100644 --- a/fastplotlib/graphics/_axes.py +++ b/fastplotlib/graphics/_axes.py @@ -3,6 +3,8 @@ import pygfx from pylinalg import quat_from_vecs, vec_transform_quat +from ..utils.enums import RenderQueue + GRID_PLANES = ["xy", "xz", "yz"] @@ -159,31 +161,53 @@ def __init__( ): self._plot_area = plot_area - if x_kwargs is None: - x_kwargs = dict() - - if y_kwargs is None: - y_kwargs = dict() + x_kwargs = x_kwargs or {} + y_kwargs = y_kwargs or {} + z_kwargs = z_kwargs or {} - if z_kwargs is None: - z_kwargs = dict() + generic_kwargs = dict( + tick_size=8.0, + line_width=2.0, + tick_marker="tick", # 'tick' for both-sides, 'tick_left' or 'tick_right' for one-sided + color="#fff", + ) - x_kwargs = { - "tick_side": "right", + x_kwargs = dict( + tick_side="right", + **generic_kwargs, **x_kwargs, - } + ) - y_kwargs = {"tick_side": "left", **y_kwargs} + y_kwargs = dict( + tick_side="left", + **generic_kwargs, + **y_kwargs, + ) - z_kwargs = { - "tick_side": "left", + z_kwargs = dict( + tick_side="left", + **generic_kwargs, **z_kwargs, - } + ) # create ruler for each dim - self._x = pygfx.Ruler(**x_kwargs) - self._y = pygfx.Ruler(**y_kwargs) - self._z = pygfx.Ruler(**z_kwargs) + self._x = pygfx.Ruler( + alpha_mode="solid", render_queue=RenderQueue.axes, **x_kwargs + ) + self._y = pygfx.Ruler( + alpha_mode="solid", render_queue=RenderQueue.axes, **y_kwargs + ) + self._z = pygfx.Ruler( + alpha_mode="solid", render_queue=RenderQueue.axes, **z_kwargs + ) + + # We render the lines and ticks as solid, but enable aa for text for prettier glyphs + for ruler in self._x, self._y, self._z: + ruler.line.material.depth_compare = "<=" + ruler.points.material.depth_compare = "<=" + ruler.text.material.depth_compare = "<=" + ruler.text.material.alpha_mode = "auto" + ruler.text.material.aa = True self._offset = offset @@ -226,15 +250,23 @@ def __init__( if grid_kwargs is None: grid_kwargs = dict() - grid_kwargs = { - "major_step": 10, - "minor_step": 1, - "thickness_space": "screen", - "major_thickness": 2, - "minor_thickness": 0.5, - "infinite": True, + # The grid is a bit weird, because it makes use of transparency to fade off in the distance. + # But w want it to write depth, so that objects that are drawn behind it are partually hidden. + # So we set alha_mode to 'auto'. We make it draw earlier than other 'auto' objects, under the + # assumption that most interesting stuff is in front of the grid, and artifacts behind the grid are less + # bad than those in front. Note that fully opaque objects blend perfectly fine with the grid. Artifacts + # should only emerge for objects that have semi-transparent fragments. + grid_kwargs = dict( + alpha_mode="auto", + render_queue=RenderQueue.auto + 50, + major_step=10, + minor_step=1, + thickness_space="screen", + major_thickness=2, + minor_thickness=0.5, + infinite=True, **grid_kwargs, - } + ) if grids: _grids = dict() diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index bc3486696..a4f3e9a67 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -5,7 +5,7 @@ import numpy as np import pylinalg as la -from wgpu.gui.base import log_exception +from rendercanvas.base import log_exception try: from imgui_bundle import imgui @@ -22,6 +22,8 @@ Name, Offset, Rotation, + Alpha, + AlphaMode, Visible, ) from ._axes import Axes @@ -53,13 +55,6 @@ class Graphic: _features: dict[str, type] = dict() def __init_subclass__(cls, **kwargs): - # set the type of the graphic in lower case like "image", "line_collection", etc. - cls.type = ( - cls.__name__.lower() - .replace("graphic", "") - .replace("collection", "_collection") - .replace("stack", "_stack") - ) # set of all features cls._features = { @@ -67,6 +62,8 @@ def __init_subclass__(cls, **kwargs): "name": Name, "offset": Offset, "rotation": Rotation, + "alpha": Alpha, + "alpha_mode": AlphaMode, "visible": Visible, "deleted": Deleted, } @@ -77,6 +74,8 @@ def __init__( 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), + alpha: float = 1.0, + alpha_mode: str = "auto", visible: bool = True, metadata: Any = None, ): @@ -93,6 +92,44 @@ def __init__( rotation: (float, float, float, float), default (0, 0, 0, 1) rotation quaternion + alpha: (float), default 1.0 + The global alpha value, i.e. opacity, of the graphic. + + The alpha value for the colors. If you make your a graphic transparent, consider setting ``alpha_mode`` + to 'blend' or 'weighted_blend' so it won't write to the depth buffer. + + alpha_mode: (str), default "auto", + The alpha-mode, e.g. 'auto', 'blend', 'weighted_blend', 'solid', or 'dither'. + + Modes for method “opaque” (overwrites the value in the output texture): + + * “solid”: alpha is ignored. + * “solid_premul”: the alpha is multipled with the color (making it darker). + + Modes for method “blended” (per-fragment blending, a.k.a. compositing): + + * “auto”: classic alpha blending, with depth_write defaulting to True. See note below. + * “blend”: classic alpha blending using the over-operator. depth_write defaults to False. + * “add”: additive blending that adds the fragment color, multiplied by alpha. + * “subtract”: subtractuve blending that removes the fragment color. + * “multiply”: multiplicative blending that multiplies the fragment color. + + Modes for method “weighted” (order independent blending): + + * “weighted_blend”: weighted blended order independent transparency. + * “weighted_solid”: fragments are combined based on alpha, but the final alpha is always 1. Great for e.g. image stitching. + + Modes for method “stochastic” (alpha represents the chance of a fragment being visible): + + * “dither”: stochastic transparency with blue noise. This mode handles order-independent transparency exceptionally well, but it produces results that can look somewhat noisy. + * “bayer”: stochastic transparency with an 8x8 Bayer pattern. + + + For details see https://docs.pygfx.org/stable/transparency.html + + visible: (bool), default True + Whether the graphic is visible. + metadata: Any, optional metadata attached to this Graphic, this is for the user to manage @@ -119,6 +156,8 @@ def __init__( self._deleted = Deleted(False) self._rotation = Rotation(rotation) self._offset = Offset(offset) + self._alpha = Alpha(alpha) + self._alpha_mode = AlphaMode(alpha_mode) self._visible = Visible(visible) self._block_events = False @@ -158,6 +197,24 @@ def rotation(self) -> np.ndarray: def rotation(self, value: np.ndarray | list | tuple): self._rotation.set_value(self, value) + @property + def alpha(self) -> float: + """The opacity of the graphic""" + return self._alpha.value + + @alpha.setter + def alpha(self, value: float): + self._alpha.set_value(self, value) + + @property + def alpha_mode(self) -> str: + """How the alpha is handled by the renderer""" + return self._alpha_mode.value + + @alpha_mode.setter + def alpha_mode(self, value: str): + self._alpha_mode.set_value(self, value) + @property def visible(self) -> bool: """Whether the graphic is visible""" @@ -194,14 +251,22 @@ def world_object(self) -> pygfx.WorldObject: def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo - self.world_object.visible = self.visible + wo.visible = self.visible + if "Image" in self.__class__.__name__: + # Image and ImageVolume use tiling and share one material + self._material.opacity = self.alpha + self._material.alpha_mode = self.alpha_mode + + if wo.material is not None: + wo.material.opacity = self.alpha + wo.material.alpha_mode = self.alpha_mode # set offset if it's not (0., 0., 0.) - if not all(self.world_object.world.position == self.offset): + if not all(wo.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): + if not all(wo.world.rotation == self.rotation): self.rotation = self.rotation @property @@ -270,6 +335,13 @@ def decorator(_callback): if t in self._features.keys(): # fpl feature event feature = getattr(self, f"_{t}") + + if feature is None: + # feature is None in the graphic's current mode, probably is a scatter graphic + raise AttributeError( + f"{self} does not have the passed feature: '{t}' in its current mode." + ) + feature.add_event_handler(_callback_wrapper) else: # wrap pygfx event @@ -460,8 +532,7 @@ def right_click_menu(self): def right_click_menu(self, menu): if not IMGUI: raise ImportError( - "imgui is required to set right-click menus:\n" - "pip install imgui_bundle" + "imgui is required to set right-click menus:\npip install imgui_bundle" ) self._right_click_menu = menu diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 4a4f5a797..73520cc84 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Sequence import numpy as np @@ -9,7 +9,6 @@ VertexColors, UniformColor, VertexCmap, - PointsSizesFeature, SizeSpace, ) @@ -36,7 +35,7 @@ def colors(self) -> VertexColors | pygfx.Color: return self._colors.value @colors.setter - def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]): + def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): if isinstance(self._colors, VertexColors): self._colors[:] = value @@ -46,7 +45,7 @@ def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str @property def cmap(self) -> VertexCmap: """ - Control the cmap, cmap transform, or cmap alpha + Control the cmap or cmap transform For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ """ @@ -77,7 +76,6 @@ def __init__( 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, @@ -112,7 +110,6 @@ def __init__( self._colors, cmap_name=cmap, transform=cmap_transform, - alpha=alpha, ) elif isinstance(cmap, VertexCmap): # use existing cmap instance @@ -129,9 +126,7 @@ def __init__( self._colors = colors self._colors._shared += 1 # blank colormap instance - self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None, alpha=alpha - ) + self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) else: if uniform_color: if not isinstance(colors, str): # not a single color @@ -139,16 +134,14 @@ def __init__( raise TypeError( "must pass a single color if using `uniform_colors=True`" ) - self._colors = UniformColor(colors, alpha=alpha) + self._colors = UniformColor(colors) self._cmap = None else: self._colors = VertexColors( - colors, - n_colors=self._data.value.shape[0], - alpha=alpha, + colors, n_colors=self._data.value.shape[0] ) self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None, alpha=alpha + self._colors, cmap_name=None, transform=None ) self._size_space = SizeSpace(size_space) diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py new file mode 100644 index 000000000..6f761bd49 --- /dev/null +++ b/fastplotlib/graphics/_vectors.py @@ -0,0 +1,430 @@ +from typing import Sequence + +import pygfx +from pygfx.geometries.utils import merge as merge_geometries +import pylinalg as la +import numpy as np + +from ._base import Graphic +from .features import ( + VectorPositions, + VectorDirections, +) + + +class VectorsGraphic(Graphic): + _features = { + "positions": VectorPositions, + "directions": VectorDirections, + } + + def __init__( + self, + positions: np.ndarray | Sequence[float], + directions: np.ndarray | Sequence[float], + color: str | Sequence[float] | np.ndarray = "w", + size: float = None, + vector_shape_options: dict = None, + **kwargs, + ): + """ + Create graphic that draw vectors. Similar to matplotlib quiver. + + Parameters + ---------- + positions: np.ndarray | Sequence[float] + positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + directions: np.ndarray | Sequence[float] + directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + spacing: float + average distance between pairs of nearest-neighbor vectors, used for scaling + + color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" + color of the vectors + + size: float or None + Size of a vector of magnitude 1 in world space for display purpose. + Estimated from density if not provided. + + vector_shape_options: dict + dict with the following fields that directly describes the shape of the vector arrows. + Overrides ``size`` argument. + + * cone_radius + * cone_height + * stalk_radius + * stalk_height + + **kwargs + passed to :class:`.Graphic` + + """ + + super().__init__(**kwargs) + + # TODO: once it's possible to constructor instanced objects with a shared buffer I can do this + # if isinstance(positions, VectorPositions): + # self._positions = positions + # else: + # self._positions = VectorPositions(positions) + # + # if isinstance(directions, VectorDirections): + # self._directions = directions + # else: + # self._directions = VectorDirections(directions) + + positions = np.asarray(positions) + directions = np.asarray(directions) + + if positions.shape != directions.shape: + raise ValueError( + f"positions.shape != directions.shape: {positions.shape} != {directions.shape}\n" + f"They must be of the same shape" + ) + + self._positions = VectorPositions(positions) + self._directions = VectorDirections(directions) + + if vector_shape_options is not None: + required = {"cone_radius", "cone_height", "stalk_radius", "stalk_height"} + if set(vector_shape_options.keys()) != required: + raise KeyError( + f"`vector_shape_options` must be a dict with the following keys: {required}.\n" + f"You have passed: {vector_shape_options}" + ) + shape_options = vector_shape_options + else: + if size is None: + # guess from density + # sort xs and then take unique to get the density along x, same for y and z + x_density = np.diff(np.unique(np.sort(self._positions[:, 0]))).mean() + y_density = np.diff(np.unique(np.sort(self._positions[:, 1]))).mean() + densities = [x_density, y_density] + + # if z is not basically zero + if not np.allclose( + np.diff(np.unique(np.sort(self._positions[:, 2]))), 0.0 + ): + z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean() + densities.append(z_density) + + mean_density = np.mean(densities) + + size = mean_density + + cone_height = size / 2 + stalk_height = size / 2 + + cone_radius = size / 10 + stalk_radius = cone_radius / 8 + + shape_options = { + "cone_radius": cone_radius, + "cone_height": cone_height, + "stalk_radius": stalk_radius, + "stalk_height": stalk_height, + } + + geometry = create_vector_geometry(color=color, **shape_options) + material = pygfx.MeshBasicMaterial() + + n_vectors = self._positions.value.shape[0] + + world_object = pygfx.InstancedMesh(geometry, material, n_vectors) + + magnitudes = np.linalg.norm(self.directions[:], axis=1, ord=2) + + for i in range(n_vectors): + # get quaternion to rotate vector to new direction + rotation = la.quat_from_vecs( + self._directions.init_direction, self._directions[i] + ) + # get the new transform + transform = la.mat_compose( + self._positions.value[i], rotation, magnitudes[i] + ) + # set the buffer + world_object.instance_buffer.data["matrix"][i] = transform.T + + world_object.instance_buffer.update_full() + + self._set_world_object(world_object) + + @property + def positions(self) -> VectorPositions: + """Vector positions""" + return self._positions + + @positions.setter + def positions(self, new_positions): + self._positions.set_value(self, new_positions) + + @property + def directions(self) -> VectorDirections: + """Vector directions""" + return self._directions + + @directions.setter + def directions(self, new_directions): + self._directions.set_value(self, new_directions) + + +# mesh code copied and adapted from pygfx +def generate_torso( + radius_bottom, + radius_top, + height, + radial_segments, + height_segments, + theta_start, + theta_length, + z_offset=0.0, +): + """copied from pygfx, generates the mesh for a cylinder with the given parameters""" + # compute POSITIONS assuming x-y horizontal plane and z up axis + + # radius for each vertex ring from bottom to top + n_rings = height_segments + 1 + radii = np.linspace(radius_bottom, radius_top, num=n_rings, dtype=np.float32) + + # height for each vertex ring from bottom to top + half_height = height / 2 + heights = np.linspace(-half_height, half_height, num=n_rings, dtype=np.float32) + + # to enable texture mapping to fully wrap around the cylinder, + # we can't close the geometry and need a degenerate vertex + n_vertices = radial_segments + 1 + + # xy coordinates on unit circle for a single vertex ring + theta = np.linspace( + theta_start, theta_start + theta_length, num=n_vertices, dtype=np.float32 + ) + ring_xy = np.column_stack([np.cos(theta), np.sin(theta)]) + + # put all the rings together + positions = np.empty((n_rings, n_vertices, 3), dtype=np.float32) + positions[..., :2] = ring_xy[None, ...] * radii[:, None, None] + positions[..., 2] = heights[:, None] - z_offset + + # the NORMALS are the same for every ring, so compute for only one ring + # and then repeat + slope = (radius_bottom - radius_top) / height + ring_normals = np.empty(positions.shape[1:], dtype=np.float32) + ring_normals[..., :2] = ring_xy + ring_normals[..., 2] = slope + ring_normals /= np.linalg.norm(ring_normals, axis=-1)[:, None] + normals = np.empty_like(positions) + normals[:] = ring_normals[None, ...] + + # the TEXTURE COORDS + # u maps 0..1 to theta_start..theta_start+theta_length + # v maps 0..1 to -height/2..height/2 + ring_u = (theta - theta_start) / theta_length + ring_v = (heights / height) + 0.5 + texcoords = np.empty((n_rings, n_vertices, 2), dtype=np.float32) + texcoords[..., 0] = ring_u[None, :] + texcoords[..., 1] = ring_v[:, None] + + # the face INDEX + # the amount of vertices + indices = np.arange(n_rings * n_vertices, dtype=np.uint32).reshape( + (n_rings, n_vertices) + ) + # for every panel (height_segments, radial_segments) there is a quad (2, 3) + index = np.empty((height_segments, radial_segments, 2, 3), dtype=np.uint32) + # create a grid of initial indices for the panels + index[:, :, 0, 0] = indices[ + np.arange(height_segments)[:, None], np.arange(radial_segments)[None, :] + ] + # the remainder of the indices for every panel are relative + index[:, :, 0, 1] = index[:, :, 0, 0] + 1 + index[:, :, 0, 2] = index[:, :, 0, 0] + n_vertices + index[:, :, 1, 0] = index[:, :, 0, 0] + n_vertices + 1 + index[:, :, 1, 1] = index[:, :, 1, 0] - 1 + index[:, :, 1, 2] = index[:, :, 1, 0] - n_vertices + + return ( + positions.reshape((-1, 3)), + normals.reshape((-1, 3)), + texcoords.reshape((-1, 2)), + index.flatten(), + ) + + +def generate_cap(radius, height, radial_segments, theta_start, theta_length, up=True): + """copied from pygfx, generates the mesh for a circular cap with the given parameters""" + # compute POSITIONS assuming x-y horizontal plane and z up axis + + # to enable texture mapping to fully wrap around the cylinder, + # we can't close the geometry and need a degenerate vertex + n_vertices = radial_segments + 1 + + # xy coordinates on unit circle for vertex ring + theta = np.linspace( + theta_start, theta_start + theta_length, num=n_vertices, dtype=np.float32 + ) + ring_xy = np.column_stack([np.cos(theta), np.sin(theta)]) + + # put the vertices together, inserting a center vertex at the start + positions = np.empty((1 + n_vertices, 3), dtype=np.float32) + positions[0, :2] = [0.0, 0.0] + positions[1:, :2] = ring_xy * radius + positions[..., 2] = height + + # the NORMALS + normals = np.zeros_like(positions, dtype=np.float32) + sign = int(up) * 2.0 - 1.0 + normals[..., 2] = sign + + # the TEXTURE COORDS + # uv etches out a circle from the [0..1, 0..1] range + # direction is reversed for up=False + texcoords = np.empty((1 + n_vertices, 2), dtype=np.float32) + texcoords[0] = [0.5, 0.5] + texcoords[1:, 0] = ring_xy[:, 0] * 0.5 + 0.5 + texcoords[1:, 1] = ring_xy[:, 1] * 0.5 * sign + 0.5 + + # the face INDEX + indices = np.arange(n_vertices) + 1 + # for every radial segment there is a triangle (3) + index = np.empty((radial_segments, 3), dtype=np.uint32) + # create a grid of initial indices for the panels + index[:, 0] = indices[np.arange(radial_segments)] + # the remainder of the indices for every panel are relative + index[:, 1 + int(up)] = n_vertices + index[:, 2 - int(up)] = index[:, 0] + 1 + + return ( + positions, + normals, + texcoords, + index.flatten(), + ) + + +def create_vector_geometry( + color: str | pygfx.Color | Sequence[float] | np.ndarray = "w", + cone_cap_color: str | pygfx.Color | Sequence[float] | np.ndarray | None = None, + cone_radius: float = 1.0, + cone_height: float = 0.5, + stalk_radius: float = 0.3, + stalk_height: float = 0.5, + segments: int = 12, +): + """ + Generate the mesh for a vector pointing in the direction [0, 0, 1], a unit vector in the +z direction. + + Parameters + ---------- + color: + color of the vector + + cone_cap_color: + color of the cone cap, by default it will use a darker version of the provided vector color from above. + + cone_radius: + radius of the bottom of the cone segment of the vector + + cone_height: + height of the cone segment of the vector + + stalk_radius: + radius of the vector's stalk + + stalk_height: + height of the vector's stalk + + segments: + number of mesh segments, more looks nicers but is also more expennsive to render, 12 looks good enough. + + """ + + radius_top = 0 # radius top = 0 means the cylinder becomes a cone + + radial_segments = segments + + height_segments = 1 + theta_start = 0.0 + theta_length = np.pi * 2 + + # create cone + cone = generate_torso( + cone_radius, + radius_top, + cone_height, + radial_segments, + height_segments, + theta_start, + theta_length, + ) + + groups = [cone] + + cone_cap_start_ix = len(cone[0]) + + # create bottom cap + cone_cap = generate_cap( + cone_radius, + -cone_height / 2, + radial_segments, + theta_start, + theta_length, + up=False, + ) + + cone_cap_stop_ix = cone_cap_start_ix + len(cone_cap[0]) + + groups.append(cone_cap) + + stalk = generate_torso( + stalk_radius, + stalk_radius, + stalk_height, + radial_segments, + height_segments, + theta_start, + theta_length, + z_offset=cone_height, + ) + + groups.append(stalk) + + stalk_cap = generate_cap( + stalk_radius, + -stalk_radius / 2, + radial_segments, + theta_start, + theta_length, + up=False, + ) + + groups.append(stalk_cap) + + merged = merge_geometries(groups) + + positions, normals, texcoords, indices = merged + + color = np.array(pygfx.Color(color).rgb, dtype=np.float32) + + # color the cone cap in a different color + if cone_cap_color is not None: + cone_cap_color = np.array(pygfx.Color(cone_cap_color).rgb, dtype=np.float32) + else: + # make the cone cap a slightly darker version of the cone color + cone_cap_color = (color - np.array([0.25, 0.25, 0.25], dtype=np.float32)).clip( + 0 + ) + + colors = np.repeat([color], repeats=len(positions), axis=0) + + colors[cone_cap_start_ix:cone_cap_stop_ix, :] = cone_cap_color + + return pygfx.Geometry( + indices=indices.reshape((-1, 3)), + positions=positions, + normals=normals, + texcoords=texcoords, + colors=colors, + ) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 18bcf5187..f745f10c8 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -1,13 +1,21 @@ from ._positions_graphics import ( VertexColors, UniformColor, - UniformSize, SizeSpace, - Thickness, VertexPositions, - PointsSizesFeature, VertexCmap, ) +from ._line import Thickness +from ._scatter import ( + VertexMarkers, + UniformMarker, + UniformEdgeColor, + EdgeWidth, + UniformRotations, + VertexRotations, + VertexPointSizes, + UniformSize, +) from ._image import ( TextureArray, ImageCmap, @@ -16,6 +24,24 @@ ImageInterpolation, ImageCmapInterpolation, ) +from ._volume import ( + TextureArrayVolume, + VolumeRenderMode, + VolumeIsoThreshold, + VolumeIsoStepSize, + VolumeIsoSubStepSize, + VolumeIsoEmissive, + VolumeIsoShininess, + VolumeSlicePlane, + VOLUME_RENDER_MODES, + create_volume_material_kwargs, +) + +from ._vectors import ( + VectorPositions, + VectorDirections, +) + from ._base import ( GraphicFeature, BufferManager, @@ -36,24 +62,40 @@ LinearRegionSelectionFeature, RectangleSelectionFeature, ) -from ._common import Name, Offset, Rotation, Visible, Deleted +from ._common import Name, Offset, Rotation, Alpha, AlphaMode, Visible, Deleted __all__ = [ "VertexColors", "UniformColor", - "UniformSize", "SizeSpace", - "Thickness", "VertexPositions", - "PointsSizesFeature", "VertexCmap", + "Thickness", + "VertexMarkers", + "UniformMarker", + "UniformEdgeColor", + "EdgeWidth", + "UniformRotations", + "VertexRotations", + "VertexPointSizes", + "UniformSize", "TextureArray", "ImageCmap", "ImageVmin", "ImageVmax", "ImageInterpolation", "ImageCmapInterpolation", + "TextureArrayVolume", + "VolumeRenderMode", + "VolumeIsoThreshold", + "VolumeIsoStepSize", + "VolumeIsoSubStepSize", + "VolumeIsoEmissive", + "VolumeIsoShininess", + "VolumeSlicePlane", + "VectorPositions", + "VectorDirections", "TextData", "FontSize", "TextFaceColor", @@ -65,6 +107,8 @@ "Name", "Offset", "Rotation", + "Alpha", + "AlphaMode", "Visible", "Deleted", "GraphicFeatureEvent", diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index d32904ae5..5dec9f1e5 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -4,7 +4,7 @@ import numpy as np from numpy.typing import NDArray -from wgpu.gui.base import log_exception +from rendercanvas.base import log_exception import pygfx @@ -49,7 +49,8 @@ def __init__(self, type: str, info: dict): class GraphicFeature: - def __init__(self, **kwargs): + def __init__(self, property_name: str, **kwargs): + self._property_name = property_name self._event_handlers = list() self._block_events = False @@ -139,10 +140,9 @@ def __init__( data: NDArray | pygfx.Buffer, buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", isolated_buffer: bool = True, - texture_dim: int = 2, **kwargs, ): - super().__init__() + super().__init__(**kwargs) 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) @@ -157,9 +157,6 @@ def __init__( 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'" @@ -167,8 +164,6 @@ def __init__( self._event_handlers: list[callable] = list() - self._shared: int = 0 - @property def value(self) -> np.ndarray: """numpy array object representing the data managed by this buffer""" @@ -183,11 +178,6 @@ def buffer(self) -> pygfx.Buffer | pygfx.Texture: """managed buffer""" return self._buffer - @property - def shared(self) -> int: - """Number of graphics that share this buffer""" - return self._shared - @property def __array_interface__(self): raise BufferError( @@ -318,7 +308,7 @@ def __len__(self): raise NotImplementedError def __repr__(self): - return f"{self.__class__.__name__} buffer data:\n" f"{self.value.__repr__()}" + return f"{self.__class__.__name__} buffer data:\n{self.value.__repr__()}" def block_reentrance(set_value): diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py index 71e979f77..b2b99cc49 100644 --- a/fastplotlib/graphics/features/_common.py +++ b/fastplotlib/graphics/features/_common.py @@ -1,19 +1,20 @@ +from typing import Sequence + import numpy as np from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance class Name(GraphicFeature): - property_name = "name" event_info_spec = [ {"dict key": "value", "type": "str", "description": "user provided name"}, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "name"): """Graphic name""" self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -29,12 +30,11 @@ def set_value(self, graphic, value: str): self._value = value - event = GraphicFeatureEvent(type="name", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Offset(GraphicFeature): - property_name = "offset" event_info_spec = [ { "dict key": "value", @@ -43,7 +43,9 @@ class Offset(GraphicFeature): }, ] - def __init__(self, value: np.ndarray | list | tuple): + def __init__( + self, value: np.ndarray | Sequence[float], property_name: str = "offset" + ): """Offset position of the graphic, [x, y, z]""" self._validate(value) @@ -52,7 +54,7 @@ def __init__(self, value: np.ndarray | list | tuple): # set values self._value[:] = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if not len(value) == 3: @@ -63,7 +65,7 @@ def value(self) -> np.ndarray: return self._value @block_reentrance - def set_value(self, graphic, value: np.ndarray | list | tuple): + def set_value(self, graphic, value: np.ndarray | Sequence[float]): self._validate(value) value = np.asarray(value) @@ -75,12 +77,11 @@ def set_value(self, graphic, value: np.ndarray | list | tuple): # set value of existing feature value array self._value[:] = value - event = GraphicFeatureEvent(type="offset", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Rotation(GraphicFeature): - property_name = "offset" event_info_spec = [ { "dict key": "value", @@ -89,7 +90,9 @@ class Rotation(GraphicFeature): }, ] - def __init__(self, value: np.ndarray | list | tuple): + def __init__( + self, value: np.ndarray | Sequence[float], property_name: str = "rotation" + ): """Graphic rotation quaternion""" self._validate(value) @@ -97,7 +100,7 @@ def __init__(self, value: np.ndarray | list | tuple): self._value = np.zeros(4) self._value[:] = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if not len(value) == 4: @@ -110,7 +113,7 @@ def value(self) -> np.ndarray: return self._value @block_reentrance - def set_value(self, graphic, value: np.ndarray | list | tuple): + def set_value(self, graphic, value: np.ndarray | Sequence[float]): self._validate(value) value = np.asarray(value) @@ -123,21 +126,82 @@ def set_value(self, graphic, value: np.ndarray | list | tuple): # set value of existing feature value array self._value[:] = value - event = GraphicFeatureEvent(type="rotation", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class Alpha(GraphicFeature): + """The alpha value (i.e. opacity) of a graphic.""" + + event_info_spec = [ + {"dict key": "value", "type": "float", "description": "new alpha value"}, + ] + + def __init__(self, value: float, property_name: str = "alpha"): + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + wo = graphic.world_object + if wo.material is not None: + wo.material.opacity = value + + if "Image" in graphic.__class__.__name__: + # Image and ImageVolume use tiling and share one material + graphic._material.alpha = value + + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class AlphaMode(GraphicFeature): + """The alpha-mode value of a graphic (i.e. how alpha is handled by the renderer).""" + + event_info_spec = [ + {"dict key": "value", "type": "str", "description": "new alpha mode"}, + ] + + def __init__(self, value: str, property_name: str = "alpha_mode"): + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> str: + return self._value + + @block_reentrance + def set_value(self, graphic, value: str): + wo = graphic.world_object + if wo.material is not None: + wo.alpha_mode = value + + if "Image" in graphic.__class__.__name__: + # Image and ImageVolume use tiling and share one material + graphic._material.alpha_mode = value + + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Visible(GraphicFeature): """Access or change the visibility.""" - property_name = "offset" event_info_spec = [ {"dict key": "value", "type": "bool", "description": "new visibility bool"}, ] - def __init__(self, value: bool): + def __init__(self, value: bool, property_name: str = "visible"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> bool: @@ -148,7 +212,7 @@ def set_value(self, graphic, value: bool): graphic.world_object.visible = value self._value = value - event = GraphicFeatureEvent(type="visible", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -157,7 +221,6 @@ class Deleted(GraphicFeature): Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted """ - property_name = "deleted" event_info_spec = [ { "dict key": "value", @@ -166,9 +229,9 @@ class Deleted(GraphicFeature): }, ] - def __init__(self, value: bool): + def __init__(self, value: bool, property_name: str = "deleted"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> bool: @@ -177,5 +240,5 @@ def value(self) -> bool: @block_reentrance def set_value(self, graphic, value: bool): self._value = value - event = GraphicFeatureEvent(type="deleted", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index c47a26e6a..648f79bc8 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -13,8 +13,13 @@ ) -# manages an array of 8192x8192 Textures representing chunks of an image class TextureArray(GraphicFeature): + """ + Manages an array of Textures representing chunks of an image. + + Creates multiple pygfx.Texture objects based on the GPU's max texture dimension limit. + """ + event_info_spec = [ { "dict key": "key", @@ -28,8 +33,8 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True): - super().__init__() + def __init__(self, data, isolated_buffer: bool = True, property_name: str = "data"): + super().__init__(property_name=property_name) data = self._fix_data(data) @@ -70,8 +75,6 @@ def __init__(self, data, isolated_buffer: bool = True): self.buffer[buffer_index] = texture - self._shared: int = 0 - @property def value(self) -> np.ndarray: return self._value @@ -99,10 +102,6 @@ def col_indices(self) -> np.ndarray: """ 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( @@ -155,7 +154,9 @@ def __setitem__(self, key, value): for texture in self.buffer.ravel(): texture.update_range((0, 0, 0), texture.size) - event = GraphicFeatureEvent("data", info={"key": key, "value": value}) + event = GraphicFeatureEvent( + self._property_name, info={"key": key, "value": value} + ) self._call_event_handlers(event) def __len__(self): @@ -173,9 +174,9 @@ class ImageVmin(GraphicFeature): }, ] - def __init__(self, value: float): + def __init__(self, value: float, property_name: str = "vmin"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> float: @@ -187,7 +188,7 @@ def set_value(self, graphic, value: float): graphic._material.clim = (value, vmax) self._value = value - event = GraphicFeatureEvent(type="vmin", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -202,9 +203,9 @@ class ImageVmax(GraphicFeature): }, ] - def __init__(self, value: float): + def __init__(self, value: float, property_name: str = "vmax"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> float: @@ -216,7 +217,7 @@ def set_value(self, graphic, value: float): graphic._material.clim = (vmin, value) self._value = value - event = GraphicFeatureEvent(type="vmax", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -231,10 +232,10 @@ class ImageCmap(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "cmap"): self._value = value self.texture = get_cmap_texture(value) - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -247,7 +248,7 @@ def set_value(self, graphic, value: str): graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) self._value = value - event = GraphicFeatureEvent(type="cmap", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -262,10 +263,10 @@ class ImageInterpolation(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "interpolation"): self._validate(value) self._value = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if value not in ["nearest", "linear"]: @@ -297,10 +298,10 @@ class ImageCmapInterpolation(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "cmap_interpolation"): self._validate(value) self._value = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if value not in ["nearest", "linear"]: @@ -321,5 +322,5 @@ def set_value(self, graphic, value: str): graphic._material.map.mag_filter = value self._value = value - event = GraphicFeatureEvent(type="cmap_interpolation", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_line.py b/fastplotlib/graphics/features/_line.py new file mode 100644 index 000000000..792cb7832 --- /dev/null +++ b/fastplotlib/graphics/features/_line.py @@ -0,0 +1,28 @@ +from ._base import ( + GraphicFeature, + GraphicFeatureEvent, + block_reentrance, +) + + +class Thickness(GraphicFeature): + event_info_spec = [ + {"dict key": "value", "type": "float", "description": "new thickness value"}, + ] + + def __init__(self, value: float, property_name: str = "thickness"): + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + value = float(value) + graphic.world_object.material.thickness = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_positions_graphics.py b/fastplotlib/graphics/features/_positions_graphics.py index 868701079..ae57e77d7 100644 --- a/fastplotlib/graphics/features/_positions_graphics.py +++ b/fastplotlib/graphics/features/_positions_graphics.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Sequence import numpy as np import pygfx @@ -17,7 +17,6 @@ class VertexColors(BufferManager): - property_name = "colors" event_info_spec = [ { "dict key": "key", @@ -38,36 +37,35 @@ class VertexColors(BufferManager): def __init__( self, - colors: str | np.ndarray | tuple[float] | list[float] | list[str], + colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], n_colors: int, - alpha: float = None, isolated_buffer: bool = True, + property_name: str = "colors", ): """ 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 + colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str] 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) + data = parse_colors(colors, n_colors) - super().__init__(data=data, isolated_buffer=isolated_buffer) + super().__init__( + data=data, isolated_buffer=isolated_buffer, property_name=property_name + ) @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - user_value: str | np.ndarray | tuple[float] | list[float] | list[str], + user_value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], ): user_key = key @@ -141,7 +139,7 @@ def __setitem__( "user_value": user_value, } - event = GraphicFeatureEvent("colors", info=event_info) + event = GraphicFeatureEvent(self._property_name, info=event_info) self._call_event_handlers(event) def __len__(self): @@ -149,66 +147,41 @@ def __len__(self): class UniformColor(GraphicFeature): - property_name = "colors" event_info_spec = [ { "dict key": "value", - "type": "np.ndarray [RGBA]", + "type": "str | pygfx.Color | np.ndarray | Sequence[float]", "description": "new color value", }, ] def __init__( - self, value: str | np.ndarray | tuple | list | pygfx.Color, alpha: float = 1.0 + self, + value: str | pygfx.Color | np.ndarray | Sequence[float], + property_name: str = "colors", ): """Manages uniform color for line or scatter material""" - v = (*tuple(pygfx.Color(value))[:-1], alpha) # apply alpha - self._value = pygfx.Color(v) - super().__init__() + self._value = pygfx.Color(value) + super().__init__(property_name=property_name) @property def value(self) -> pygfx.Color: return self._value @block_reentrance - def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + def set_value( + self, graphic, value: str | pygfx.Color | np.ndarray | Sequence[float] + ): value = pygfx.Color(value) graphic.world_object.material.color = value self._value = value - event = GraphicFeatureEvent(type="colors", info={"value": value}) - self._call_event_handlers(event) - - -class UniformSize(GraphicFeature): - property_name = "sizes" - event_info_spec = [ - {"dict key": "value", "type": "float", "description": "new size value"}, - ] - - def __init__(self, value: int | float): - """Manages uniform size for scatter material""" - - self._value = float(value) - super().__init__() - - @property - def value(self) -> float: - return self._value - - @block_reentrance - def set_value(self, graphic, value: float | int): - value = float(value) - graphic.world_object.material.size = value - self._value = value - - event = GraphicFeatureEvent(type="sizes", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class SizeSpace(GraphicFeature): - property_name = "size_space" event_info_spec = [ { "dict key": "value", @@ -217,11 +190,11 @@ class SizeSpace(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "size_space"): """Manages the coordinate space for scatter/line graphic""" self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -240,12 +213,11 @@ def set_value(self, graphic, value: str): graphic.world_object.material.size_space = value self._value = value - event = GraphicFeatureEvent(type="size_space", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class VertexPositions(BufferManager): - property_name = "data" event_info_spec = [ { "dict key": "key", @@ -259,14 +231,18 @@ class VertexPositions(BufferManager): }, ] - def __init__(self, data: Any, isolated_buffer: bool = True): + def __init__( + self, data: Any, isolated_buffer: bool = True, property_name: str = "data" + ): """ 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) + super().__init__( + data, isolated_buffer=isolated_buffer, property_name=property_name + ) def _fix_data(self, data): # data = to_gpu_supported_dtype(data) @@ -300,115 +276,13 @@ def __setitem__( # 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): - property_name = "sizes" - event_info_spec = [ - { - "dict key": "key", - "type": "slice, index (int) or numpy-like fancy index", - "description": "key at which point sizes were indexed/sliced", - }, - { - "dict key": "value", - "type": "int | float | array-like", - "description": "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 - - @block_reentrance - 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) + self._emit_event(self._property_name, key, value) def __len__(self): return len(self.buffer.data) -class Thickness(GraphicFeature): - property_name = "thickness" - event_info_spec = [ - {"dict key": "value", "type": "float", "description": "new thickness value"}, - ] - - def __init__(self, value: float): - self._value = value - super().__init__() - - @property - def value(self) -> float: - return self._value - - @block_reentrance - def set_value(self, graphic, value: float): - value = float(value) - graphic.world_object.material.thickness = value - self._value = value - - event = GraphicFeatureEvent(type="thickness", info={"value": value}) - self._call_event_handlers(event) - - class VertexCmap(BufferManager): - property_name = "cmap" event_info_spec = [ { "dict key": "key", @@ -427,19 +301,18 @@ def __init__( vertex_colors: VertexColors, cmap_name: str | None, transform: np.ndarray | None, - alpha: float = 1.0, + property_name: str = "colors", ): """ Sliceable colormap feature, manages a VertexColors instance and provides a way to set colormaps with arbitrary transforms """ - super().__init__(data=vertex_colors.buffer) + super().__init__(data=vertex_colors.buffer, property_name=property_name) 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): @@ -457,7 +330,6 @@ def __init__( cmap_name=self._cmap_name, transform=self._transform, ) - colors[:, -1] = alpha # set vertex colors from cmap self._vertex_colors[:] = colors @@ -481,7 +353,6 @@ def __setitem__(self, key: slice, cmap_name): 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 @@ -489,7 +360,7 @@ def __setitem__(self, key: slice, cmap_name): # 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) + self._emit_event(self._property_name, key, cmap_name) @property def name(self) -> str: @@ -517,8 +388,6 @@ def transform( n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values ) - colors[:, -1] = self.alpha - self._transform = values if indices is None: @@ -528,18 +397,6 @@ def transform( 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" diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py new file mode 100644 index 000000000..16671ef89 --- /dev/null +++ b/fastplotlib/graphics/features/_scatter.py @@ -0,0 +1,574 @@ +from typing import Sequence + +import numpy as np +import pygfx + +from ._base import ( + GraphicFeature, + BufferManager, + GraphicFeatureEvent, + block_reentrance, +) + + +marker_names = { + # MPL + "o": "circle", + "s": "square", + "D": "diamond", + "+": "plus", + "x": "cross", + "^": "triangle_up", + "<": "triangle_left", + ">": "triangle_right", + "v": "triangle_down", + "*": "asterisk6", + # Unicode + "●": "circle", + "○": "ring", + "■": "square", + "♦": "diamond", + "♥": "heart", + "♠": "spade", + "♣": "club", + "✳": "asterisk6", + "▲": "triangle_up", + "▼": "triangle_down", + "◀": "triangle_left", + "▶": "triangle_right", + # Emojis (these may look like their plaintext variants in your editor) + "❤️": "heart", + "♠️": "spade", + "♣️": "club", + "♦️": "diamond", + "💎": "diamond", + "💍": "ring", + "✳️": "asterisk6", + "📍": "pin", +} + + +def user_input_to_marker(name): + resolved_name = marker_names.get(name, name).lower() + if resolved_name not in pygfx.MarkerShape: + raise ValueError( + f"markers must be a string in: {list(pygfx.MarkerShape) + list(marker_names.keys())}, not {name!r}" + ) + + return resolved_name + + +def validate_user_markers_array(markers): + # make sure all markers are valid + # need to validate before converting to ints because + # we can't use control flow in the vectorized function + unique_values = np.unique(markers) + for m in unique_values: + user_input_to_marker(m) + + +# fast vectorized function to convert array of user markers to the standardized strings +# TODO: can probably use search-sorted for this too +vectorized_user_markers_to_std_markers = np.vectorize(marker_names.get, otypes=[" array of int +# see: https://github.com/pygfx/pygfx/issues/1215 +# Prepare for searchsorted +def init_searchsorted(markers_mapping): + keys = np.array(list(markers_mapping.keys())) + vals = np.array(list(markers_mapping.values())) + + order = np.argsort(keys) + keys = keys[order] + vals = vals[order] + + return keys, vals + + +marker_int_searchsorted_keys, marker_int_searchsorted_vals = init_searchsorted( + marker_int_mapping +) + + +def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]): + # Vectorized lookup + indices = np.searchsorted(marker_int_searchsorted_keys, markers_str_array) + return marker_int_searchsorted_vals[indices] + + +class VertexMarkers(BufferManager): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which markers were indexed/sliced", + }, + { + "dict key": "value", + "type": "str | np.ndarray[str]", + "description": "new marker values for points that were changed", + }, + ] + + def __init__( + self, + markers: str | Sequence[str] | np.ndarray, + n_datapoints: int, + property_name: str = "markers", + ): + """ + Manages the markers buffer for the scatter points. Supports fancy indexing. + """ + + # first validate then allocate buffers + + if isinstance(markers, str): + markers = user_input_to_marker(markers) + + elif isinstance(markers, (tuple, list, np.ndarray)): + validate_user_markers_array(markers) + + # allocate buffers + markers_int_array = np.zeros(n_datapoints, dtype=np.int32) + + marker_str_length = max(map(len, list(pygfx.MarkerShape))) + + self._markers_readable_array = np.empty( + n_datapoints, dtype=f" np.ndarray[str]: + """numpy array of per-vertex marker shapes in human-readable form""" + return self._markers_readable_array + + @property + def value_int(self) -> np.ndarray[np.int32]: + """numpy array of the actual int32 buffer that represents per-vertex marker shapes on the GPU""" + return self.buffer.data + + def _set_markers_arrays(self, key, value, n_markers): + if isinstance(value, str): + # set markers at these indices to this value + m = user_input_to_marker(value) + self._markers_readable_array[key] = m + self.value_int[key] = marker_int_mapping[m] + + elif isinstance(value, (np.ndarray, list, tuple)): + if n_markers != len(value): + raise IndexError( + f"Must provide one marker value, or an array/list/tuple of marker values with the same length " + f"as the slice. You have provided the slice: {key}, which refers to {n_markers} markers, but " + f"provided {len(value)} new marker values. You must provide 1 or {n_markers} values." + ) + + validate_user_markers_array(value) + + new_markers_human_readable = vectorized_user_markers_to_std_markers(value) + new_markers_int = searchsorted_markers_to_int_array( + new_markers_human_readable + ) + + self._markers_readable_array[key] = new_markers_human_readable + self.value_int[key] = new_markers_int + else: + raise TypeError( + "new markers value must be a str, Sequence or np.ndarray of new marker values" + ) + + @block_reentrance + def __setitem__( + self, + key: int | slice | list[int | bool] | np.ndarray[int | bool], + value: str | Sequence[str] | np.ndarray[str], + ): + if isinstance(key, int): + if key >= self.value.size: + raise IndexError(f"index : {key} out of bounds: {self.value.size}") + + if not isinstance(value, str): + # only a single marker should be provided if changing one at one index + raise TypeError( + f"you must provide a marker value if providing a single index, " + f"you have passed index: {key} and value: {value}" + ) + + m = user_input_to_marker(value) + self._markers_readable_array[key] = m + self.value_int[key] = marker_int_mapping[m] + + elif isinstance(key, slice): + # find the number of new markers by converting slice to range and then parse markers + start, stop, step = key.indices(self.value.size) + + n_markers = len(range(start, stop, step)) + self._set_markers_arrays(key, value, n_markers) + + elif isinstance(key, (list, np.ndarray)): + key = np.asarray(key) # convert to array if list + + 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 " + f"a bool array of size: {key.size}" + ) + + n_markers = np.count_nonzero(key) + self._set_markers_arrays(key, value, n_markers) + + # if it's an array of int + elif np.issubdtype(key.dtype, np.integer): + if key.size > self.buffer.data.shape[0]: + raise IndexError( + f"Length of array for fancy indexing must be <= n_datapoints. " + f"There are: {self.buffer.data.shape[0]} datapoints, you have passed an " + f"integer array for fancy indexing of size: {key.size}" + ) + n_markers = key.size + self._set_markers_arrays(key, value, n_markers) + + else: + # 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}" + ) + + else: + raise TypeError( + f"Can only set markers by slicing/indexing using the one of the following types: " + f"int | slice | list[int | bool] | np.ndarray[int | bool], you have passed" + f"sliced using the following type: {type(key)}" + ) + + # _update_range handles parsing the key to + # determine offset and size for GPU upload + self._update_range(key) + + self._emit_event(self._property_name, key, value) + + def __len__(self): + return len(self.buffer.data) + + +class UniformMarker(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | None", + "description": "new marker value", + }, + ] + + def __init__(self, marker: str, property_name: str = "markers"): + """Manages evented uniform buffer for scatter marker""" + + self._value = user_input_to_marker(marker) + super().__init__(property_name=property_name) + + @property + def value(self) -> str: + return self._value + + @block_reentrance + def set_value(self, graphic, value: str): + value = user_input_to_marker(value) + graphic.world_object.material.marker = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class UniformEdgeColor(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | np.ndarray | pygfx.Color | Sequence[float]", + "description": "new edge_color", + }, + ] + + def __init__( + self, + edge_color: str | np.ndarray | pygfx.Color | Sequence[float], + property_name: str = "edge_colors", + ): + """Manages evented uniform buffer for scatter marker edge_color""" + + self._value = pygfx.Color(edge_color) + super().__init__(property_name=property_name) + + @property + def value(self) -> pygfx.Color: + return self._value + + @block_reentrance + def set_value( + self, graphic, value: str | np.ndarray | pygfx.Color | Sequence[float] + ): + graphic.world_object.material.edge_color = pygfx.Color(value) + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class EdgeWidth(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new edge_width", + }, + ] + + def __init__(self, edge_width: float, property_name: str = "edge_width"): + """Manages evented uniform buffer for scatter marker edge_width""" + + self._value = edge_width + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic.world_object.material.edge_width = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class UniformRotations(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new edge_width", + }, + ] + + def __init__(self, edge_width: float, property_name: str = "point_rotations"): + """Manages evented uniform buffer for scatter marker rotation""" + + self._value = edge_width + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic.world_object.material.rotations = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VertexRotations(BufferManager): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which point rotations were indexed/sliced", + }, + { + "dict key": "value", + "type": "int | float | array-like", + "description": "new rotation values for points that were changed", + }, + ] + + def __init__( + self, + rotations: int | float | np.ndarray | Sequence[int | float], + n_datapoints: int, + isolated_buffer: bool = True, + property_name: str = "point_rotations", + ): + """ + Manages rotations buffer of scatter points. + """ + sizes = self._fix_sizes(rotations, n_datapoints) + super().__init__( + data=sizes, isolated_buffer=isolated_buffer, property_name=property_name + ) + + def _fix_sizes( + self, + sizes: int | float | np.ndarray | Sequence[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 `rotations` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError( + "`rotations` must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints" + ) + + return sizes + + @block_reentrance + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | list[int | bool], + value: int | float | np.ndarray | Sequence[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(self._property_name, key, value) + + def __len__(self): + return len(self.buffer.data) + + +class VertexPointSizes(BufferManager): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which point sizes were indexed/sliced", + }, + { + "dict key": "value", + "type": "int | float | array-like", + "description": "new size values for points that were changed", + }, + ] + + def __init__( + self, + sizes: int | float | np.ndarray | Sequence[int | float], + n_datapoints: int, + isolated_buffer: bool = True, + property_name: str = "sizes", + ): + """ + Manages sizes buffer of scatter points. + """ + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__( + data=sizes, isolated_buffer=isolated_buffer, property_name=property_name + ) + + def _fix_sizes( + self, + sizes: int | float | np.ndarray | Sequence[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 + + @block_reentrance + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | list[int | bool], + value: int | float | np.ndarray | Sequence[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(self._property_name, key, value) + + def __len__(self): + return len(self.buffer.data) + + +class UniformSize(GraphicFeature): + event_info_spec = [ + {"dict key": "value", "type": "float", "description": "new size value"}, + ] + + def __init__(self, value: int | float, property_name: str = "sizes"): + """Manages uniform size for scatter material""" + + self._value = float(value) + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float | int): + value = float(value) + graphic.world_object.material.size = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 233353401..654b3d4c6 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -1,9 +1,11 @@ from typing import Sequence import numpy as np +import pygfx as gfx from ...utils import mesh_masks from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance +from ...utils.triangulation import triangulate class LinearSelectionFeature(GraphicFeature): @@ -38,7 +40,7 @@ def __init__(self, axis: str, value: float, limits: tuple[float, float]): """ - super().__init__() + super().__init__(property_name="selection") self._axis = axis self._limits = limits @@ -62,13 +64,13 @@ def set_value(self, selector, value: float): elif self._axis == "y": dim = 1 - for edge in selector._edges: - edge.geometry.positions.data[:, dim] = value - edge.geometry.positions.update_range() + edge = selector._edges[0] + edge.geometry.positions.data[:, dim] = value + edge.geometry.positions.update_range() self._value = value - event = GraphicFeatureEvent("selection", {"value": value}) + event = GraphicFeatureEvent(self._property_name, {"value": value}) event.get_selected_index = selector.get_selected_index self._call_event_handlers(event) @@ -97,7 +99,7 @@ class LinearRegionSelectionFeature(GraphicFeature): ] def __init__(self, value: tuple[int, int], axis: str, limits: tuple[float, float]): - super().__init__() + super().__init__(property_name="selection") self._axis = axis self._limits = limits @@ -150,10 +152,10 @@ def set_value(self, selector, value: Sequence[float]): selector.fill.geometry.positions.data[mesh_masks.x_right] = value[1] # change x position of the left edge line - selector.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 - selector.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 @@ -163,24 +165,24 @@ def set_value(self, selector, value: Sequence[float]): selector.fill.geometry.positions.data[mesh_masks.y_top] = value[1] # change y position of the bottom edge line - selector.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 - selector.edges[1].geometry.positions.data[:, 1] = value[1] + selector._edges[1].geometry.positions.data[:, 1] = value[1] self._value = value # send changes to GPU selector.fill.geometry.positions.update_range() - selector.edges[0].geometry.positions.update_range() - selector.edges[1].geometry.positions.update_range() + selector._edges[0].geometry.positions.update_range() + selector._edges[1].geometry.positions.update_range() # send event if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent("selection", {"value": self.value}) + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data @@ -218,7 +220,7 @@ def __init__( value: tuple[float, float, float, float], limits: tuple[float, float, float, float], ): - super().__init__() + super().__init__(property_name="selection") self._limits = limits self._value = tuple(int(v) for v in value) @@ -333,7 +335,110 @@ def set_value(self, selector, value: Sequence[float]): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent("selection", {"value": self.value}) + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) + + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + + # calls any events + self._call_event_handlers(event) + + +class PolygonSelectionFeature(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new array of points that represents the polygon selection", + }, + ] + + event_extra_attrs = [ + { + "attribute": "get_selected_indices", + "type": "callable", + "description": "returns indices under the selector", + }, + { + "attribute": "get_selected_data", + "type": "callable", + "description": "returns data under the selector", + }, + ] + + def __init__( + self, + value: Sequence[tuple[float]], + limits: tuple[float, float, float, float], + ): + super().__init__(property_name="selection") + + self._limits = limits + self._value = np.asarray(value).reshape(-1, 3).astype(float) + + @property + def value(self) -> np.ndarray[float]: + """ + The array of the polygon, in data space + """ + return self._value + + @block_reentrance + def set_value(self, selector, value: Sequence[tuple[float]]): + """ + Set the selection of the rectangle selector. + + Parameters + ---------- + selector: PolygonSelector + + value: array + new values (3D points) of the selection + """ + + value = np.asarray(value, dtype=np.float32) + + if not value.shape[1] == 3: + raise TypeError( + "Selection must be an array, tuple, list, or sequence of the shape Nx3." + ) + + # clip values if they are beyond the limits + value[:, 0] = value[:, 0].clip(self._limits[0], self._limits[1]) + value[:, 1] = value[:, 1].clip(self._limits[2], self._limits[3]) + + self._value = value + + if len(value) >= 3: + indices = triangulate(value) + else: + indices = np.zeros((0, 3), np.int32) + + geometry = selector.geometry + + # Need larger buffer? + if len(value) > geometry.positions.nitems: + arr = np.zeros((geometry.positions.nitems * 2, 3), np.float32) + geometry.positions = gfx.Buffer(arr) + if len(indices) > geometry.indices.nitems: + arr = np.zeros((geometry.indices.nitems * 2, 3), np.int32) + geometry.indices = gfx.Buffer(arr) + + geometry.positions.data[: len(value)] = value + geometry.positions.data[len(value) :] = value[-1] if len(value) else (0, 0, 0) + geometry.positions.draw_range = 0, len(value) + geometry.positions.update_full() + + geometry.indices.data[: len(indices)] = indices + geometry.indices.data[len(indices) :] = 0 + geometry.indices.draw_range = 0, len(indices) + geometry.indices.update_full() + + # send event + if len(self._event_handlers) < 1: + return + + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data diff --git a/fastplotlib/graphics/features/_text.py b/fastplotlib/graphics/features/_text.py index d8e5e95e8..ed0485d3a 100644 --- a/fastplotlib/graphics/features/_text.py +++ b/fastplotlib/graphics/features/_text.py @@ -16,7 +16,7 @@ class TextData(GraphicFeature): def __init__(self, value: str): self._value = value - super().__init__() + super().__init__(property_name="text") @property def value(self) -> str: @@ -27,7 +27,7 @@ def set_value(self, graphic, value: str): graphic.world_object.set_text(value) self._value = value - event = GraphicFeatureEvent(type="text", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -42,7 +42,7 @@ class FontSize(GraphicFeature): def __init__(self, value: float | int): self._value = value - super().__init__() + super().__init__(property_name="font_size") @property def value(self) -> float | int: @@ -53,7 +53,7 @@ def set_value(self, graphic, value: float | int): graphic.world_object.font_size = value self._value = graphic.world_object.font_size - event = GraphicFeatureEvent(type="font_size", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -68,7 +68,7 @@ class TextFaceColor(GraphicFeature): def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): self._value = pygfx.Color(value) - super().__init__() + super().__init__(property_name="face_color") @property def value(self) -> pygfx.Color: @@ -80,7 +80,7 @@ def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float graphic.world_object.material.color = value self._value = graphic.world_object.material.color - event = GraphicFeatureEvent(type="face_color", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -95,7 +95,7 @@ class TextOutlineColor(GraphicFeature): def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): self._value = pygfx.Color(value) - super().__init__() + super().__init__(property_name="outline_color") @property def value(self) -> pygfx.Color: @@ -107,7 +107,7 @@ def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float graphic.world_object.material.outline_color = value self._value = graphic.world_object.material.outline_color - event = GraphicFeatureEvent(type="outline_color", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -122,7 +122,7 @@ class TextOutlineThickness(GraphicFeature): def __init__(self, value: float): self._value = value - super().__init__() + super().__init__(property_name="outline_thickness") @property def value(self) -> float: @@ -133,5 +133,5 @@ def set_value(self, graphic, value: float): graphic.world_object.material.outline_thickness = value self._value = graphic.world_object.material.outline_thickness - event = GraphicFeatureEvent(type="outline_thickness", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py new file mode 100644 index 000000000..9c86d25fc --- /dev/null +++ b/fastplotlib/graphics/features/_vectors.py @@ -0,0 +1,187 @@ +import numpy as np +import pylinalg as la + +from ._base import ( + GraphicFeature, + GraphicFeatureEvent, + block_reentrance, +) + + +# it doesn't make sense to modify just a portion of a vector field, I can't think of a use case. +# so we only allow setting the entire buffer, but allow getting portions of it +class VectorPositions(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new vector positions", + }, + ] + + def __init__( + self, + positions: np.ndarray, + isolated_buffer: bool = True, + property_name: str = "positions", + ): + """ + Manages vector field positions by managing the translation elements of the mesh instance transform matrix buffer + """ + + positions = np.asarray(positions, dtype=np.float32) + if positions.ndim != 2: + raise ValueError( + f"vector field positions must be of shape [n, 2] or [n, 3]" + ) + + if positions.shape[1] == 2: + positions = np.column_stack( + [ + positions[:, 0], + positions[:, 1], + np.zeros(positions.shape[0], dtype=np.float32), + ] + ) + + elif positions.shape[1] == 3: + pass + + else: + raise ValueError( + f"vector field positions must be of shape [n, 2] or [n, 3]" + ) + + self._positions = positions + + super().__init__(property_name=property_name) + + @property + def value(self) -> np.ndarray: + return self._positions + + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + raise NotImplementedError( + "cannot set individual slices of vector positions, must set all positions" + ) + + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + if value.shape[0] != self._positions.shape[0]: + raise ValueError( + f"number of vector positions in passed array != number of vectors in graphic: " + f"{value.shape[0]} != {self._positions.shape[0]}" + ) + + if value.shape[1] == 2: + # assume 2d + self._positions[:, :-1] = value + + else: + self._positions[:] = value + + for i in range(self._positions.shape[0]): + # only need to update the translation vector + graphic.world_object.instance_buffer.data["matrix"][i][3, 0:3] = ( + self._positions[i] + ) + + graphic.world_object.instance_buffer.update_full() + + event = GraphicFeatureEvent(type="positions", info={"value": value}) + self._call_event_handlers(event) + + +class VectorDirections(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new vector directions", + }, + ] + + # vector is always pointing in [0, 0, 1] when mesh is initialized + init_direction = np.array([0, 0, 1]) + init_direction.flags.writeable = False + + def __init__( + self, + directions: np.ndarray, + isolated_buffer: bool = True, + property_name: str = "directions", + ): + """Manages vector field positions by managing the mesh instance buffer's full transform matrix""" + + directions = np.asarray(directions, dtype=np.float32) + if directions.ndim != 2: + raise ValueError( + f"vector field directions must be of shape [n, 2] or [n, 3]" + ) + + if directions.shape[1] == 2: + directions = np.column_stack( + [ + directions[:, 0], + directions[:, 1], + np.zeros(directions.shape[0], dtype=np.float32), + ] + ) + + elif directions.shape[1] == 3: + pass + + else: + raise ValueError( + f"vector field directions must be of shape [n, 2] or [n, 3]" + ) + + self._directions = directions + + super().__init__(property_name=property_name) + + @property + def value(self) -> np.ndarray: + return self._directions + + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + raise NotImplementedError( + "cannot set individual slices of vector directions, must set all directions" + ) + + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + if value.shape[0] != self._directions.shape[0]: + raise ValueError( + f"number of vector directions in passed array != number of vectors in graphic: " + f"{value.shape[0]} != {self._directions.shape[0]}" + ) + + if value.shape[1] == 2: + # assume 2d + self._directions[:, :-1] = value + + else: + self._directions[:] = value + + # vector determines the size of the vector + magnitudes = np.linalg.norm(self._directions, axis=1, ord=2) + + for i in range(self._directions.shape[0]): + # get quaternion to rotate vector to new direction + rotation = la.quat_from_vecs(self.init_direction, self._directions[i]) + # get the new transform + transform = la.mat_compose(graphic.positions[i], rotation, magnitudes[i]) + # set the buffer + graphic.world_object.instance_buffer.data["matrix"][i] = transform.T + + graphic.world_object.instance_buffer.update_full() + + event = GraphicFeatureEvent(type="directions", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py new file mode 100644 index 000000000..ec4c4052a --- /dev/null +++ b/fastplotlib/graphics/features/_volume.py @@ -0,0 +1,445 @@ +from itertools import product +from math import ceil + +import numpy as np +import pygfx + +from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance + +VOLUME_RENDER_MODES = { + "mip": pygfx.VolumeMipMaterial, + "minip": pygfx.VolumeMinipMaterial, + "iso": pygfx.VolumeIsoMaterial, + "slice": pygfx.VolumeSliceMaterial, +} + + +class TextureArrayVolume(GraphicFeature): + """ + Manages an array of Textures representing chunks of an image. Chunk size is the GPU's max texture limit. + + Creates and manages multiple pygfx.Texture objects. + """ + + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index, numpy-like fancy index", + "description": "key at which image data was sliced/fancy indexed", + }, + { + "dict key": "value", + "type": "np.ndarray | float", + "description": "new data values", + }, + ] + + def __init__(self, data, isolated_buffer: bool = True): + super().__init__(property_name="data") + + data = self._fix_data(data) + + shared = pygfx.renderers.wgpu.get_shared() + + self._texture_size_limit = shared.device.limits["max-texture-dimension-3d"] + + 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[1] / self._texture_size_limit) + * self._texture_size_limit, + self._texture_size_limit, + ) + self._col_indices = np.arange( + 0, + ceil(self.value.shape[2] / self._texture_size_limit) + * self._texture_size_limit, + self._texture_size_limit, + ) + + self._zdim_indices = np.arange( + 0, + ceil(self.value.shape[0] / self._texture_size_limit) + * self._texture_size_limit, + self._texture_size_limit, + ) + + shape = (self.zdim_indices.size, self.row_indices.size, self.col_indices.size) + + # buffer will be an array of textures + self._buffer: np.ndarray[pygfx.Texture] = np.empty(shape=shape, 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=3) + + self.buffer[buffer_index] = texture + + @property + def value(self) -> np.ndarray: + """The full array that represents all the data within this TextureArray""" + return self._value + + def set_value(self, graphic, value): + self[:] = value + + @property + def buffer(self) -> np.ndarray[pygfx.Texture]: + """array of buffers that are mapped to the GPU""" + 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 zdim_indices(self) -> np.ndarray: + """ + z dimension indices that are used to chunk the big data array + into individual Textures on the GPU + """ + return self._zdim_indices + + def _fix_data(self, data): + if data.ndim not in (3, 4): + raise ValueError( + "Volume Image data must be 3D with or without an RGB(A) dimension, i.e. " + "it must be of shape [z, rows, cols], [z, rows, cols, 3] or [z, rows, cols, 4]" + ) + + # let's just cast to float32 always + return data.astype(np.float32) + + def __iter__(self): + self._iter = product( + enumerate(self.zdim_indices), + enumerate(self.row_indices), + enumerate(self.col_indices), + ) + + return self + + def __next__( + self, + ) -> tuple[pygfx.Texture, tuple[int, int, int], tuple[slice, 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 indices + ( + (chunk_z, data_z_start), + (chunk_row, data_row_start), + (chunk_col, data_col_start), + ) = next(self._iter) + + # indices for to self.buffer for this chunk + chunk_index = (chunk_z, chunk_row, chunk_col) + + # stop indices of big data array for this chunk + z_stop = min(self.value.shape[0], data_z_start + self._texture_size_limit) + row_stop = min(self.value.shape[1], data_row_start + self._texture_size_limit) + col_stop = min(self.value.shape[2], data_col_start + self._texture_size_limit) + + # zdim, row and column slices that slice the data for this chunk from the big data array + data_slice = ( + slice(data_z_start, z_stop), + 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] + + @block_reentrance + def __setitem__(self, key, value): + self.value[key] = value + + for texture in self.buffer.ravel(): + texture.update_range((0, 0, 0), texture.size) + + event = GraphicFeatureEvent( + self._property_name, info={"key": key, "value": value} + ) + self._call_event_handlers(event) + + def __len__(self): + return self.buffer.size + + +def create_volume_material_kwargs(graphic, mode: str): + kwargs = { + "clim": (graphic.vmin, graphic.vmax), + "map": graphic._texture_map, + "interpolation": graphic.interpolation, + "pick_write": True, + } + + if mode == "iso": + more_kwargs = { + attr: getattr(graphic, attr) + for attr in [ + "threshold", + "step_size", + "substep_size", + "emissive", + "shininess", + ] + } + + elif mode == "slice": + more_kwargs = {"plane": graphic.plane} + else: + more_kwargs = {} + + kwargs.update(more_kwargs) + return kwargs + + +class VolumeRenderMode(GraphicFeature): + """Volume rendering mode, controls world object material""" + + event_info_spec = [ + { + "dict key": "value", + "type": "str", + "description": "volume rendering mode that has been set", + }, + ] + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__(property_name="mode") + + @property + def value(self) -> str: + return self._value + + def _validate(self, value): + if value not in VOLUME_RENDER_MODES.keys(): + raise ValueError( + f"Given render mode: {value} is invalid. Valid render modes are: {VOLUME_RENDER_MODES.keys()}" + ) + + @block_reentrance + def set_value(self, graphic, value: str): + self._validate(value) + + VolumeMaterialCls = VOLUME_RENDER_MODES[value] + + kwargs = create_volume_material_kwargs(graphic, mode=value) + + new_material = VolumeMaterialCls(**kwargs) + # since the world object is a group + for volume_tile in graphic.world_object.children: + volume_tile.material = new_material + + # so we have one place to reference it + graphic._material = new_material + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoThreshold(GraphicFeature): + """Isosurface threshold""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new isosurface threshold", + }, + ] + + def __init__(self, value: float): + self._value = value + super().__init__(property_name="threshold") + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.threshold = value + self._value = graphic._material.threshold + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoStepSize(GraphicFeature): + """Isosurface step_size""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new isosurface step_size", + }, + ] + + def __init__(self, value: float): + self._value = value + super().__init__(property_name="step_size") + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.step_size = value + self._value = graphic._material.step_size + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoSubStepSize(GraphicFeature): + """Isosurface substep_size""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new isosurface step_size", + }, + ] + + def __init__(self, value: float): + self._value = value + super().__init__(property_name="substep_size") + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.substep_size = value + self._value = graphic._material.substep_size + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoEmissive(GraphicFeature): + """Isosurface emissive color""" + + event_info_spec = [ + { + "dict key": "value", + "type": "pygfx.Color", + "description": "new isosurface emissive color", + }, + ] + + def __init__(self, value: pygfx.Color | str | tuple | np.ndarray): + self._value = pygfx.Color(value) + super().__init__(property_name="emissive") + + @property + def value(self) -> pygfx.Color: + return self._value + + @block_reentrance + def set_value(self, graphic, value: pygfx.Color | str | tuple | np.ndarray): + graphic._material.emissive = value + self._value = graphic._material.emissive + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoShininess(GraphicFeature): + """Isosurface shininess""" + + event_info_spec = [ + { + "dict key": "value", + "type": "int", + "description": "new isosurface shininess", + }, + ] + + def __init__(self, value: int): + self._value = value + super().__init__(property_name="shininess") + + @property + def value(self) -> int: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.shininess = value + self._value = graphic._material.shininess + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VolumeSlicePlane(GraphicFeature): + """Volume plane""" + + event_info_spec = [ + { + "dict key": "value", + "type": "tuple[float, float, float, float]", + "description": "new plane slice", + }, + ] + + def __init__(self, value: tuple[float, float, float, float]): + self._value = value + super().__init__(property_name="plane") + + @property + def value(self) -> tuple[float, float, float, float]: + return self._value + + @block_reentrance + def set_value(self, graphic, value: tuple[float, float, float, float]): + graphic._material.plane = value + self._value = graphic._material.plane + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index e2f6e3428..408610e1e 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -6,9 +6,7 @@ def parse_colors( - colors: str | np.ndarray | list[str] | tuple[str], - n_colors: int | None, - alpha: float | None = None, + colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None ): """ @@ -16,8 +14,6 @@ def parse_colors( ---------- colors n_colors - alpha - key Returns ------- @@ -30,20 +26,22 @@ def parse_colors( colors = colors.tolist() # if the color is provided as a numpy array if isinstance(colors, np.ndarray): - if colors.shape == (4,): # single RGBA array + if colors.shape == (3,): # single RGB array + data = np.repeat(np.array([colors]), n_colors, axis=0) + elif 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: + if not (colors.shape[1] in (3, 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]" + "RGB or RGBA arrays for each datapoint in the shape [n_datapoints, 3] or [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]" + "Valid array color arguments must be a single RGB(A) array or a stack of " + "RGB(A) arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4]" ) # if the color is provided as list or tuple @@ -58,8 +56,8 @@ def parse_colors( 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: + # if it's a single RGB/RGBA array as a tuple/list + elif len(colors) in (3, 4): c = pygfx.Color(colors) data = np.repeat(np.array([c]), n_colors, axis=0) @@ -70,18 +68,11 @@ def parse_colors( ) elif isinstance(colors, str): if colors == "random": - data = np.random.rand(n_colors, 4) - data[:, -1] = alpha + data = np.random.rand(n_colors, 3) 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/image.py b/fastplotlib/graphics/image.py index 957607fe1..1eaf54bb6 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -5,7 +5,12 @@ from ..utils import quick_min_max from ._base import Graphic -from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector +from .selectors import ( + LinearSelector, + LinearRegionSelector, + RectangleSelector, + PolygonSelector, +) from .features import ( TextureArray, ImageCmap, @@ -83,8 +88,8 @@ class ImageGraphic(Graphic): def __init__( self, data: Any, - vmin: int = None, - vmax: int = None, + vmin: float = None, + vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", @@ -100,11 +105,11 @@ def __init__( array-like, usually numpy.ndarray, must support ``memoryview()`` | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA - vmin: int, optional - minimum value for color scaling, calculated from data if not provided + vmin: float, optional + minimum value for color scaling, estimated from data if not provided - vmax: int, optional - maximum value for color scaling, calculated from data if not provided + vmax: float, optional + maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" colormap to use to display the data. For supported colormaps see the @@ -131,11 +136,20 @@ def __init__( world_object = pygfx.Group() - # texture array that manages the textures on the GPU for displaying this image - self._data = TextureArray(data, isolated_buffer=isolated_buffer) + if isinstance(data, TextureArray): + # share buffer + self._data = data + else: + # create new texture array to manage buffer + # texture array that manages the multiple textures on the GPU that represent this image + self._data = TextureArray(data, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + _vmin, _vmax = quick_min_max(self.data.value) + if vmin is None: + vmin = _vmin + if vmax is None: + vmax = _vmax # other graphic features self._vmin = ImageVmin(vmin) @@ -167,9 +181,8 @@ def __init__( ) # iterate through each texture chunk and create - # an _ImageTIle, offset the tile using the data indices + # an _ImageTile, offset the tile using the data indices for texture, chunk_index, data_slice in self._data: - # create an ImageTile using the texture for this chunk img = _ImageTile( geometry=pygfx.Geometry(grid=texture), @@ -201,15 +214,16 @@ def data(self, data): self._data[:] = data @property - def cmap(self) -> str: + def cmap(self) -> str | None: """ - Get or set the colormap + Get or set the colormap for grayscale images. Returns ``None`` if image is RGB(A). For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ """ - if self.data.value.ndim > 2: - raise AttributeError("RGB(A) images do not have a colormap property") - return self._cmap.value + if self._cmap is not None: + return self._cmap.value + + return None @cmap.setter def cmap(self, name: str): @@ -310,9 +324,6 @@ def add_linear_selector( self._plot_area.add_graphic(selector, center=False) - # place selector above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - return selector def add_linear_region_selector( @@ -388,9 +399,6 @@ def add_linear_region_selector( self._plot_area.add_graphic(selector, center=False) - # place above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - return selector def add_rectangle_selector( @@ -433,7 +441,39 @@ def add_rectangle_selector( self._plot_area.add_graphic(selector, center=False) - # place above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) + return selector + + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + + """ + + # min/max limits are image shape + # rows are ys, columns are xs + limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0]) + + selector = PolygonSelector( + selection, + limits, + fill_color=fill_color, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) return selector diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py new file mode 100644 index 000000000..db616b30d --- /dev/null +++ b/fastplotlib/graphics/image_volume.py @@ -0,0 +1,421 @@ +from typing import * + +import numpy as np +import pygfx + +from ..utils import quick_min_max +from ._base import Graphic +from .features import ( + TextureArrayVolume, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, + VolumeRenderMode, + VolumeIsoThreshold, + VolumeIsoStepSize, + VolumeIsoSubStepSize, + VolumeIsoEmissive, + VolumeIsoShininess, + VolumeSlicePlane, + VOLUME_RENDER_MODES, + create_volume_material_kwargs, +) + + +class _VolumeTile(pygfx.Volume): + """ + Similar to pygfx.Volume, only difference is that it modifies the pick_info + by adding the data row start indices that correspond to this chunk of the big Volume + """ + + def __init__( + self, + geometry, + material, + data_slice: tuple[slice, slice, slice], + chunk_index: tuple[int, int, int], + **kwargs, + ): + super().__init__(geometry, material, **kwargs) + + self._data_slice = data_slice + self._chunk_index = chunk_index + + def _wgpu_get_pick_info(self, pick_value): + pick_info = super()._wgpu_get_pick_info(pick_value) + + data_z_start, data_row_start, data_col_start = ( + self.data_slice[0].start, + self.data_slice[1].start, + self.data_slice[2].start, + ) + + # add the actual data row and col start indices + x, y, z = pick_info["index"] + x += data_col_start + y += data_row_start + z += data_z_start + pick_info["index"] = (x, y, z) + + xp, yp, zp = pick_info["voxel_coord"] + xp += data_col_start + yp += data_row_start + zp += data_z_start + pick_info["voxel_coord"] = (xp, yp, zp) + + # add row chunk and col chunk index to pick_info dict + return { + **pick_info, + "data_slice": self.data_slice, + "chunk_index": self.chunk_index, + } + + @property + def data_slice(self) -> tuple[slice, slice, slice]: + return self._data_slice + + @property + def chunk_index(self) -> tuple[int, int, int]: + return self._chunk_index + + +class ImageVolumeGraphic(Graphic): + _features = { + "data": TextureArrayVolume, + "cmap": ImageCmap, + "vmin": ImageVmin, + "vmax": ImageVmax, + "interpolation": ImageInterpolation, + "cmap_interpolation": ImageCmapInterpolation, + "mode": VolumeRenderMode, + "threshold": VolumeIsoThreshold, + "step_size": VolumeIsoStepSize, + "substep_size": VolumeIsoSubStepSize, + "emissive": VolumeIsoEmissive, + "shininess": VolumeIsoShininess, + "plane": VolumeSlicePlane, + } + + def __init__( + self, + data: Any, + mode: str = "mip", + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + interpolation: str = "linear", + cmap_interpolation: str = "linear", + plane: tuple[float, float, float, float] = (0, 0, -1, 0), + threshold: float = 0.5, + step_size: float = 1.0, + substep_size: float = 0.1, + emissive: str | tuple | np.ndarray = (0, 0, 0), + shininess: int = 30, + isolated_buffer: bool = True, + **kwargs, + ): + """ + Create an ImageVolumeGraphic. + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()``. + Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) + + mode: str, default "mip" + render mode, one of "mip", "minip", "iso" or "slice" + + vmin: float + lower contrast limit + + vmax: float + upper contrast limit + + cmap: str, default "plasma" + colormap for grayscale volumes + + interpolation: str, default "linear" + interpolation method for sampling pixels + + cmap_interpolation: str, default "linear" + interpolation method for sampling from colormap + + plane: (float, float, float, float), default (0, 0, -1, 0) + Slice volume at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice" + + threshold : float, default 0.5 + The threshold texture value at which the surface is rendered. + Used only if `mode` = "iso" + + step_size : float, default 1.0 + The size of the initial ray marching step for the initial surface finding. Smaller values will result in + more accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + substep_size : float, default 0.1 + The size of the raymarching step for the refined surface finding. Smaller values will result in more + accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + emissive : Color, default (0, 0, 0, 1) + The emissive color of the surface. I.e. the color that the object emits even when not lit by a light + source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored. + Used only if `mode` = "iso" + + shininess : int, default 30 + How shiny the specular highlight is; a higher value gives a sharper highlight. + Used only if `mode` = "iso" + + 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 - useful if the array is large. + + kwargs + additional keyword arguments passed to :class:`.Graphic` + + """ + + valid_modes = VOLUME_RENDER_MODES.keys() + if mode not in valid_modes: + raise ValueError( + f"invalid mode specified: {mode}, valid modes are: {valid_modes}" + ) + + super().__init__(**kwargs) + + world_object = pygfx.Group() + + if isinstance(data, TextureArrayVolume): + # share existing buffer + self._data = data + else: + # create new texture array to manage buffer + # texture array that manages the textures on the GPU that represent this image volume + self._data = TextureArrayVolume(data, isolated_buffer=isolated_buffer) + + if (vmin is None) or (vmax is None): + _vmin, _vmax = quick_min_max(self.data.value) + if vmin is None: + vmin = _vmin + if vmax is None: + vmax = _vmax + + # other graphic features + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) + + self._interpolation = ImageInterpolation(interpolation) + + # TODO: I'm assuming RGB volume images aren't supported??? + # use TextureMap for grayscale images + self._cmap = ImageCmap(cmap) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) + + self._texture_map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + + self._plane = VolumeSlicePlane(plane) + self._threshold = VolumeIsoThreshold(threshold) + self._step_size = VolumeIsoStepSize(step_size) + self._substep_size = VolumeIsoSubStepSize(substep_size) + self._emissive = VolumeIsoEmissive(emissive) + self._shininess = VolumeIsoShininess(shininess) + + material_kwargs = create_volume_material_kwargs(graphic=self, mode=mode) + + VolumeMaterialCls = VOLUME_RENDER_MODES[mode] + + self._material = VolumeMaterialCls(**material_kwargs) + + self._mode = VolumeRenderMode(mode) + + # iterate through each texture chunk and create + # a _VolumeTile, offset the tile using the data indices + for texture, chunk_index, data_slice in self._data: + # create a _VolumeTile using the texture for this chunk + vol = _VolumeTile( + geometry=pygfx.Geometry(grid=texture), + material=self._material, + data_slice=data_slice, # used to parse pick_info + chunk_index=chunk_index, + ) + + # row and column start index for this chunk + data_z_start = data_slice[0].start + data_row_start = data_slice[1].start + data_col_start = data_slice[2].start + + # offset tile position using the indices from the big data array + # that correspond to this chunk + vol.world.z = data_z_start + vol.world.x = data_col_start + vol.world.y = data_row_start + + world_object.add(vol) + + self._set_world_object(world_object) + + @property + def data(self) -> TextureArrayVolume: + """Get or set the image data""" + return self._data + + @data.setter + def data(self, data): + self._data[:] = data + + @property + def mode(self) -> str: + """Get or set the volume rendering mode""" + return self._mode.value + + @mode.setter + def mode(self, mode: str): + self._mode.set_value(self, mode) + + @property + def cmap(self) -> str: + """Get or set colormap name""" + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + self._cmap.set_value(self, name) + + @property + def vmin(self) -> float: + """Get or set the lower contrast limit""" + return self._vmin.value + + @vmin.setter + def vmin(self, value: float): + self._vmin.set_value(self, value) + + @property + def vmax(self) -> float: + """Get or set the upper contrast limit""" + return self._vmax.value + + @vmax.setter + def vmax(self, value: float): + self._vmax.set_value(self, value) + + @property + def interpolation(self) -> str: + """Get or set the image data interpolation method""" + return self._interpolation.value + + @interpolation.setter + def interpolation(self, value: str): + self._interpolation.set_value(self, value) + + @property + def cmap_interpolation(self) -> str: + """Get or set the cmap interpolation method""" + return self._cmap_interpolation.value + + @cmap_interpolation.setter + def cmap_interpolation(self, value: str): + self._cmap_interpolation.set_value(self, value) + + @property + def plane(self) -> tuple[float, float, float, float]: + """Get or set displayed plane in the volume. Valid only for `slice` render mode.""" + return self._plane.value + + @plane.setter + def plane(self, value: tuple[float, float, float, float]): + if self.mode != "slice": + raise TypeError("`plane` property is only valid for `slice` render mode.") + + self._plane.set_value(self, value) + + @property + def threshold(self) -> float: + """Get or set isosurface threshold, only for `iso` mode""" + return self._threshold.value + + @threshold.setter + def threshold(self, value: float): + if self.mode != "iso": + raise TypeError( + "`threshold` property is only used for `iso` rendering mode" + ) + + self._threshold.set_value(self, value) + + @property + def step_size(self) -> float: + """Get or set isosurface step_size, only for `iso` mode""" + return self._step_size.value + + @step_size.setter + def step_size(self, value: float): + if self.mode != "iso": + raise TypeError( + "`step_size` property is only used for `iso` rendering mode" + ) + + self._step_size.set_value(self, value) + + @property + def substep_size(self) -> float: + """Get or set isosurface substep_size, only for `iso` mode""" + return self._substep_size.value + + @substep_size.setter + def substep_size(self, value: float): + if self.mode != "iso": + raise TypeError( + "`substep_size` property is only used for `iso` rendering mode" + ) + + self._substep_size.set_value(self, value) + + @property + def emissive(self) -> pygfx.Color: + """Get or set isosurface emissive color, only for `iso` mode. Pass a color, RGBA array or pygfx.Color""" + return self._emissive.value + + @emissive.setter + def emissive(self, value: pygfx.Color | str | tuple | np.ndarray): + if self.mode != "iso": + raise TypeError("`emissive` property is only used for `iso` rendering mode") + + self._emissive.set_value(self, value) + + @property + def shininess(self) -> int: + """Get or set isosurface shininess""" + return self._shininess.value + + @shininess.setter + def shininess(self, value: int): + if self.mode != "iso": + raise TypeError( + "`shininess` property is only used for `iso` rendering mode" + ) + + self._shininess.set_value(self, value) + + def reset_vmin_vmax(self): + """ + Reset the vmin, vmax by *estimating* it from the data + + Returns + ------- + None + + """ + + vmin, vmax = quick_min_max(self.data.value) + self.vmin = vmin + self.vmax = vmax diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 4cdc7f413..f2d862067 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,7 +5,12 @@ import pygfx from ._positions_base import PositionsGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) from .features import ( Thickness, VertexPositions, @@ -32,7 +37,6 @@ def __init__( thickness: float = 2.0, colors: str | np.ndarray | Sequence = "w", uniform_color: bool = False, - alpha: float = 1.0, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, isolated_buffer: bool = True, @@ -61,9 +65,6 @@ def __init__( 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". For supported colormaps see the @@ -84,7 +85,6 @@ def __init__( data=data, colors=colors, uniform_color=uniform_color, - alpha=alpha, cmap=cmap, cmap_transform=cmap_transform, isolated_buffer=isolated_buffer, @@ -96,24 +96,31 @@ def __init__( if thickness < 1.1: MaterialCls = pygfx.LineThinMaterial + aa = True else: MaterialCls = pygfx.LineMaterial + aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") + if uniform_color: geometry = pygfx.Geometry(positions=self._data.buffer) material = MaterialCls( + aa=aa, thickness=self.thickness, color_mode="uniform", color=self.colors, pick_write=True, thickness_space=self.size_space, + depth_compare="<=", ) else: material = MaterialCls( + aa=aa, thickness=self.thickness, color_mode="vertex", pick_write=True, thickness_space=self.size_space, + depth_compare="<=", ) geometry = pygfx.Geometry( positions=self._data.buffer, colors=self._colors.buffer @@ -175,9 +182,6 @@ def add_linear_selector( self._plot_area.add_graphic(selector, center=False) - # place selector above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - return selector def add_linear_region_selector( @@ -234,9 +238,6 @@ def add_linear_region_selector( self._plot_area.add_graphic(selector, center=False) - # 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 selector @@ -288,6 +289,46 @@ def add_rectangle_selector( return selector + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. + + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a + plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + """ + + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] + + x_axis_vals = data[:, 0] + y_axis_vals = data[:, 1] + + ymin = np.floor(y_axis_vals.min()).astype(int) + ymax = np.ceil(y_axis_vals.max()).astype(int) + + # min/max limits + limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5) + + selector = PolygonSelector( + selection, + limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args( self, axis: str, padding diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index de4139679..275cc1e47 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,7 +7,12 @@ from ..utils import parse_cmap_values from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature from .line import LineGraphic -from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) class _LineCollectionProperties: @@ -67,7 +72,7 @@ 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. + Optionally set using a tuple ("cmap", ) to set the transform.. Example: line_collection.cmap = ("jet", sine_transform_vals, 0.7) @@ -79,23 +84,20 @@ def cmap(self) -> CollectionFeature: def cmap(self, args): if isinstance(args, str): name = args - transform, alpha = None, 1.0 + transform = None elif len(args) == 1: name = args[0] - transform, alpha = None, None - + transform = None elif len(args) == 2: name, transform = args - alpha = None - - elif len(args) == 3: - name, transform, alpha = args + else: + raise ValueError( + "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" + ) - colors = parse_cmap_values( + self.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: @@ -127,7 +129,6 @@ def __init__( 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: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, name: str = None, @@ -159,9 +160,6 @@ def __init__( | 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 @@ -198,19 +196,19 @@ def __init__( if not isinstance(thickness, (float, int)): if len(thickness) != len(data): raise ValueError( - f"len(thickness) != len(data)\n" f"{len(thickness)} != {len(data)}" + f"len(thickness) != len(data)\n{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)}" + f"len(names) != len(data)\n{len(names)} != {len(data)}" ) if metadatas is not None: if len(metadatas) != len(data): raise ValueError( - f"len(metadata) != len(data)\n" f"{len(metadatas)} != {len(data)}" + f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}" ) if kwargs_lines is not None: @@ -248,7 +246,7 @@ def __init__( else: if isinstance(colors, np.ndarray): # single color for all lines in the collection as RGBA - if colors.shape == (4,): + if colors.shape in [(3,), (4,)]: single_color = True # colors specified for each line as array of shape [n_lines, RGBA] @@ -263,8 +261,7 @@ def __init__( elif isinstance(colors, str): if colors == "random": - colors = np.random.rand(len(data), 4) - colors[:, -1] = alpha + colors = np.random.rand(len(data), 3) single_color = False else: # parse string color @@ -381,9 +378,6 @@ def add_linear_selector( self._plot_area.add_graphic(selector, center=False) - # place selector above this graphic - selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - return selector def add_linear_region_selector( @@ -438,16 +432,13 @@ def add_linear_region_selector( self._plot_area.add_graphic(selector, center=False) - # 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 selector def add_rectangle_selector( self, - selection: tuple[float, float, float, float] = None, + selection: tuple[float, float, float] = None, **kwargs, ) -> RectangleSelector: """ @@ -486,6 +477,43 @@ def add_rectangle_selector( return selector + def add_polygon_selector( + self, + selection: List[tuple[float, float]] = None, + **kwargs, + ) -> PolygonSelector: + """ + Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: List of positions, optional + Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = PolygonSelector( + selection, + limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + def _get_linear_selector_init_args(self, axis, padding): # use bbox to get size and center bbox = self.world_object.get_world_bounding_box() @@ -526,7 +554,6 @@ def __init__( data: List[np.ndarray], 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, @@ -560,9 +587,6 @@ def __init__( | 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 @@ -604,7 +628,6 @@ def __init__( data=data, thickness=thickness, colors=colors, - alpha=alpha, cmap=cmap, cmap_transform=cmap_transform, name=name, diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index b31022f5b..a2e696a82 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -5,37 +5,58 @@ from ._positions_base import PositionsGraphic from .features import ( - PointsSizesFeature, + VertexPointSizes, UniformSize, SizeSpace, VertexPositions, VertexColors, UniformColor, VertexCmap, + VertexMarkers, + UniformMarker, + UniformEdgeColor, + EdgeWidth, + UniformRotations, + VertexRotations, + TextureArray, ) class ScatterGraphic(PositionsGraphic): _features = { "data": VertexPositions, - "sizes": (PointsSizesFeature, UniformSize), + "sizes": (VertexPointSizes, UniformSize), "colors": (VertexColors, UniformColor), "cmap": (VertexCmap, None), + "markers": (VertexMarkers, UniformMarker, None), + "edge_colors": (UniformEdgeColor, VertexColors, None), + "edge_width": (EdgeWidth, None), + "image": (TextureArray, None), "size_space": SizeSpace, + "point_rotations": (UniformRotations, VertexRotations, None), } def __init__( self, data: Any, - colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", uniform_color: bool = False, - alpha: float = 1.0, cmap: str = None, cmap_transform: np.ndarray = None, - isolated_buffer: bool = True, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + uniform_marker: bool = False, + custom_sdf: str = None, + edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", + uniform_edge_color: bool = True, + edge_width: float = 1.0, + image: np.ndarray = None, + point_rotations: float | np.ndarray = 0, + point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: float | np.ndarray | Sequence[float] = 1, uniform_size: bool = False, size_space: str = "screen", + isolated_buffer: bool = True, **kwargs, ): """ @@ -55,9 +76,6 @@ def __init__( if True, uses a uniform buffer for the scatter point colors. Useful if you need to save GPU VRAM when all points have the same 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". For supported colormaps see the @@ -66,9 +84,65 @@ def __init__( 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 if you have large arrays. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: None | str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers" + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. + + uniform_marker: bool, default False + Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use + the same marker for all points and want to save GPU RAM. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" + edge color of the markers, used when `mode` is "markers" + + uniform_edge_color: bool, default True + Set the same edge color for all markers. Useful for saving GPU RAM. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: ArrayLike, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float | ArrayLike = 0, + The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all + points, or an array of rotation values can be used to set per-point rotations + + point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" + * uniform: set the same rotation for every point, useful to save GPU RAM + * vertex: set per-vertex rotations + * curve: The rotation follows the curve of the line defined by the points (in screen space) sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points @@ -78,7 +152,11 @@ def __init__( save GPU VRAM when all points have the same size. size_space: str, default "screen" - coordinate space in which the size is expressed ("screen", "world", "model") + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + 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 if you have large arrays. kwargs passed to :class:`.Graphic` @@ -89,7 +167,6 @@ def __init__( data=data, colors=colors, uniform_color=uniform_color, - alpha=alpha, cmap=cmap, cmap_transform=cmap_transform, isolated_buffer=isolated_buffer, @@ -100,37 +177,259 @@ def __init__( n_datapoints = self.data.value.shape[0] geo_kwargs = {"positions": self._data.buffer} - material_kwargs = {"pick_write": True} + + aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") + + material_kwargs = dict( + pick_write=True, + aa=aa, + depth_compare="<=", + ) + + self._markers: VertexMarkers | UniformMarker | None = None + self._edge_colors: UniformEdgeColor | VertexColors | None = None + self._edge_width: EdgeWidth | None = None + self._point_rotations: VertexRotations | UniformRotations | None = None + self._image: TextureArray | None = None + + # material cannot be changed after the ScatterGraphic is created + self._mode = mode + match self.mode: + case "markers": + # default + material = pygfx.PointsMarkerMaterial + + if uniform_marker: + if not isinstance(markers, str): + raise TypeError( + "must pass a single marker if uniform_marker is True" + ) + + self._markers = UniformMarker(markers) + + material_kwargs["marker_mode"] = pygfx.MarkerMode.uniform + material_kwargs["marker"] = self._markers.value + else: + material_kwargs["marker_mode"] = pygfx.MarkerMode.vertex + + self._markers = VertexMarkers(markers, n_datapoints) + + geo_kwargs["markers"] = self._markers.buffer + + if edge_colors is None: + # interpret as no edge color + edge_colors = (0, 0, 0, 0) + + if uniform_edge_color: + if not isinstance(edge_colors, (str, pygfx.Color)): + if len(edge_colors) not in [3, 4]: + raise TypeError( + f"if `uniform_edge_color` is True, then `edge_color` must be a str, pygfx.Color, " + f"or an RGB(A) tuple, list, array representation of a single color. You have passed: " + f"{edge_colors}" + ) + + self._edge_colors = UniformEdgeColor(edge_colors) + material_kwargs["edge_color"] = self._edge_colors.value + material_kwargs["edge_color_mode"] = pygfx.ColorMode.uniform + else: + self._edge_colors = VertexColors( + edge_colors, n_datapoints, property_name="edge_colors" + ) + material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex + geo_kwargs["edge_colors"] = self._edge_colors.buffer + + self._edge_width = EdgeWidth(edge_width) + material_kwargs["edge_width"] = self._edge_width.value + material_kwargs["custom_sdf"] = custom_sdf + + case "simple": + # basic points material + material = pygfx.PointsMaterial + + case "gaussian": + material = pygfx.PointsGaussianBlobMaterial + + case "image": + material = pygfx.PointsSpriteMaterial + # sprites should actually only be one texture, but we don't + # want to create a new buffer manager just for sprites. + # If someone is creating scatter plots with images of size + # larger than the typical limit of 16384, I'm very curious + # to know what they're trying to visualize + shared = pygfx.renderers.wgpu.get_shared() + limit = shared.device.limits["max-texture-dimension-2d"] + if any([dim > limit for dim in image.shape]): + raise BufferError( + f"Scatter point image dimension is greater than the device texture limit." + f"Your device limit is: {limit} but your image shape is: {image.shape}" + ) + + # create texture array with normalized image + self._image = TextureArray( + image / np.nanmax(image), property_name="image" + ) + + material_kwargs["sprite"] = self._image.buffer[0, 0] + self._size_space = SizeSpace(size_space) if uniform_color: - material_kwargs["color_mode"] = "uniform" + material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: - material_kwargs["color_mode"] = "vertex" + material_kwargs["color_mode"] = pygfx.ColorMode.vertex geo_kwargs["colors"] = self.colors.buffer if uniform_size: - material_kwargs["size_mode"] = "uniform" + material_kwargs["size_mode"] = pygfx.SizeMode.uniform self._sizes = UniformSize(sizes) material_kwargs["size"] = self.sizes else: - material_kwargs["size_mode"] = "vertex" - self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) + material_kwargs["size_mode"] = pygfx.SizeMode.vertex + self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) geo_kwargs["sizes"] = self.sizes.buffer + match point_rotation_mode: + case pygfx.enums.RotationMode.vertex: + self._point_rotations = VertexRotations( + point_rotations, n_datapoints=n_datapoints + ) + geo_kwargs["rotations"] = self._point_rotations.buffer + + case pygfx.enums.RotationMode.uniform: + self._point_rotations = UniformRotations(point_rotations) + + case pygfx.enums.RotationMode.curve: + pass # nothing special for curve rotation mode + + case _: + raise ValueError( + f"`point_rotation_mode` must be one of: {pygfx.enums.RotationMode}, " + f"you have passed: {point_rotation_mode}" + ) + + material_kwargs["rotation_mode"] = point_rotation_mode material_kwargs["size_space"] = self.size_space + world_object = pygfx.Points( pygfx.Geometry(**geo_kwargs), - material=pygfx.PointsMaterial(**material_kwargs), + material=material(**material_kwargs), ) self._set_world_object(world_object) @property - def sizes(self) -> PointsSizesFeature | float: + def mode(self) -> str: + """scatter point display mode""" + return self._mode + + @property + def markers(self) -> str | VertexMarkers | None: + """markers if mode is 'marker'""" + if isinstance(self._markers, VertexMarkers): + return self._markers + elif isinstance(self._markers, UniformMarker): + return self._markers.value + + @markers.setter + def markers(self, value: str | np.ndarray[str] | Sequence[str]): + if self.mode != "markers": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers" + ) + if isinstance(self._markers, VertexMarkers): + self._markers[:] = value + elif isinstance(self._markers, UniformMarker): + self._markers.set_value(self, value) + + @property + def edge_colors(self) -> str | pygfx.Color | VertexColors | None: + """edge_colors if mode is 'marker'""" + + if isinstance(self._edge_colors, VertexColors): + return self._edge_colors + + elif isinstance(self._edge_colors, UniformEdgeColor): + return self._edge_colors.value + + @edge_colors.setter + def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]): + if self.mode != "markers": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" + ) + + if isinstance(self._edge_colors, VertexColors): + self._edge_colors[:] = value + + elif isinstance(self._edge_colors, UniformEdgeColor): + self._edge_colors.set_value(self, value) + + @property + def edge_width(self) -> float | None: + """Get or set the edge_width if mode is 'markers'""" + if self._edge_width is None: + return None + + return self._edge_width.value + + @edge_width.setter + def edge_width(self, value: float): + if self.mode != "markers": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_width" + ) + + self._edge_width.set_value(self, value) + + @property + def point_rotation_mode(self) -> str: + """point rotation mode, read-only, one of 'uniform', 'vertex', or 'curve'""" + return self.world_object.material.rotation_mode + + @property + def point_rotations(self) -> VertexRotations | float | None: + """rotation of each point, in radians, if `point_rotation_mode` is 'uniform' or 'vertex'""" + + if isinstance(self._point_rotations, VertexRotations): + return self._point_rotations + + elif isinstance(self._point_rotations, UniformRotations): + return self._point_rotations.value + + @point_rotations.setter + def point_rotations(self, value: float | np.ndarray[float]): + if self.point_rotation_mode not in ["uniform", "vertex"]: + raise AttributeError( + f"point_rotation_mode is: {self.point_rotation_mode}. " + f"it be 'uniform' or 'vertex' to set the `point_rotations`" + ) + + if isinstance(self._point_rotations, VertexRotations): + self._point_rotations[:] = value + + elif isinstance(self._point_rotations, UniformRotations): + self._point_rotations.set_value(self, value) + + @property + def image(self) -> TextureArray | None: + """Get or set the image data, returns None if scatter plot mode is not 'image'""" + return self._image + + @image.setter + def image(self, data): + if self.mode != "image": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'image' to set the image" + ) + + self._image[:] = data + + @property + def sizes(self) -> VertexPointSizes | float: """Get or set the scatter point size(s)""" - if isinstance(self._sizes, PointsSizesFeature): + if isinstance(self._sizes, VertexPointSizes): return self._sizes elif isinstance(self._sizes, UniformSize): @@ -138,7 +437,7 @@ def sizes(self) -> PointsSizesFeature | float: @sizes.setter def sizes(self, value): - if isinstance(self._sizes, PointsSizesFeature): + if isinstance(self._sizes, VertexPointSizes): self._sizes[:] = value elif isinstance(self._sizes, UniformSize): diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 5cef0c6b0..e4dbc890b 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -111,6 +111,7 @@ def edge_color(self, color: str | Sequence[float]): def __init__( self, edges: Tuple[Line, ...] = None, + outer_edges: Tuple[Line, ...] = None, fill: Tuple[Mesh, ...] = None, vertices: Tuple[Points, ...] = None, hover_responsive: Tuple[WorldObject, ...] = None, @@ -122,6 +123,9 @@ def __init__( if edges is None: edges = tuple() + if outer_edges is None: + outer_edges = tuple() + if fill is None: fill = tuple() @@ -129,11 +133,15 @@ def __init__( vertices = tuple() self._edges: Tuple[Line, ...] = edges + self._outer_edges: Tuple[Line, ...] = outer_edges self._fill: Tuple[Mesh, ...] = fill self._vertices: Tuple[Points, ...] = vertices self._world_objects: Tuple[WorldObject, ...] = ( - self._edges + self._fill + self._vertices + *self._edges, + *self._outer_edges, + *self._fill, + *self._vertices, ) for wo in self._world_objects: @@ -148,7 +156,7 @@ def __init__( self._hover_colors = {} if hover_responsive is not None: - for wo in self._hover_responsive: + for wo in [*self._hover_responsive, *self._outer_edges]: self._original_colors[wo] = wo.material.color self._axis = axis @@ -215,7 +223,7 @@ def _fpl_add_plot_area_hook(self, plot_area): wo.add_event_handler(self._toggle_arrow_key_moveable, "double_click") for fill in self._fill: - if fill.material.color_is_transparent: + if fill.material.color.a < 1 or fill.material.opacity < 1: self._pfunc_fill = partial(self._check_fill_pointer_event, fill) self._plot_area.renderer.add_event_handler( self._pfunc_fill, "pointer_down" @@ -231,7 +239,7 @@ def _fpl_add_plot_area_hook(self, plot_area): self._plot_area.renderer.add_event_handler(self._move_to_pointer, "click") # mouse hover color events - for wo in self._hover_responsive: + for wo in [*self._hover_responsive, *self._outer_edges]: wo.add_event_handler(self._pointer_enter, "pointer_enter") wo.add_event_handler(self._pointer_leave, "pointer_leave") @@ -282,6 +290,12 @@ def _move_start(self, event_source: WorldObject, ev): """ position = self._plot_area.map_screen_to_world(ev) + # if the event source was an outer transparent line, get the + # corresponding inner line since it's just a proxy + if event_source in self._outer_edges: + index = self._outer_edges.index(event_source) + event_source = self._edges[index] + self._move_info = MoveInfo( start_selection=None, start_position=position, @@ -393,14 +407,20 @@ def _move_to_pointer(self, ev): self._move_graphic(move_info) def _pointer_enter(self, ev): - if self._hover_responsive is None: return wo = ev.pick_info["world_object"] - if wo not in self._hover_responsive: + if wo not in [*self._hover_responsive, *self._outer_edges]: return + # if it's an outer edge, highlight the corresponding inner edge instead + if wo in self._outer_edges: + # get index + index = self._outer_edges.index(wo) + # now use inner edge + wo = self._edges[index] + if wo in self._edges: self._edge_hovered = True @@ -416,7 +436,7 @@ def _pointer_leave(self, ev): self._edge_hovered = False # reset colors - for wo in self._hover_responsive: + for wo in [*self._hover_responsive, *self._outer_edges]: if self._moving: self._hover_colors[wo] = self._original_colors[wo] else: diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 64a673768..0364305a4 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -5,6 +5,7 @@ import numpy as np import pygfx +from ...utils.enums import RenderQueue from .._base import Graphic from .._collection_base import GraphicCollection from ..features._selection_features import LinearSelectionFeature @@ -77,9 +78,10 @@ def __init__( limits: Sequence[float], axis: str = "x", parent: Graphic = None, - edge_color: str | Sequence[float] | np.ndarray = "w", - thickness: float = 2.5, + edge_color: str | Sequence[float] | np.ndarray = "yellow", + thickness: float = 1.0, arrow_keys_modifier: str = "Shift", + extra_width: float = 14.0, name: str = None, ): """ @@ -110,6 +112,9 @@ def __init__( edge_color: str | tuple | np.ndarray, default "w" color of the selector + extra_width: float, default 14.0 + the width around the selector which is responsive to mouse events, in logical pixels + name: str, optional name of linear selector @@ -140,26 +145,42 @@ def __init__( material = pygfx.LineInfiniteSegmentMaterial - self.colors_outer = pygfx.Color([0.3, 0.3, 0.3, 1.0]) - line_inner = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=line_data), - material=material(thickness=thickness, color=edge_color, pick_write=True), + material=material( + thickness=thickness, + color=edge_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, + ), ) - self.line_outer = pygfx.Line( - geometry=pygfx.Geometry(positions=line_data), + line_outer = pygfx.Line( + geometry=line_inner.geometry, material=material( - thickness=thickness + 6, color=self.colors_outer, pick_write=True + thickness=thickness + extra_width, + color=pygfx.Color([0, 0, 0]), + opacity=0, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, ), ) - line_inner.world.z = self.line_outer.world.z + 1 + # Inner line goes on top of the outer line + line_inner.render_order = 1 world_object = pygfx.Group() - world_object.add(self.line_outer) + world_object.add(line_outer) world_object.add(line_inner) if axis == "x": @@ -170,8 +191,9 @@ def __init__( # init base selector BaseSelector.__init__( self, - edges=(line_inner, self.line_outer), - hover_responsive=(line_inner, self.line_outer), + edges=(line_inner,), + outer_edges=(line_outer,), + hover_responsive=(line_inner,), arrow_keys_modifier=arrow_keys_modifier, axis=axis, parent=parent, diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index e93e2a147..9f5803c93 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -8,6 +8,7 @@ from .._collection_base import GraphicCollection from ..features._selection_features import LinearRegionSelectionFeature from ._base_selector import BaseSelector, MoveInfo +from ...utils.enums import RenderQueue class LinearRegionSelector(BaseSelector): @@ -63,9 +64,10 @@ def __init__( parent: Graphic = None, resizable: bool = True, fill_color: str | Sequence[float] = (0, 0, 0.35), - edge_color: str | Sequence[float] = (0.8, 0.6, 0), - edge_thickness: float = 8, + edge_color: str | Sequence[float] = "yellow", + edge_thickness: float = 1.0, arrow_keys_modifier: str = "Shift", + extra_width: float = 14.0, name: str = None, ): """ @@ -112,6 +114,9 @@ def __init__( modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None`` + extra_width: float, default 14.0 + the width around the selector lines which is responsive to mouse events, in logical pixels + name: str, optional name of this selector graphic @@ -140,7 +145,13 @@ def __init__( mesh = pygfx.Mesh( pygfx.box_geometry(1, size, 1), pygfx.MeshBasicMaterial( - color=pygfx.Color(self.fill_color), pick_write=True + color=pygfx.Color(self.fill_color), + alpha_mode="blend", + opacity=0.4, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, ), ) @@ -148,12 +159,21 @@ def __init__( mesh = pygfx.Mesh( pygfx.box_geometry(size, 1, 1), pygfx.MeshBasicMaterial( - color=pygfx.Color(self.fill_color), pick_write=True + color=pygfx.Color(self.fill_color), + alpha_mode="blend", + opacity=0.4, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, ), ) else: raise ValueError("`axis` must be one of 'x' or 'y'") + # Render the mesh before the lines + mesh.render_order = -1 + # the fill of the selection self.fill = mesh # no x, y offsets for linear region selector @@ -188,24 +208,74 @@ def __init__( positions=init_line_data.copy() ), # copy so the line buffer is isolated pygfx.LineMaterial( - thickness=edge_thickness, color=self.edge_color, pick_write=True + thickness=edge_thickness, + color=self.edge_color, + alpha_mode="blend", + opacity=1, + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, + ), + ) + + line0_outer = pygfx.Line( + pygfx.Geometry( + # share buffer with inner line so they can both be managed together + positions=line0.geometry.positions + ), + pygfx.LineMaterial( + thickness=edge_thickness + extra_width, + color=pygfx.Color([0, 0, 0]), + alpha_mode="blend", + opacity=0, + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + 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=self.edge_color, pick_write=True + thickness=edge_thickness, + color=self.edge_color, + alpha_mode="blend", + opacity=1, + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, ), ) - self.edges: tuple[pygfx.Line, pygfx.Line] = (line0, line1) + line1_outer = pygfx.Line( + pygfx.Geometry( + # share buffer with inner line so they can both be managed together + positions=line1.geometry.positions + ), + pygfx.LineMaterial( + thickness=edge_thickness + extra_width, + color=pygfx.Color([0, 0, 0]), + alpha_mode="blend", + opacity=0, + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, + ), + ) - # add the edge lines - for edge in self.edges: - edge.world.z = -0.5 - group.add(edge) + edges: tuple[pygfx.Line, pygfx.Line] = (line0, line1) + outer_edges = (line0_outer, line1_outer) + group.add(*edges, *outer_edges) # TODO: if parent offset changes, we should set the selector offset too, use offset evented property # TODO: add check if parent is `None`, will throw error otherwise @@ -225,9 +295,10 @@ def __init__( BaseSelector.__init__( self, - edges=self.edges, + edges=edges, + outer_edges=outer_edges, fill=(self.fill,), - hover_responsive=self.edges, + hover_responsive=edges, arrow_keys_modifier=arrow_keys_modifier, axis=axis, parent=parent, @@ -369,7 +440,6 @@ def get_selected_indices( return np.arange(*bounds, dtype=int) def _move_graphic(self, move_info: MoveInfo): - # If this the first move in this drag, store initial selection if move_info.start_selection is None: move_info.start_selection = self.selection @@ -396,12 +466,12 @@ def _move_graphic(self, move_info: MoveInfo): # if event source was an edge and selector is resizable, # move the edge that caused the event - if move_info.source == self.edges[0]: + if move_info.source == self._edges[0]: # change only left or bottom bound new_min = min(cur_min + delta, cur_max) self._selection.set_value(self, (new_min, cur_max)) - elif move_info.source == self.edges[1]: + elif move_info.source == self._edges[1]: # change only right or top bound new_max = max(cur_max + delta, cur_min) self._selection.set_value(self, (cur_min, new_max)) diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index 22e42e63e..e02c627ac 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -1,152 +1,575 @@ +import warnings from typing import * -import numpy as np +from dataclasses import dataclass +from numbers import Real +import numpy as np import pygfx -from ._base_selector import BaseSelector, MoveInfo +from ...utils.enums import RenderQueue from .._base import Graphic +from .._collection_base import GraphicCollection +from ..features._selection_features import PolygonSelectionFeature +from ._base_selector import BaseSelector + + +@dataclass +class MoveInfo: + """Movement info specific to the polygon selector.""" + + # The interaction mode: None, 'create', or 'drag' + mode: str + + # The index of the point in the polygon that is currently being manipulated + index: int + + # The index of the point in the polygon to snap to. This is used to merge (i.e. delete) points, and to finish se the polygon. + snap_index: int + + # The position of the cursor at the start of a drag + start_pos: np.ndarray | None + + # The position of the vertices at the start of a drag + start_positions: np.ndarray | None class PolygonSelector(BaseSelector): + _features = {"selection": PolygonSelectionFeature} + + @property + def parent(self) -> Graphic | None: + """Graphic that selector is associated with.""" + return self._parent + + @property + def selection(self) -> np.ndarray[float]: + """ + The polygon as an array of 3D points. The shape is [n_points, 3]. + """ + return self._selection.value.copy() + + @selection.setter + def selection(self, selection: np.ndarray[float]): + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, selection) + + @property + def limits(self) -> Tuple[float, float, float, float]: + """Return the limits of the selector.""" + return self._limits + + @limits.setter + def limits(self, values: Tuple[float, float, float, float]): + if len(values) != 4 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 + def __init__( self, - edge_color="magenta", - edge_width: float = 3, + selection: Optional[Sequence[Tuple[float]]], + limits: Sequence[float], parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 4, + vertex_color=(0.7, 0.4, 0), + vertex_size: float = 12, name: str = None, ): - self.parent = parent - - group = pygfx.Group() - - self._set_world_object(group) + self._parent = parent + self._resizable = bool(resizable) - self.edge_color = edge_color - self.edge_width = edge_width + BaseSelector.__init__(self, name=name, parent=parent) + self._move_info = MoveInfo("none", -1, -1, None, None) - self._move_info: MoveInfo = None + # Initialize geometry with space for 8 points. The buffers are oversized, so we only need to create new buffers when the allocated space is full. + # The points are 3D, even though the z-component is always 0. Indices represent the faces (i.e. the triangles). + self.geometry = pygfx.Geometry( + positions=np.zeros((8, 3), np.float32), + indices=np.zeros((8, 3), np.int32), + ) - self._current_mode = None + # The draw range allows us to draw only part of the buffer, i.e. it allows us to oversize our buffers to avoid creating a new one for every added point. + self.geometry.positions.draw_range = 0, 0 + self.geometry.indices.draw_range = 0, 0 + + self._line = pygfx.Line( + self.geometry, + pygfx.LineMaterial( + thickness=edge_thickness, + color=edge_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, + ), + ) + self._points = pygfx.Points( + self.geometry, + pygfx.PointsMaterial( + size=vertex_size, + color=vertex_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, + ), + ) + self._indicator = pygfx.Points( + pygfx.Geometry(positions=[[0, 0, 0]]), + pygfx.PointsMaterial( + size=15, + color=vertex_color, + alpha_mode="blend", + opacity=0.3, + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + ), + ) + self._indicator.visible = False + self._mesh = pygfx.Mesh( + self.geometry, + pygfx.MeshBasicMaterial( + color=fill_color, + alpha_mode="blend", + opacity=0.4, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, + ), + ) + group = pygfx.Group().add(self._line, self._points, self._mesh, self._indicator) + self._set_world_object(group) - BaseSelector.__init__(self, name=name) + # Points go on top of lines, which go on top of the mesh. And indicator in between. + self._line.render_order = 1 + self._indicator.render_order = 2 + self._points.render_order = 3 - def get_vertices(self) -> np.ndarray: - """Get the vertices for the polygon""" - vertices = list() - for child in self.world_object.children: - vertices.append(child.geometry.positions.data[:, :2]) + if selection is None: + selection = [] + self._selection = PolygonSelectionFeature(selection, (0, 0, 0, 0)) - return np.vstack(vertices) + self.edge_color = edge_color + self.edge_width = edge_thickness + self.limits = limits + self.selection = self.selection # trigger positions to be created + + def get_selected_data( + self, graphic: Graphic = None, mode: str = "full" + ) -> Union[np.ndarray, List[np.ndarray]]: + """ + Get the ``Graphic`` data bounded by the current selection. + 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. + + Parameters + ---------- + graphic: Graphic, optional, default ``None`` + if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` + mode: str, default 'full' + One of 'full', 'partial', or 'ignore'. Indicates how selected data should be returned based on the + selectors position over the graphic. Only used for ``LineGraphic``, ``LineCollection``, and ``LineStack`` + | If 'full', will return all data bounded by the x and y limits of the selector even if partial indices + along one axis are not fully covered by the selector. + | If 'partial' will return only the data that is bounded by the selector, missing indices not bounded by the + selector will be set to NaNs + | If 'ignore', will only return data for graphics that have indices completely bounded by the selector + + Returns + ------- + np.ndarray or List[np.ndarray] + view or list of views of the full array, returns empty array if selection is empty + """ + source = self._get_source(graphic) + ixs = self.get_selected_indices(source) + + # do not need to check for mode for images, because the selector is bounded by the image shape + # will always be `full` + if "Image" in source.__class__.__name__: + return source.data[ixs[:, 1], ixs[:, 0]] + + if mode not in ["full", "partial", "ignore"]: + raise ValueError( + f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}" + ) + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + data_selections: List[np.ndarray] = list() + + for i, g in enumerate(source.graphics): + # want to keep same length as the original line collection + if ixs[i].size == 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + # s gives entire slice of data along the x + s = slice( + ixs[i][0], ixs[i][-1] + 1 + ) # add 1 because these are direct indices + # slices n_datapoints dim + + # calculate missing ixs using set difference + # then calculate shift + missing_ixs = ( + np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i]) + - ixs[i][0] + ) + + match mode: + # take all ixs, ignore missing + case "full": + data_selections.append(g.data[s]) + # set missing ixs data to NaNs + case "partial": + if len(missing_ixs) > 0: + data = g.data[s].copy() + data[missing_ixs] = np.nan + data_selections.append(data) + else: + data_selections.append(g.data[s]) + # ignore lines that do not have full ixs to start + case "ignore": + if len(missing_ixs) > 0: + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) + else: + data_selections.append(g.data[s]) + return data_selections + else: # for lines + if ixs.size == 0: + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) + + # add 1 to end because these are direct indices + s = slice(ixs[0], ixs[-1] + 1) + # slices n_datapoints dim + # slice with min, max is faster than using all the indices + + # get missing ixs + missing_ixs = np.setdiff1d(np.arange(ixs[0], ixs[-1] + 1), ixs) - ixs[0] + + match mode: + # return all, do not care about missing + case "full": + return source.data[s] + # set missing to NaNs + case "partial": + if len(missing_ixs) > 0: + data = source.data[s].copy() + data[missing_ixs] = np.nan + return data + else: + return source.data[s] + # missing means nothing will be returned even if selector is partially over data + # warn the user and return empty + case "ignore": + if len(missing_ixs) > 0: + warnings.warn( + "You have selected 'ignore' mode. Selected graphic has incomplete indices. " + "Move the selector or change the mode to one of `partial` or `full`." + ) + return np.array([], dtype=np.float32) + else: + return source.data[s] + + def get_selected_indices( + self, graphic: Graphic = None + ) -> np.ndarray | tuple[np.ndarray]: + """ + Returns the indices of the ``Graphic`` data bounded by the current selection. + + These are the data indices which correspond to the data under the selector. + + Parameters + ---------- + graphic: Graphic, default ``None`` + If provided, returns the selection indices from this graphic instead of the graphic set as ``parent`` + + Returns + ------- + Union[np.ndarray, List[np.ndarray]] + data indicies of the selection + | array of (x, y) indices if the graphic is an image + | list of indices along the x-dimension for each line if graphic is a line collection + | array of indices along the x-dimension if graphic is a line + """ + # get indices from source + source = self._get_source(graphic) + + # selector (xmin, xmax, ymin, ymax) values + polygon = self.selection[:, :2] + + # Empty ... + if len(polygon) == 0: + if "Image" in source.__class__.__name__: + return np.zeros((0, 2), np.int32) + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + return [np.zeros((0, 1), np.int32) for _ in source.graphics] + else: + return np.zeros((0, 1), np.int32) + + # Get bounding box to be able to do first selection + xmin, xmax = polygon[:, 0].min(), polygon[:, 0].max() + ymin, ymax = polygon[:, 1].min(), polygon[:, 1].max() + + # image data does not need to check for mode because the selector is always bounded + # to the image + if "Image" in source.__class__.__name__: + shape = source.data.value.shape + col_ixs = np.arange(max(0, xmin), min(xmax, shape[1] - 1), dtype=int) + row_ixs = np.arange(max(0, ymin), min(ymax, shape[0] - 1), dtype=int) + indices = [] + for y in row_ixs: + for x in col_ixs: + p = np.array([x, y], np.float32) + if point_in_polygon((x, y), polygon): + indices.append(p) + return np.array(indices, np.int32).reshape(-1, 2) + + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + ixs = list() + for g in source.graphics: + points = g.data.value[:, :2] + g.offset[:2] + g_ixs = np.where( + (points[:, 0] >= xmin) + & (points[:, 0] <= xmax) + & (points[:, 1] >= ymin) + & (points[:, 1] <= ymax) + )[0] + g_ixs = np.array( + [i for i in g_ixs if point_in_polygon(points[i], polygon)], + g_ixs.dtype, + ) + ixs.append(g_ixs) + else: + # map only this graphic + points = source.data.value[:2] + ixs = np.where( + (points[:, 0] >= xmin) + & (points[:, 0] <= xmax) + & (points[:, 1] >= ymin) + & (points[:, 1] <= ymax) + )[0] + ixs = np.array( + [i for i in ixs if point_in_polygon(points[i], polygon)], + ixs.dtype, + ) + + return ixs 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._on_pointer_down, "pointer_down" ) - - # click to finish existing segment - self._plot_area.renderer.add_event_handler(self._finish_segment, "click") - - # double click to finish polygon - self._plot_area.renderer.add_event_handler(self._finish_polygon, "double_click") - - self.position_z = len(self._plot_area) + 10 - - def _add_segment(self, ev): - """After click event, adds a new line segment""" - self._current_mode = "add" - - position = self._plot_area.map_screen_to_world(ev) - self._move_info = MoveInfo( - start_selection=None, - start_position=position, - delta=np.zeros_like(position), - source=None, - ) - - # line with same position for start and end until mouse moves - data = np.array([position, position]) - - 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), - pick_write=True, - ), + self._plot_area.renderer.add_event_handler( + self._on_pointer_move, "pointer_move" ) + self._plot_area.renderer.add_event_handler(self._on_pointer_up, "pointer_up") - self.world_object.add(new_line) - - def _move_segment_endpoint(self, ev): - """After mouse pointer move event, moves endpoint of current line segment""" - if self._move_info is None: - return - self._current_mode = "move" + self.position_z = len(self._plot_area) + 10 + if len(self.selection) == 0: + self._start_move_mode("create", -1) + + def start_new_polygon(self): + """Remove the current polygon and start drawing a new one.""" + self.selection = np.zeros((0, 3), np.float32) + self._start_move_mode("create", -1) + + def _start_move_mode(self, what, index, start_pos=None): + self._plot_area.controller.enabled = False + self._move_info.mode = what + self._move_info.index = index + self._move_info.snap_index = None + self._indicator.material.size = 15 + self._indicator.visible = True + if start_pos is not None: + self._move_info.start_pos = start_pos + self._move_info.start_positions = self.selection.copy() + self._indicator.visible = False + + def _end_move_mode(self): + if self._move_info.mode == "create": + self.world_object.children[0].material.loop = True + self._plot_area.controller.enabled = True + self._move_info.mode = None + self._move_info.start_pos = None + self._move_info.start_positions = None + self._indicator.visible = False + + def _on_pointer_down(self, ev): world_pos = self._plot_area.map_screen_to_world(ev) - if world_pos is None: 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.update_range() - - def _finish_segment(self, ev): - """After click event, ends a line segment""" - # should start a new segment - if self._move_info is None: - return - - # since both _add_segment and _finish_segment use the "click" callback - # this is to block _finish_segment right after a _add_segment call - if self._current_mode == "add": + if self._move_info.mode == "create": + # Add a polygon or finish it + if self._move_info.snap_index is not None: + pass # on release we finish the polygon + else: + self._insert_polygon_vertex(999999, world_pos) + + elif self._move_info.mode is None: + # Maybe initiate a drag + if ev.target is self._points: + index = ev.pick_info["vertex_index"] + self._start_move_mode("drag", index) + elif ev.target is self._line: + index = ev.pick_info["vertex_index"] + if ev.pick_info["segment_coord"] > 0: + index += 1 + self._insert_polygon_vertex(index, world_pos) + self._start_move_mode("drag", index) + elif ev.target is self._mesh: + index = None # move whole polygon + self._start_move_mode("drag", index, world_pos) + + def _on_pointer_move(self, ev): + """After mouse pointer move event, moves endpoint of current line segment""" + if self._move_info.mode is None: return - - # just make move info None so that _move_segment_endpoint is not called - # and _add_segment gets triggered for "click" - self._move_info = None - - self._current_mode = "finish-segment" - - def _finish_polygon(self, ev): - """finishes the polygon, disconnects events""" - world_pos = self._plot_area.map_screen_to_world(ev) - + world_pos = self._plot_area.map_screen_to_world((ev.x, ev.y)) if world_pos is None: return - # make new line to connect first and last vertices - 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), - pick_write=True, - ), - ) - - self.world_object.add(new_line) - - handlers = { - self._add_segment: "click", - self._move_segment_endpoint: "pointer_move", - self._finish_segment: "click", - self._finish_polygon: "double_click", - } - - for handler, event in handlers.items(): - self._plot_area.renderer.remove_event_handler(handler, event) + # Are we close to a point that we can snap to? + # The concept of snapping does multiple things: + # - preventing the user from creating points that are very close to each-other, + # - allowing the user to finish the polygon by connecting to the start-point when in 'create' mode. + # - allowing the user to merge points by dragging one onto its neighbour. + index = self._move_info.index + snap_index = None + + # Use numpy to select the nearest point. + # This is because we cannot use picking on the actual points because + # then we'd always pick the point being moved. We don't use a depth buffer + # so we cannot move the point backwards to avoid it being picked. + # An advantage is that we can make the snap-radius larger than the size of the points. + world_pos2 = self._plot_area.map_screen_to_world((ev.x + 1, ev.y)) + world_pos_scale = float(np.linalg.norm(world_pos - world_pos2)) + snap_radius = 20 # logical screen pixels + if len(self.selection) > 0: + distances = np.linalg.norm(self.selection[:, :2] - world_pos[:2], axis=1) + distances /= world_pos_scale + distances[index] = np.inf + snap_index = int(np.argmin(distances)) + if distances[snap_index] > snap_radius: + snap_index = None + + if snap_index == index: # just in case, dont snap to moving point + snap_index = None + if len(self.selection) < 4: + snap_index = None + if self._move_info.mode == "create" and snap_index != 0: + snap_index = None + if self._move_info.mode == "drag" and index is not None: + last_index = len(self.selection) - 1 + if not ( + (index == 0 and snap_index == last_index) + or (index == last_index and snap_index == 0) + or (snap_index in (index - 1, index + 1)) + ): + snap_index = None + self._move_info.snap_index = snap_index + + # Show state of snap index to user + if snap_index is not None: + world_pos = self.geometry.positions.data[snap_index] + self._indicator.material.size = 30 + else: + self._indicator.material.size = 15 + + self._indicator.local.position = world_pos + + # Update data + if self._move_info.mode in ("create", "drag"): + data = self.selection + if len(data) > 0: + if self._move_info.index is None: + delta = world_pos - self._move_info.start_pos + data[:] = self._move_info.start_positions + delta + else: + data[self._move_info.index] = world_pos + self._selection.set_value(self, data) + + def _on_pointer_up(self, ev): + if self._move_info.mode in ("create", "drag"): + # If we snapped, we dissolve (i.e. delete the vertex being moved) + if self._move_info.snap_index is not None: + assert self._move_info.index is not None + self._delete_polygon_vertex(self._move_info.index) + + # Moving the mouse up may end the move action + if self._move_info.mode == "create": + if self._move_info.snap_index is not None: + self._end_move_mode() + elif self._move_info.mode == "drag": + self._end_move_mode() + + def _insert_polygon_vertex(self, i, world_pos): + selection = self.selection + if len(selection) == 0: + data = np.vstack([selection, world_pos, world_pos]) + else: + data = np.vstack([selection[:i], world_pos, selection[i:]]) + self._selection.set_value(self, data) + + def _delete_polygon_vertex(self, i): + selection = self.selection + if i < 0: + data = selection[:i] + else: + data = np.vstack([selection[:i], selection[i + 1 :]]) + self._selection.set_value(self, data) + + +def is_left(p0, p1, p2): + """Test if point p2 is left of the line formed by p0 → p1""" + return (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]) + + +def point_in_polygon(point, polygon): + """Determines if the point is inside the polygon using the winding number algorithm.""" + wn = 0 # winding number counter + n = len(polygon) + + for i in range(n): + p0 = polygon[i] + p1 = polygon[(i + 1) % n] + + if p0[1] <= point[1]: # start y <= point.y + if p1[1] > point[1]: # upward crossing + if is_left(p0, p1, point) > 0: + wn += 1 # point is left of edge + else: # start y > point.y + if p1[1] <= point[1]: # downward crossing + if is_left(p0, p1, point) < 0: + wn -= 1 # point is right of edge + + return wn != 0 diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index db7691e07..e30165dae 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -6,6 +6,7 @@ import pygfx from .._collection_base import GraphicCollection +from ...utils.enums import RenderQueue from .._base import Graphic from ..features import RectangleSelectionFeature from ._base_selector import BaseSelector, MoveInfo @@ -135,7 +136,13 @@ def __init__( self.fill = pygfx.Mesh( pygfx.box_geometry(width, height, 1), pygfx.MeshBasicMaterial( - color=pygfx.Color(self.fill_color), pick_write=True + color=pygfx.Color(self.fill_color), + alpha_mode="blend", + opacity=0.4, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=True, ), ) @@ -153,7 +160,15 @@ def __init__( left_line = pygfx.Line( pygfx.Geometry(positions=left_line_data.copy()), - pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color), + pygfx.LineMaterial( + thickness=edge_thickness, + color=self.edge_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + ), ) # position data for the right edge line @@ -166,7 +181,15 @@ def __init__( right_line = pygfx.Line( pygfx.Geometry(positions=right_line_data.copy()), - pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color), + pygfx.LineMaterial( + thickness=edge_thickness, + color=self.edge_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + ), ) # position data for the left edge line @@ -179,7 +202,15 @@ def __init__( bottom_line = pygfx.Line( pygfx.Geometry(positions=bottom_line_data.copy()), - pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color), + pygfx.LineMaterial( + thickness=edge_thickness, + color=self.edge_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + ), ) # position data for the right edge line @@ -192,7 +223,15 @@ def __init__( top_line = pygfx.Line( pygfx.Geometry(positions=top_line_data.copy()), - pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color), + pygfx.LineMaterial( + thickness=edge_thickness, + color=self.edge_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + ), ) self.edges: Tuple[pygfx.Line, pygfx.Line, pygfx.Line, pygfx.Line] = ( @@ -203,9 +242,10 @@ def __init__( ) # left line, right line, bottom line, top line # add the edge lines + for edge in self.edges: - edge.world.z = -0.5 - group.add(edge) + edge.render_order = 1 + group.add(*self.edges) # vertices top_left_vertex_data = (xmin, ymax, 1) @@ -221,6 +261,11 @@ def __init__( color=self.vertex_color, size_mode="vertex", edge_color=self.vertex_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, ), ) @@ -232,6 +277,11 @@ def __init__( color=self.vertex_color, size_mode="vertex", edge_color=self.vertex_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, ), ) @@ -243,6 +293,11 @@ def __init__( color=self.vertex_color, size_mode="vertex", edge_color=self.vertex_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, ), ) @@ -254,6 +309,11 @@ def __init__( color=self.vertex_color, size_mode="vertex", edge_color=self.vertex_color, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, ), ) @@ -265,7 +325,7 @@ def __init__( ) for vertex in self.vertices: - vertex.world.z = -0.25 + vertex.render_order = 2 group.add(vertex) self._selection = RectangleSelectionFeature(selection, limits=self._limits) @@ -337,7 +397,6 @@ def get_selected_data( f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}" ) if "Line" in source.__class__.__name__: - if isinstance(source, GraphicCollection): data_selections: List[np.ndarray] = list() @@ -431,7 +490,7 @@ def get_selected_indices( Parameters ---------- graphic: Graphic, default ``None`` - If provided, returns the selection indices from this graphic instrad of the graphic set as ``parent`` + If provided, returns the selection indices from this graphic instead of the graphic set as ``parent`` Returns ------- @@ -479,7 +538,6 @@ def get_selected_indices( return ixs def _move_graphic(self, move_info: MoveInfo): - # If this the first move in this drag, store initial selection if move_info.start_selection is None: move_info.start_selection = self.selection diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index fd0e9d702..9f1aeb8af 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -1,6 +1,7 @@ import pygfx import numpy as np +from ..utils.enums import RenderQueue from ._base import Graphic from .features import ( TextData, @@ -78,12 +79,21 @@ def __init__( self._outline_color = TextOutlineColor(outline_color) self._outline_thickness = TextOutlineThickness(outline_thickness) + # Text is usually used for annotations and the like. But we still want it to write depth. + # We make it render later than other 'auto' objects, assuming that most of these don't have transparent fragments. + + # The aa is on because it makes the glyphs prettier. It can result in artifacts, but these often express a different color outline + # which is actually not so bad; it would look weird on a line, but for text it helps the contrast of the glyph! + world_object = pygfx.Text( text=self.text, font_size=self.font_size, screen_space=screen_space, anchor=anchor, material=pygfx.TextMaterial( + alpha_mode="auto", + render_queue=RenderQueue.auto + 50, + aa=True, color=self.face_color, outline_color=self.outline_color, outline_thickness=self.outline_thickness, diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index bfd97000b..8fd5dc666 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -543,6 +543,11 @@ def show_tooltips(self) -> bool: """show/hide tooltips for all graphics""" return self._show_tooltips + @property + def animations(self) -> dict[str, list[callable]]: + """Returns a dictionary of 'pre' and 'post' animation functions.""" + return {"pre": self._animate_funcs_pre, "post": self._animate_funcs_post} + @show_tooltips.setter def show_tooltips(self, val: bool): self._show_tooltips = val @@ -560,12 +565,19 @@ def _render(self, draw=True): # draw the underlay planes self.renderer.render(self._underlay_scene, self._underlay_camera, flush=False) + # With new pygfx' blending, the depth buffer is only cleared after each flush, we need a manual depth + # clear to erase the depth values set by the underlay. + if hasattr(self.renderer, "clear"): + self.renderer.clear(depth=True) + # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) for subplot in self: subplot._render() # overlay render pass + if hasattr(self.renderer, "clear"): + self.renderer.clear(depth=True) self.renderer.render(self._overlay_scene, self._overlay_camera, flush=False) self.renderer.flush() @@ -585,6 +597,7 @@ def show( self, autoscale: bool = True, maintain_aspect: bool = None, + axes_visible: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None, ): @@ -599,6 +612,9 @@ def show( maintain_aspect: bool, default ``True`` maintain aspect ratio + axes_visible: bool, default ``True`` + show axes + sidecar: bool, default ``True`` display plot in a ``jupyterlab-sidecar``, only in jupyter @@ -637,6 +653,11 @@ def show( _maintain_aspect = maintain_aspect subplot.auto_scale(maintain_aspect=maintain_aspect) + # set axes visibility if False + if not axes_visible: + for subplot in self: + subplot.axes.visible = False + # parse based on canvas type if self.canvas.__class__.__name__ == "JupyterRenderCanvas": if sidecar: @@ -749,6 +770,37 @@ def remove_animation(self, func): if func in self._animate_funcs_post: self._animate_funcs_post.remove(func) + def clear_animations(self, removal: str = None): + """ + Remove animation functions. + + Parameters + ---------- + removal: str, default ``None`` + The type of animation functions to clear. One of 'pre' or 'post'. If `None`, removes all animation + functions. + """ + if removal is None: + # remove all + for func in self._animate_funcs_pre: + self._animate_funcs_pre.remove(func) + + for func in self._animate_funcs_post: + self._animate_funcs_post.remove(func) + elif removal == "pre": + # only pre + for func in self._animate_funcs_pre: + self._animate_funcs_pre.remove(func) + elif removal == "post": + # only post + for func in self._animate_funcs_post: + self._animate_funcs_post.remove(func) + else: + raise ValueError( + f"Animation type: {removal} must be one of 'pre' or 'post'. To remove all animation " + f"functions, pass `type=None`" + ) + def clear(self): """Clear all Subplots""" for subplot in self: diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py index cd2a1cbc2..1c308590f 100644 --- a/fastplotlib/layouts/_frame.py +++ b/fastplotlib/layouts/_frame.py @@ -1,6 +1,7 @@ import numpy as np import pygfx +from ..utils.enums import RenderQueue from ._rect import RectManager from ._utils import IMGUI_TOOLBAR_HEIGHT from ..utils.types import SelectorColorStates @@ -37,7 +38,7 @@ # wgsl shader snippet for SDF function that defines the resize handler, a lower right triangle. sdf_wgsl_resize_handle = """ -// hardcode square root of 2 +// hardcode square root of 2 let m_sqrt_2 = 1.4142135; // given a distance from an origin point, this defines the hypotenuse of a lower right triangle @@ -171,16 +172,28 @@ def __init__( else: title_text = title self._title_graphic = TextGraphic(title_text, font_size=16, face_color="white") + m = self._title_graphic.world_object.material + m.alpha_mode = "blend" + m.render_queue = RenderQueue.background + m.depth_write = False + m.depth_test = False wobjects.append(self._title_graphic.world_object) # init mesh of size 1 to graphically represent rect geometry = pygfx.plane_geometry(1, 1) - material = pygfx.MeshBasicMaterial(color=self.plane_color.idle, pick_write=True) + material = pygfx.MeshBasicMaterial( + alpha_mode="blend", + render_queue=RenderQueue.background, + color=self.plane_color.idle, + depth_write=False, + depth_test=False, + pick_write=True, + ) self._plane = pygfx.Mesh(geometry, material) wobjects.append(self._plane) - # otherwise text isn't visible - self._plane.world.z = 0.5 + # Plane gets rendered before text and point + self._plane.render_order = -1 # create resize handler at point (x1, y1) x1, y1 = self.extent[[1, 3]] @@ -189,21 +202,21 @@ def __init__( # subtract 7 so that the bottom right corner of the triangle is at the center pygfx.Geometry(positions=[[x1 - 7, -y1 + 7, 0]]), pygfx.PointsMarkerMaterial( + alpha_mode="blend", + render_queue=RenderQueue.background, color=self.resize_handle_color.idle, marker="custom", custom_sdf=sdf_wgsl_resize_handle, size=12, size_space="screen", + depth_write=False, + depth_test=False, pick_write=True, ), ) if not resizeable: - # set all color states to transparent if Frame isn't resizeable - c = (0, 0, 0, 0) - self._resize_handle.material.color = c - self._resize_handle.material.edge_width = 0 - self.resize_handle_color = SelectorColorStates(c, c, c) + self._resize_handle.visible = False wobjects.append(self._resize_handle) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index cb9cd04c0..e7ff99a1d 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -4,6 +4,8 @@ import numpy +import pygfx + from ..graphics import * from ..graphics._base import Graphic @@ -26,8 +28,8 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: def add_image( self, data: Any, - vmin: int = None, - vmax: int = None, + vmin: float = None, + vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", @@ -44,11 +46,11 @@ def add_image( array-like, usually numpy.ndarray, must support ``memoryview()`` | shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA - vmin: int, optional - minimum value for color scaling, calculated from data if not provided + vmin: float, optional + minimum value for color scaling, estimated from data if not provided - vmax: int, optional - maximum value for color scaling, calculated from data if not provided + vmax: float, optional + maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" colormap to use to display the data. For supported colormaps see the @@ -83,13 +85,114 @@ def add_image( **kwargs, ) + def add_image_volume( + self, + data: Any, + mode: str = "mip", + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + interpolation: str = "linear", + cmap_interpolation: str = "linear", + plane: tuple[float, float, float, float] = (0, 0, -1, 0), + threshold: float = 0.5, + step_size: float = 1.0, + substep_size: float = 0.1, + emissive: str | tuple | numpy.ndarray = (0, 0, 0), + shininess: int = 30, + isolated_buffer: bool = True, + **kwargs, + ) -> ImageVolumeGraphic: + """ + + Create an ImageVolumeGraphic. + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()``. + Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) + + mode: str, default "mip" + render mode, one of "mip", "minip", "iso" or "slice" + + vmin: float + lower contrast limit + + vmax: float + upper contrast limit + + cmap: str, default "plasma" + colormap for grayscale volumes + + interpolation: str, default "linear" + interpolation method for sampling pixels + + cmap_interpolation: str, default "linear" + interpolation method for sampling from colormap + + plane: (float, float, float, float), default (0, 0, -1, 0) + Slice volume at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice" + + threshold : float, default 0.5 + The threshold texture value at which the surface is rendered. + Used only if `mode` = "iso" + + step_size : float, default 1.0 + The size of the initial ray marching step for the initial surface finding. Smaller values will result in + more accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + substep_size : float, default 0.1 + The size of the raymarching step for the refined surface finding. Smaller values will result in more + accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + emissive : Color, default (0, 0, 0, 1) + The emissive color of the surface. I.e. the color that the object emits even when not lit by a light + source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored. + Used only if `mode` = "iso" + + shininess : int, default 30 + How shiny the specular highlight is; a higher value gives a sharper highlight. + Used only if `mode` = "iso" + + 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 - useful if the array is large. + + kwargs + additional keyword arguments passed to :class:`.Graphic` + + + """ + return self._create_graphic( + ImageVolumeGraphic, + data, + mode, + vmin, + vmax, + cmap, + interpolation, + cmap_interpolation, + plane, + threshold, + step_size, + substep_size, + emissive, + shininess, + 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, @@ -122,9 +225,6 @@ def add_line_collection( | 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 @@ -162,7 +262,6 @@ def add_line_collection( thickness, colors, uniform_colors, - alpha, cmap, cmap_transform, name, @@ -180,7 +279,6 @@ def add_line( thickness: float = 2.0, colors: Union[str, numpy.ndarray, Sequence] = "w", uniform_color: bool = False, - alpha: float = 1.0, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, @@ -210,9 +308,6 @@ def add_line( 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". For supported colormaps see the @@ -235,7 +330,6 @@ def add_line( thickness, colors, uniform_color, - alpha, cmap, cmap_transform, isolated_buffer, @@ -248,7 +342,6 @@ def add_line_stack( 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, @@ -283,9 +376,6 @@ def add_line_stack( | 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 @@ -329,7 +419,6 @@ def add_line_stack( data, thickness, colors, - alpha, cmap, cmap_transform, name, @@ -346,15 +435,26 @@ def add_line_stack( def add_scatter( self, data: Any, - colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w", + colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", uniform_color: bool = False, - alpha: float = 1.0, cmap: str = None, cmap_transform: numpy.ndarray = None, - isolated_buffer: bool = True, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: Union[str, numpy.ndarray, Sequence[str]] = "o", + uniform_marker: bool = False, + custom_sdf: str = None, + edge_colors: Union[ + str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float] + ] = "black", + uniform_edge_color: bool = True, + edge_width: float = 1.0, + image: numpy.ndarray = None, + point_rotations: float | numpy.ndarray = 0, + point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, uniform_size: bool = False, size_space: str = "screen", + isolated_buffer: bool = True, **kwargs, ) -> ScatterGraphic: """ @@ -375,9 +475,6 @@ def add_scatter( if True, uses a uniform buffer for the scatter point colors. Useful if you need to save GPU VRAM when all points have the same 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". For supported colormaps see the @@ -386,9 +483,65 @@ def add_scatter( 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 if you have large arrays. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: None | str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers" + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used. + + uniform_marker: bool, default False + Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use + the same marker for all points and want to save GPU RAM. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" + edge color of the markers, used when `mode` is "markers" + + uniform_edge_color: bool, default True + Set the same edge color for all markers. Useful for saving GPU RAM. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: ArrayLike, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float | ArrayLike = 0, + The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all + points, or an array of rotation values can be used to set per-point rotations + + point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" + * uniform: set the same rotation for every point, useful to save GPU RAM + * vertex: set per-vertex rotations + * curve: The rotation follows the curve of the line defined by the points (in screen space) sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points @@ -398,7 +551,11 @@ def add_scatter( save GPU VRAM when all points have the same size. size_space: str, default "screen" - coordinate space in which the size is expressed ("screen", "world", "model") + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + 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 if you have large arrays. kwargs passed to :class:`.Graphic` @@ -410,13 +567,22 @@ def add_scatter( data, colors, uniform_color, - alpha, cmap, cmap_transform, - isolated_buffer, + mode, + markers, + uniform_marker, + custom_sdf, + edge_colors, + uniform_edge_color, + edge_width, + image, + point_rotations, + point_rotation_mode, sizes, uniform_size, size_space, + isolated_buffer, **kwargs, ) @@ -483,3 +649,58 @@ def add_text( anchor, **kwargs, ) + + def add_vectors( + self, + positions: Union[numpy.ndarray, Sequence[float]], + directions: Union[numpy.ndarray, Sequence[float]], + color: Union[str, Sequence[float], numpy.ndarray] = "w", + size: float = None, + vector_shape_options: dict = None, + **kwargs, + ) -> VectorsGraphic: + """ + + Create graphic that draw vectors. Similar to matplotlib quiver. + + Parameters + ---------- + positions: np.ndarray | Sequence[float] + positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + directions: np.ndarray | Sequence[float] + directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors. + + spacing: float + average distance between pairs of nearest-neighbor vectors, used for scaling + + color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w" + color of the vectors + + size: float or None + Size of a vector of magnitude 1 in world space for display purpose. + Estimated from density if not provided. + + vector_shape_options: dict + dict with the following fields that directly describes the shape of the vector arrows. + Overrides ``size`` argument. + + * cone_radius + * cone_height + * stalk_radius + * stalk_height + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + VectorsGraphic, + positions, + directions, + color, + size, + vector_shape_options, + **kwargs, + ) diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index c54890239..046c622ea 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -66,7 +66,15 @@ def __init__( self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas) - fronts_path = str( + # This loads both the Roboto Font and FontAwesome 6 icons and creates and merged font + # allowing us to use both without pushing and popping to display icons or regular text + sans_serif_font = str( + Path(imgui_bundle.__file__).parent.joinpath( + "assets", "fonts", "Roboto", "Roboto-Regular.ttf" + ) + ) + + fa_6_fonts_path = str( Path(imgui_bundle.__file__).parent.joinpath( "assets", "fonts", "Font_Awesome_6_Free-Solid-900.otf" ) @@ -74,12 +82,20 @@ def __init__( io = imgui.get_io() - self._fa_icons = io.fonts.add_font_from_file_ttf( - fronts_path, 16, glyph_ranges_as_int_list=[fa.ICON_MIN_FA, fa.ICON_MAX_FA] + self._default_imgui_font = io.fonts.add_font_from_file_ttf( + sans_serif_font, 14, imgui.ImFontConfig() + ) + + font_config = imgui.ImFontConfig() + font_config.merge_mode = True + + self._default_imgui_font = io.fonts.add_font_from_file_ttf( + fa_6_fonts_path, + 14, + font_config, ) - io.fonts.build() - self.imgui_renderer.backend.create_fonts_texture() + imgui.push_font(self._default_imgui_font, self._default_imgui_font.legacy_size) self.imgui_renderer.set_gui(self._draw_imgui) @@ -88,12 +104,10 @@ def __init__( ) for i, subplot in enumerate(self._subplots.ravel()): - toolbar = SubplotToolbar(subplot=subplot, fa_icons=self._fa_icons) + toolbar = SubplotToolbar(subplot=subplot) self._subplot_toolbars[i] = toolbar - self._right_click_menu = StandardRightClickMenu( - figure=self, fa_icons=self._fa_icons - ) + self._right_click_menu = StandardRightClickMenu(figure=self) self._popups: dict[str, Popup] = {} @@ -102,6 +116,10 @@ def __init__( self.register_popup(ColormapPicker) + @property + def default_imgui_font(self) -> imgui.ImFont: + return self._default_imgui_font + @property def guis(self) -> dict[str, EdgeWindow]: """GUI windows added to the Figure""" @@ -125,7 +143,7 @@ def _render(self, draw=False): self.canvas.request_draw() def _draw_imgui(self) -> imgui.ImDrawData: - imgui.new_frame() + # imgui.new_frame() for subplot, toolbar in zip( self._subplots.ravel(), self._subplot_toolbars.ravel() @@ -144,11 +162,11 @@ def _draw_imgui(self) -> imgui.ImDrawData: self._right_click_menu.update() - imgui.end_frame() + # imgui.end_frame() - imgui.render() + # imgui.render() - return imgui.get_draw_data() + # return imgui.get_draw_data() def add_gui(self, gui: EdgeWindow): """ diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 2542fc215..3c5027caf 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -10,6 +10,7 @@ from ._utils import create_controller from ..graphics._base import Graphic +from ..graphics import ImageGraphic from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend @@ -111,6 +112,7 @@ def __init__( (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 0.0, 1.0), + alpha_mode="blend", ) self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) @@ -272,6 +274,11 @@ def background_color(self, colors: str | tuple[float]): """1, 2, or 4 colors, each color must be acceptable by pygfx.Color""" self._background_material.set_colors(*colors) + @property + def animations(self) -> dict[str, list[callable]]: + """Returns a dictionary of 'pre' and 'post' animation functions.""" + return {"pre": self._animate_funcs_pre, "post": self._animate_funcs_post} + def map_screen_to_world( self, pos: tuple[float, float] | pygfx.PointerEvent, allow_outside: bool = False ) -> np.ndarray | None: @@ -393,6 +400,60 @@ def remove_animation(self, func): if func in self._animate_funcs_post: self._animate_funcs_post.remove(func) + def clear_animations(self, removal: str = None): + """ + Remove animation functions. + + Parameters + ---------- + removal: str, default ``None`` + The type of animation functions to clear. One of 'pre' or 'post'. If `None`, removes all animation + functions. + """ + if removal is None: + # remove all + for func in self._animate_funcs_pre: + self._animate_funcs_pre.remove(func) + + for func in self._animate_funcs_post: + self._animate_funcs_post.remove(func) + elif removal == "pre": + # only pre + for func in self._animate_funcs_pre: + self._animate_funcs_pre.remove(func) + elif removal == "post": + # only post + for func in self._animate_funcs_post: + self._animate_funcs_post.remove(func) + else: + raise ValueError( + f"Animation type: {removal} must be one of 'pre' or 'post'. To remove all animation " + f"functions, pass `type=None`" + ) + + def _sort_images_by_depth(self): + """ + In general, we want to avoid setting the offset of a graphic, because the + z-dimension may actually mean something; we cannot know whether the user is + building a 3D scene or not. We could check whether the 3d dimension of line/point data + is all zeros, but maybe this is intended, and *other* graphics in the same scene + may be actually 3D. We could check camera.fov being zero, but maybe the user + switches to a 3D camera later, or uses a 3D orthographic camera. + + The one exception, kindof, is images, which are inherently 2D, and for which + layering helps a lot to get things rendered correctly. So we basically layer the + images, in the order that they were added, pushing older images backwards (away + from the camera). + """ + count = 0 + for graphic in self._graphics: + if isinstance(graphic, ImageGraphic): + count += 1 + auto_depth = -count + user_changed_depth = graphic.offset[2] % 1 > 0.0 # i.e. is not integer + if not user_changed_depth: + graphic.offset = (*graphic.offset[:-1], auto_depth) + def add_graphic(self, graphic: Graphic, center: bool = True): """ Add a Graphic to the scene @@ -415,10 +476,8 @@ def add_graphic(self, graphic: Graphic, center: bool = True): self._add_or_insert_graphic(graphic=graphic, center=center, action="add") - 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)) + if isinstance(graphic, ImageGraphic): + self._sort_images_by_depth() def insert_graphic( self, @@ -457,17 +516,14 @@ def insert_graphic( graphic=graphic, center=center, action="insert", index=index ) - 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) + if isinstance(graphic, ImageGraphic): + self._sort_images_by_depth() def _add_or_insert_graphic( self, graphic: Graphic, center: bool = True, - action: str = Literal["insert", "add"], + action: Literal["insert", "add"] = "add", index: int = 0, ): """Private method to handle inserting or adding a graphic to a PlotArea.""" diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 98a6268f1..49120c71a 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -17,7 +17,7 @@ # number of pixels taken by the imgui toolbar when present -IMGUI_TOOLBAR_HEIGHT = 39 +IMGUI_TOOLBAR_HEIGHT = 36 def make_canvas_and_renderer( diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 69a556109..9da836fd7 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -5,6 +5,7 @@ import numpy as np import pygfx +from ..utils.enums import RenderQueue from ..graphics import Graphic from ..graphics.features import GraphicFeatureEvent from ..graphics import LineGraphic, ScatterGraphic, ImageGraphic @@ -70,26 +71,34 @@ def __init__( # 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), + material=pygfx.LineMaterial( + alpha_mode="blend", + render_queue=RenderQueue.overlay, + thickness=8, + color=self._color, + depth_write=False, + depth_test=False, + ), ) # 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", - ), + text=str(label), + font_size=6, + screen_space=False, + anchor="middle-left", material=pygfx.TextMaterial( + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.overlay, color="w", outline_color="w", outline_thickness=0, + depth_write=False, + depth_test=False, ), ) @@ -101,7 +110,6 @@ def __init__( 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" @@ -175,10 +183,18 @@ def __init__( 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 + alpha_mode="blend", + render_queue=RenderQueue.overlay, + color=pygfx.Color([0.1, 0.1, 0.1, 1]), + wireframe_thickness=10, + depth_write=False, + depth_test=False, ), ) + # Plane gets rendered before text and line + self._mesh.render_order = -1 + self.world_object.add(self._mesh) self.world_object.add(self._legend_items_group) diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index aeb8dd996..7507a7ff2 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -1,4 +1,5 @@ from math import ceil +from typing import Sequence import weakref import numpy as np @@ -6,7 +7,7 @@ import pygfx from ..utils import subsample_array -from ..graphics import LineGraphic, ImageGraphic, TextGraphic +from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic from ..graphics.utils import pause_events from ..graphics._base import Graphic from ..graphics.selectors import LinearRegionSelector @@ -29,28 +30,58 @@ class HistogramLUTTool(Graphic): def __init__( self, data: np.ndarray, - image_graphic: ImageGraphic, + images: ( + ImageGraphic + | ImageVolumeGraphic + | Sequence[ImageGraphic | ImageVolumeGraphic] + ), nbins: int = 100, flank_divisor: float = 5.0, **kwargs, ): """ + HistogramLUT tool that can be used to control the vmin, vmax of ImageGraphics or ImageVolumeGraphics. + If used to control multiple images or image volumes it is assumed that they share a representation of + the same data, and that their histogram, vmin, and vmax are identical. For example, displaying a + ImageVolumeGraphic and several images that represent slices of the same volume data. Parameters ---------- - data - image_graphic + data: np.ndarray + + images: ImageGraphic | ImageVolumeGraphic | tuple[ImageGraphic | ImageVolumeGraphic] + nbins: int, defaut 100. Total number of bins used in the histogram + flank_divisor: float, default 5.0. Fraction of empty histogram bins on the tails of the distribution set `np.inf` for no flanks - kwargs + + kwargs: passed to ``Graphic`` + """ super().__init__(**kwargs) self._nbins = nbins self._flank_divisor = flank_divisor - self._image_graphic = image_graphic + + if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): + images = (images,) + elif isinstance(images, Sequence): + if not all( + [isinstance(ig, (ImageGraphic, ImageVolumeGraphic)) for ig in images] + ): + raise TypeError( + f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " + f"tuple or list or ImageGraphic | ImageVolumeGraphic" + ) + else: + raise TypeError( + f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " + f"tuple or list or ImageGraphic | ImageVolumeGraphic" + ) + + self._images = images self._data = weakref.proxy(data) @@ -60,7 +91,9 @@ def __init__( line_data = np.column_stack([hist_scaled, edges_flanked]) - self._histogram_line = LineGraphic(line_data) + self._histogram_line = LineGraphic( + line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(0, 0, -1) + ) bounds = (edges[0] * self._scale_factor, edges[-1] * self._scale_factor) limits = (edges_flanked[0], edges_flanked[-1]) @@ -73,19 +106,18 @@ def __init__( size=size, center=origin[0], axis="y", - edge_thickness=8, parent=self._histogram_line, ) + self._vmin = self.images[0].vmin + self._vmax = self.images[0].vmax + # there will be a small difference with the histogram edges so this makes them both line up exactly self._linear_region_selector.selection = ( - self._image_graphic.vmin * self._scale_factor, - self._image_graphic.vmax * self._scale_factor, + self._vmin * self._scale_factor, + self._vmax * self._scale_factor, ) - 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( @@ -94,7 +126,8 @@ def __init__( offset=(0, 0, 0), anchor="top-left", outline_color="black", - outline_thickness=1, + outline_thickness=0.5, + alpha_mode="solid", ) self._text_vmin.world_object.material.pick_write = False @@ -105,7 +138,8 @@ def __init__( offset=(0, 0, 0), anchor="bottom-left", outline_color="black", - outline_thickness=1, + outline_thickness=0.5, + alpha_mode="solid", ) self._text_vmax.world_object.material.pick_write = False @@ -130,12 +164,13 @@ def __init__( self._linear_region_handler, "selection" ) - ig_events = _get_image_graphic_events(self.image_graphic) + ig_events = _get_image_graphic_events(self.images[0]) - self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events) + for ig in self.images: + ig.add_event_handler(self._image_cmap_handler, *ig_events) # colorbar for grayscale images - if self.image_graphic.data.value.ndim != 3: + if self.images[0].cmap is not None: self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked) self._colorbar.add_event_handler(self._open_cmap_picker, "click") @@ -162,13 +197,13 @@ def _make_colorbar(self, edges_flanked) -> ImageGraphic: data=colorbar_data, vmin=self.vmin, vmax=self.vmax, - cmap=self.image_graphic.cmap, + cmap=self.images[0].cmap, interpolation="linear", offset=(-55, edges_flanked[0], -1), ) cbar.world_object.world.scale_x = 20 - self._cmap = self.image_graphic.cmap + self._cmap = self.images[0].cmap return cbar @@ -256,8 +291,9 @@ def cmap(self, name: str): if self._colorbar is None: return - with pause_events(self.image_graphic): - self.image_graphic.cmap = name + with pause_events(*self.images): + for ig in self.images: + ig.cmap = name self._cmap = name self._colorbar.cmap = name @@ -268,14 +304,15 @@ def vmin(self) -> float: @vmin.setter def vmin(self, value: float): - with pause_events(self.image_graphic, self._linear_region_selector): + with pause_events(self._linear_region_selector, *self.images): # must use world coordinate values directly from selection() # otherwise the linear region bounds jump to the closest bin edges self._linear_region_selector.selection = ( value * self._scale_factor, self._linear_region_selector.selection[1], ) - self.image_graphic.vmin = value + for ig in self.images: + ig.vmin = value self._vmin = value if self._colorbar is not None: @@ -291,7 +328,7 @@ def vmax(self) -> float: @vmax.setter def vmax(self, value: float): - with pause_events(self.image_graphic, self._linear_region_selector): + with pause_events(self._linear_region_selector, *self.images): # must use world coordinate values directly from selection() # otherwise the linear region bounds jump to the closest bin edges self._linear_region_selector.selection = ( @@ -299,7 +336,8 @@ def vmax(self, value: float): value * self._scale_factor, ) - self.image_graphic.vmax = value + for ig in self.images: + ig.vmax = value self._vmax = value if self._colorbar is not None: @@ -326,7 +364,7 @@ def set_data(self, data, reset_vmin_vmax: bool = True): self._linear_region_selector.limits = limits self._linear_region_selector.selection = bounds else: - with pause_events(self.image_graphic, self._linear_region_selector): + with pause_events(self._linear_region_selector, *self.images): # don't change the current selection self._linear_region_selector.limits = limits @@ -336,7 +374,7 @@ def set_data(self, data, reset_vmin_vmax: bool = True): self._colorbar.clear_event_handlers() self.world_object.remove(self._colorbar.world_object) - if self.image_graphic.data.value.ndim != 3: + if self.images[0].cmap is not None: self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked) self._colorbar.add_event_handler(self._open_cmap_picker, "click") @@ -349,34 +387,39 @@ def set_data(self, data, reset_vmin_vmax: bool = True): self._plot_area.auto_scale() @property - def image_graphic(self) -> ImageGraphic: - return self._image_graphic - - @image_graphic.setter - def image_graphic(self, graphic): - if not isinstance(graphic, ImageGraphic): + def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic]: + return self._images + + @images.setter + def images(self, images): + if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): + images = (images,) + elif isinstance(images, Sequence): + if not all( + [isinstance(ig, (ImageGraphic, ImageVolumeGraphic)) for ig in images] + ): + raise TypeError( + f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " + f"tuple or list or ImageGraphic | ImageVolumeGraphic" + ) + else: raise TypeError( - f"HistogramLUTTool can only use ImageGraphic types, you have passed: {type(graphic)}" - ) - - if self._image_graphic is not None: - # cleanup events from current image graphic - ig_events = _get_image_graphic_events(self._image_graphic) - self._image_graphic.remove_event_handler( - self._image_cmap_handler, *ig_events + f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " + f"tuple or list or ImageGraphic | ImageVolumeGraphic" ) - self._image_graphic = graphic + if self._images is not None: + for ig in self._images: + # cleanup events from current image graphics + ig_events = _get_image_graphic_events(ig) + ig.remove_event_handler(self._image_cmap_handler, *ig_events) - ig_events = _get_image_graphic_events(self._image_graphic) + self._images = images - self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events) + ig_events = _get_image_graphic_events(self._images[0]) - def disconnect_image_graphic(self): - ig_events = _get_image_graphic_events(self._image_graphic) - self._image_graphic.remove_event_handler(self._image_cmap_handler, *ig_events) - del self._image_graphic - # self._image_graphic = None + for ig in self.images: + ig.add_event_handler(self._image_cmap_handler, *ig_events) def _open_cmap_picker(self, ev): # check if right click diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_tooltip.py index 2fbdfcec2..f6c9cf531 100644 --- a/fastplotlib/tools/_tooltip.py +++ b/fastplotlib/tools/_tooltip.py @@ -3,6 +3,7 @@ import numpy as np import pygfx +from ..utils.enums import RenderQueue from ..graphics import LineGraphic, ImageGraphic, ScatterGraphic, Graphic from ..graphics.features import GraphicFeatureEvent @@ -59,19 +60,28 @@ def __init__(self): screen_space=False, anchor="bottom-left", material=pygfx.TextMaterial( + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.overlay, color="w", outline_color="w", outline_thickness=0.0, + depth_write=False, + depth_test=False, pick_write=False, ), ) # plane for the background of the text object geometry = pygfx.plane_geometry(1, 1) - material = pygfx.MeshBasicMaterial(color=(0.1, 0.1, 0.3, 0.95)) + material = pygfx.MeshBasicMaterial( + alpha_mode="blend", + render_queue=RenderQueue.overlay, + color=(0.1, 0.1, 0.3, 0.95), + depth_write=False, + depth_test=False, + ) self._plane = pygfx.Mesh(geometry, material) - # else text not visible - self._plane.world.z = 0.5 # line to outline the plane mesh self._line = pygfx.Line( @@ -87,8 +97,17 @@ def __init__(self): dtype=np.float32, ) ), - material=pygfx.LineThinMaterial(thickness=1.0, color=(0.8, 0.8, 1.0, 1.0)), + material=pygfx.LineThinMaterial( + alpha_mode="blend", + render_queue=RenderQueue.overlay, + thickness=1.0, + color=(0.8, 0.8, 1.0, 1.0), + depth_write=False, + depth_test=False, + ), ) + # Plane gets rendered before text and line + self._plane.render_order = -1 self._world_object = pygfx.Group() self._world_object.add(self._plane, self._text, self._line) diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index e31dd8d4a..3e763e08c 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -1,3 +1,4 @@ +import enum from typing import Literal import numpy as np @@ -42,7 +43,7 @@ def __init__( size: int, location: Literal["bottom", "right"], title: str, - window_flags: int = imgui.WindowFlags_.no_collapse + window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize, *args, **kwargs, @@ -64,8 +65,8 @@ def __init__( title: str window title - window_flags: int - window flag enum, valid flags are: + window_flags: enum.IntFlag + Window flag enum, can be compared with ``|`` operator. Valid flags are: .. code-block:: py @@ -109,7 +110,6 @@ def __init__( self._location = location self._title = title self._window_flags = window_flags - self._fa_icons = self._figure._fa_icons self._x, self._y, self._width, self._height = self.get_rect() @@ -231,7 +231,6 @@ def __init__(self, figure: Figure, *args, **kwargs): super().__init__() self._figure = figure - self._fa_icons = self._figure._fa_icons self.is_open = False diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index a06e81b90..435de4206 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -6,14 +6,13 @@ class SubplotToolbar(Window): - def __init__(self, subplot: Subplot, fa_icons: imgui.ImFont): + def __init__(self, subplot: Subplot): """ Subplot toolbar shown below all subplots """ super().__init__() self._subplot = subplot - self._fa_icons = fa_icons def update(self): # get subplot rect @@ -32,42 +31,32 @@ def update(self): imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags) - # icons for buttons - imgui.push_font(self._fa_icons) - # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): self._subplot.auto_scale() - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("autoscale scene") # center scene - imgui.push_font(self._fa_icons) if imgui.button(fa.ICON_FA_ALIGN_CENTER): self._subplot.center_scene() - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("center scene") - imgui.push_font(self._fa_icons) # checkbox controller _, self._subplot.controller.enabled = imgui.checkbox( fa.ICON_FA_COMPUTER_MOUSE, self._subplot.controller.enabled ) - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("enable/disable controller") - imgui.push_font(self._fa_icons) - # checkbox maintain_apsect + # checkbox maintain_aspect _, self._subplot.camera.maintain_aspect = imgui.checkbox( fa.ICON_FA_EXPAND, self._subplot.camera.maintain_aspect ) - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("maintain aspect") diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py index 3c48bd4d8..a80e5b2aa 100644 --- a/fastplotlib/ui/right_click_menus/_colormap_picker.py +++ b/fastplotlib/ui/right_click_menus/_colormap_picker.py @@ -26,14 +26,13 @@ class ColormapPicker(Popup): name = "colormap-picker" def __init__(self, figure): - super().__init__(figure=figure, fa_icons=None) + super().__init__(figure=figure) self.renderer = self._figure.renderer self.imgui_renderer = self._figure.imgui_renderer # maps str cmap names -> int texture IDs - self._texture_ids: dict[str, int] = {} - self._textures = list() + self._cmap_texture_refs: dict[str, imgui.ImTextureRef] = dict() # make all colormaps and upload representative texture for each cmap to the GPU for name in all_cmaps: @@ -45,8 +44,7 @@ def __init__(self, figure): data = np.vstack([[data]] * 2).astype(np.uint8) # upload the texture to the GPU, get the texture ID and texture - self._texture_ids[name], texture = self._create_texture_and_upload(data) - self._textures.append(texture) + self._cmap_texture_refs[name] = self._create_texture_and_upload(data) # used to set the states of the UI self._lut_tool = None @@ -83,12 +81,8 @@ def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture] # get a view texture_view = texture.create_view() - # get the id so that imgui can display it - id_texture = ctypes.c_int32(id(texture_view)).value - # add texture view to the backend so that it can be retrieved for rendering - self.imgui_renderer.backend._texture_views[id_texture] = texture_view - - return id_texture, texture + # return texture ref + return self.imgui_renderer.backend.register_texture(texture_view) def open(self, pos: tuple[int, int], lut_tool): """ @@ -121,10 +115,19 @@ def close(self): self.is_open = False def _add_cmap_menu_item(self, cmap_name: str): - texture_id = self._texture_ids[cmap_name] + # white border around cmap image + imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) + imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0) + + # cmap image + texture_ref = self._cmap_texture_refs[cmap_name] imgui.image( - texture_id, image_size=(50, self._texture_height), border_col=(1, 1, 1, 1) + texture_ref, + image_size=(50, self._texture_height), ) + # pop white border + imgui.pop_style_var() + imgui.pop_style_color() imgui.same_line() @@ -148,13 +151,10 @@ def update(self): self.is_open = True # make the cmap image height the same as the text height - self._texture_height = ( - self.imgui_renderer.backend.io.font_global_scale - * imgui.get_font().font_size - ) - 2 + self._texture_height = (imgui.get_font_size()) - 2 if imgui.menu_item("Reset vmin-vmax", "", False)[0]: - self._lut_tool.image_graphic.reset_vmin_vmax() + self._lut_tool.images[0].reset_vmin_vmax() # add all the cmap options for cmap_type in COLORMAP_NAMES.keys(): diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index 4bb59c51d..bb9e5bdef 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -22,8 +22,8 @@ def flip_axis(subplot: PlotArea, axis: str, flip: bool): class StandardRightClickMenu(Popup): """Right click menu that is shown on subplots""" - def __init__(self, figure, fa_icons): - super().__init__(figure=figure, fa_icons=fa_icons) + def __init__(self, figure): + super().__init__(figure=figure) self._last_right_click_pos = None self._mouse_down: bool = False @@ -182,19 +182,4 @@ def update(self): imgui.end_menu() - # renderer blend modes - if imgui.begin_menu("Blend mode"): - for blend_mode in sorted( - self.get_subplot().renderer._blenders_available.keys() - ): - clicked, _ = imgui.menu_item( - label=blend_mode, - shortcut="", - p_selected=self.get_subplot().renderer.blend_mode == blend_mode, - ) - - if clicked: - self.get_subplot().renderer.blend_mode = blend_mode - imgui.end_menu() - imgui.end_popup() diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index dce4d96f9..dd527ca67 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -5,6 +5,7 @@ from .functions import * from .gpu import enumerate_adapters, select_adapter, print_wgpu_report from ._plot_helpers import * +from .enums import * @dataclass diff --git a/fastplotlib/utils/enums.py b/fastplotlib/utils/enums.py new file mode 100644 index 000000000..3901b082c --- /dev/null +++ b/fastplotlib/utils/enums.py @@ -0,0 +1,15 @@ +from enum import IntEnum + + +class RenderQueue(IntEnum): + # Defaults by PyGfx + background = 1000 + opaque = 2000 + opaque_with_discard = 2400 + auto = 2600 + transparent = 3000 + overlay = 4000 + # For axes and selectors we use a higher render_queue, so they get rendered later than + # the graphics. Axes (rulers) have depth_compare '<=' and selectors don't compare depth. + axes = 3400 # still in 'object' group + selector = 3600 # considered in 'overlay' group diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index a1d6d476a..a839ed9d0 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -273,15 +273,15 @@ def quick_min_max(data: np.ndarray, max_size=1e6) -> tuple[float, float]: Parameters ---------- - data: np.ndarray or array-like with `min` and `max` attributes + data: np.ndarray or array-like max_size : int, optional - largest array size allowed in the subsampled array. Default is 1e6. + subsamples data array to this max size Returns ------- (float, float) - (min, max) + (min, max) estimate """ if hasattr(data, "min") and hasattr(data, "max"): diff --git a/fastplotlib/utils/mapbox_earcut.py b/fastplotlib/utils/mapbox_earcut.py new file mode 100644 index 000000000..ecb129593 --- /dev/null +++ b/fastplotlib/utils/mapbox_earcut.py @@ -0,0 +1,835 @@ +# The code below is copied from https://github.com/MIERUNE/earcut-py/blob/cb30bff5458fca224c573187f36d889068ebd4e0/src/earcut/__init__.py +# which is a port of Mapbox' JS earcut (https://github.com/mapbox/earcut) version 2.2.4 +# The code is not modified, except maybe formatting to keep the linter happy. +# +# ISC License +# +# Copyright (c) 2016, Mapbox +# Copyright (c) 2023, MIERUNE Inc. +# +# Permission to use, copy, modify, and/or distribute this software for any purpose +# with or without fee is hereby granted, provided that the above copyright notice +# and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +# THIS SOFTWARE. + +import math +from typing import Optional + + +def earcut(data, hole_indices=None, dim=2): + has_holes = bool(hole_indices) + outer_len = hole_indices[0] * dim if has_holes else len(data) + outer_node = _linked_list(data, 0, outer_len, dim, True) + triangles = [] + + if (not outer_node) or outer_node.next == outer_node.prev: + return triangles + + min_x = min_y = inv_size = None + + if has_holes: + outer_node = _eliminate_holes(data, hole_indices, outer_node, dim) + + # if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox + if len(data) > 80 * dim: + min_x = max_x = data[0] + min_y = max_y = data[1] + + for i in range(dim, outer_len, dim): + x = data[i] + y = data[i + 1] + if x < min_x: + min_x = x + if y < min_y: + min_y = y + if x > max_x: + max_x = x + if y > max_y: + max_y = y + + # minX, minY and invSize are later used to transform coords into integers for z-order calculation + inv_size = max(max_x - min_x, max_y - min_y) + inv_size = 32767 / inv_size if inv_size != 0 else 0 + + _earcut_linked(outer_node, triangles, dim, min_x, min_y, inv_size) + + return triangles + + +# create a circular doubly linked list from polygon points in the specified winding order +def _linked_list(data, start, end, dim, clockwise): + last = None + + if clockwise == (_signed_area(data, start, end, dim) > 0): + for i in range(start, end, dim): + last = _insert_node(i, data[i], data[i + 1], last) + else: + for i in reversed(range(start, end, dim)): + last = _insert_node(i, data[i], data[i + 1], last) + + if last and _equals(last, last.next): + _remove_node(last) + last = last.next + + return last + + +# eliminate colinear or duplicate points +def _filter_points(start, end=None): + if not start: + return start + + if not end: + end = start + + p = start + while True: + again = False + + if not p.steiner and (_equals(p, p.next) or _area(p.prev, p, p.next) == 0): + _remove_node(p) + p = end = p.prev + if p == p.next: + break + again = True + + else: + p = p.next + + if (not again) and p == end: + break + + return end + + +# main ear slicing loop which triangulates a polygon (given as a linked list) +def _earcut_linked(ear, triangles, dim, min_x, min_y, inv_size, _pass=0): + if not ear: + return + + # interlink polygon nodes in z-order + if not _pass and inv_size: + _index_curve(ear, min_x, min_y, inv_size) + + stop = ear + + # iterate through ears, slicing them one by one + while ear.prev != ear.next: + prev = ear.prev + next = ear.next + is_ear = ( + _is_ear_hashed(ear, min_x, min_y, inv_size) if inv_size else _is_ear(ear) + ) + + if is_ear: + # cut off the triangle + triangles.append(prev.i // dim) + triangles.append(ear.i // dim) + triangles.append(next.i // dim) + + _remove_node(ear) + + # skipping the next vertex leads to less sliver triangles + ear = next.next + stop = next.next + + continue + + ear = next + + # if we looped through the whole remaining polygon and can't find any more ears + if ear == stop: + # try filtering points and slicing again + if not _pass: + _earcut_linked( + _filter_points(ear), triangles, dim, min_x, min_y, inv_size, 1 + ) + + # if this didn't work, try curing all small self-intersections locally + elif _pass == 1: + ear = _cure_local_intersections(_filter_points(ear), triangles, dim) + _earcut_linked(ear, triangles, dim, min_x, min_y, inv_size, 2) + + # as a last resort, try splitting the remaining polygon into two + elif _pass == 2: + _split_earcut(ear, triangles, dim, min_x, min_y, inv_size) + + break + + +# check whether a polygon node forms a valid ear with adjacent nodes +def _is_ear(ear): + a = ear.prev + b = ear + c = ear.next + + if _area(a, b, c) >= 0: + return False # reflex, can't be an ear + + # now make sure we don't have other points inside the potential ear + ax = a.x + ay = a.y + bx = b.x + by = b.y + cx = c.x + cy = c.y + + # triangle bbox; min & max are calculated like this for speed + x0 = (ax if ax < cx else cx) if ax < bx else (bx if bx < cx else cx) + y0 = (ay if ay < cy else cy) if ay < by else (by if by < cy else cy) + x1 = (ax if ax > cx else cx) if ax > bx else (bx if bx > cx else cx) + y1 = (ay if ay > cy else cy) if ay > by else (by if by > cy else cy) + + p = c.next + while p != a: + if ( + (p.x >= x0 and p.x <= x1 and p.y >= y0 and p.y <= y1) + and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) + and _area(p.prev, p, p.next) >= 0 + ): + return False + p = p.next + + return True + + +def _is_ear_hashed(ear, min_x, min_y, inv_size): + a = ear.prev + b = ear + c = ear.next + + if _area(a, b, c) >= 0: + return False # reflex, can't be an ear + + ax = a.x + ay = a.y + bx = b.x + by = b.y + cx = c.x + cy = c.y + + # triangle bbox; min & max are calculated like this for speed + x0 = (ax if ax < cx else cx) if ax < bx else (bx if bx < cx else cx) + y0 = (ay if ay < cy else cy) if ay < by else (by if by < cy else cy) + x1 = (ax if ax > cx else cx) if ax > bx else (bx if bx > cx else cx) + y1 = (ay if ay > cy else cy) if ay > by else (by if by > cy else cy) + + # z-order range for the current triangle bbox + min_z = _z_order(x0, y0, min_x, min_y, inv_size) + max_z = _z_order(x1, y1, min_x, min_y, inv_size) + + p = ear.prev_z + n = ear.next_z + + # look for points inside the triangle in both directions + while p and p.z >= min_z and n and n.z <= max_z: + if ( + (p.x >= x0 and p.x <= x1 and p.y >= y0 and p.y <= y1) + and (p != a and p != c) + and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) + and _area(p.prev, p, p.next) >= 0 + ): + return False + p = p.prev_z + + if ( + (n.x >= x0 and n.x <= x1 and n.y >= y0 and n.y <= y1) + and (n != a and n != c) + and _point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y) + and _area(n.prev, n, n.next) >= 0 + ): + return False + n = n.next_z + + # look for remaining points in decreasing z-order + while p and p.z >= min_z: + if ( + (p != ear.prev and p != ear.next) + and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) + and _area(p.prev, p, p.next) >= 0 + ): + return False + p = p.prev_z + + # look for remaining points in increasing z-order + while n and n.z <= max_z: + if ( + (n != ear.prev and n != ear.next) + and _point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y) + and _area(n.prev, n, n.next) >= 0 + ): + return False + n = n.next_z + + return True + + +# go through all polygon nodes and cure small local self-intersections +def _cure_local_intersections(start, triangles, dim): + p = start + while True: + a = p.prev + b = p.next.next + + if ( + not _equals(a, b) + and _intersects(a, p, p.next, b) + and _locally_inside(a, b) + and _locally_inside(b, a) + ): + triangles.append(a.i // dim) + triangles.append(p.i // dim) + triangles.append(b.i // dim) + + # remove two nodes involved + _remove_node(p) + _remove_node(p.next) + + p = start = b + + p = p.next + if p == start: + break + + return _filter_points(p) + + +# try splitting polygon into two and triangulate them independently +def _split_earcut(start, triangles, dim, min_x, min_y, inv_size): + # look for a valid diagonal that divides the polygon into two + a = start + while True: + b = a.next.next + while b != a.prev: + if a.i != b.i and _is_valid_diagonal(a, b): + # split the polygon in two by the diagonal + c = _split_polygon(a, b) + + # filter colinear points around the cuts + a = _filter_points(a, a.next) + c = _filter_points(c, c.next) + + # run earcut on each half + _earcut_linked(a, triangles, dim, min_x, min_y, inv_size) + _earcut_linked(c, triangles, dim, min_x, min_y, inv_size) + return + b = b.next + a = a.next + if a == start: + break + + +# link every hole into the outer loop, producing a single-ring polygon without holes +def _eliminate_holes(data, hole_indices, outer_node, dim): + queue = [] + _len = len(hole_indices) + + for i in range(_len): + start = hole_indices[i] * dim + end = hole_indices[i + 1] * dim if i < _len - 1 else len(data) + lst = _linked_list(data, start, end, dim, False) + if lst: + if lst == lst.next: + lst.steiner = True + queue.append(_get_leftmost(lst)) + + queue.sort(key=lambda i: i.x) + + # process holes from left to right + for q_i in queue: + outer_node = _eliminate_hole(q_i, outer_node) + + return outer_node + + +# find a bridge between vertices that connects hole with an outer ring and and link it +def _eliminate_hole(hole, outer_node): + bridge = _find_hole_bridge(hole, outer_node) + if not bridge: + return outer_node + + bridge_reverse = _split_polygon(bridge, hole) + + _filter_points(bridge_reverse, bridge_reverse.next) + return _filter_points(bridge, bridge.next) + + +# David Eberly's algorithm for finding a bridge between hole and outer polygon +def _find_hole_bridge(hole, outer_node): + p = outer_node + hx = hole.x + hy = hole.y + qx = -math.inf + m = None + + # find a segment intersected by a ray from the hole's leftmost point to the left + # segment's endpoint with lesser x will be potential connection point + while True: + px = p.x + py = p.y + if hy <= py and hy >= p.next.y and p.next.y != py: + x = px + (hy - py) * (p.next.x - px) / (p.next.y - py) + if x <= hx and x > qx: + qx = x + m = p if px < p.next.x else p.next + if x == hx: + # hole touches outer segment; pick leftmost endpoint + return m + p = p.next + if p == outer_node: + break + + if not m: + return None + + # look for points inside the triangle of hole point, segment intersection and endpoint + # if there are no points found, we have a valid connection + # otherwise choose the point of the minimum angle with the ray as connection point + + stop = m + mx = m.x + my = m.y + tan_min = math.inf + + p = m + + while True: + px = p.x + py = p.y + if (hx >= px and px >= mx and hx != px) and _point_in_triangle( + hx if hy < my else qx, + hy, + mx, + my, + qx if hy < my else hx, + hy, + px, + py, + ): + tan = abs(hy - py) / (hx - px) # tangential + + if _locally_inside(p, hole) and ( + tan < tan_min + or ( + tan == tan_min + and (px > m.x or (px == m.x and _sector_contains_sector(m, p))) + ) + ): + m = p + tan_min = tan + + p = p.next + if p == stop: + break + + return m + + +# whether sector in vertex m contains sector in vertex p in the same coordinates +def _sector_contains_sector(m, p): + return _area(m.prev, m, p.prev) < 0 and _area(p.next, m, m.next) < 0 + + +# interlink polygon nodes in z-order +def _index_curve(start, min_x, min_y, inv_size): + p = start + while True: + if p.z is None: + p.z = _z_order(p.x, p.y, min_x, min_y, inv_size) + p.prev_z = p.prev + p.next_z = p.next + p = p.next + if p == start: + break + + p.prev_z.next_z = None + p.prev_z = None + + _sort_linked(p) + + +# Simon Tatham's linked list merge sort algorithm +# http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html +def _sort_linked(_list): + in_size = 1 + + while True: + p = _list + _list = None + tail = None + num_merges = 0 + + while p: + num_merges += 1 + q = p + p_size = 0 + for i in range(in_size): + p_size += 1 + q = q.next_z + if not q: + break + q_size = in_size + + while p_size > 0 or (q_size > 0 and q): + if p_size != 0 and (q_size == 0 or not q or p.z <= q.z): + e = p + p = p.next_z + p_size -= 1 + else: + e = q + q = q.next_z + q_size -= 1 + + if tail: + tail.next_z = e + else: + _list = e + + e.prev_z = tail + tail = e + + p = q + + tail.next_z = None + in_size *= 2 + + if num_merges <= 1: + break + + return _list + + +# z-order of a point given coords and inverse of the longer side of data bbox +def _z_order(x, y, min_x, min_y, inv_size): + # coords are transformed into non-negative 15-bit integer range + x = int((x - min_x) * inv_size) + y = int((y - min_y) * inv_size) + + x = (x | (x << 8)) & 0x00FF00FF + x = (x | (x << 4)) & 0x0F0F0F0F + x = (x | (x << 2)) & 0x33333333 + x = (x | (x << 1)) & 0x55555555 + + y = (y | (y << 8)) & 0x00FF00FF + y = (y | (y << 4)) & 0x0F0F0F0F + y = (y | (y << 2)) & 0x33333333 + y = (y | (y << 1)) & 0x55555555 + + return x | (y << 1) + + +# find the leftmost node of a polygon ring +def _get_leftmost(start): + p = start + leftmost = start + + while True: + if p.x < leftmost.x or (p.x == leftmost.x and p.y < leftmost.y): + leftmost = p + + p = p.next + if p == start: + break + + return leftmost + + +# check if a point lies within a convex triangle +def _point_in_triangle(ax, ay, bx, by, cx, cy, px, py): + pax = ax - px + pay = ay - py + pbx = bx - px + pby = by - py + pcx = cx - px + pcy = cy - py + return ( + pcx * pay - pax * pcy >= 0 + and pax * pby - pbx * pay >= 0 + and pbx * pcy - pcx * pby >= 0 + ) + + +# check if a diagonal between two polygon nodes is valid (lies in polygon interior) +def _is_valid_diagonal(a, b): + return ( + # dones't intersect other edges + (a.next.i != b.i and a.prev.i != b.i and not _intersects_polygon(a, b)) + and ( + # locally visible + (_locally_inside(a, b) and _locally_inside(b, a) and _middle_inside(a, b)) + # does not create opposite-facing sectors + and (_area(a.prev, a, b.prev) or _area(a, b.prev, b)) + # special zero-length case + or ( + _equals(a, b) + and _area(a.prev, a, a.next) > 0 + and _area(b.prev, b, b.next) > 0 + ) + ) + ) + + +# signed area of a triangle +def _area(p, q, r): + px = p.x + py = p.y + qx = q.x + qy = q.y + rx = r.x + ry = r.y + return (qy - py) * (rx - qx) - (qx - px) * (ry - qy) + + +# check if two points are equal +def _equals(p1, p2): + return p1.x == p2.x and p1.y == p2.y + + +# check if two segments intersect +def _intersects(p1, q1, p2, q2): + o1 = _sign(_area(p1, q1, p2)) + o2 = _sign(_area(p1, q1, q2)) + o3 = _sign(_area(p2, q2, p1)) + o4 = _sign(_area(p2, q2, q1)) + + if ( + (o1 != o2 and o3 != o4) # general case + or ( + o1 == 0 and _on_segment(p1, p2, q1) + ) # p1, q1 and p2 are collinear and p2 lies on p1q1 + or ( + o2 == 0 and _on_segment(p1, q2, q1) + ) # p1, q1 and q2 are collinear and q2 lies on p1q1 + or ( + o3 == 0 and _on_segment(p2, p1, q2) + ) # p2, q2 and p1 are collinear and p1 lies on p2q2 + or ( + o4 == 0 and _on_segment(p2, q1, q2) + ) # p2, q2 and q1 are collinear and q1 lies on p2q2 + ): + return True + + return False + + +# for collinear points p, q, r, check if point q lies on segment pr +def _on_segment(p, q, r): + return ( + q.x <= max(p.x, r.x) + and q.x >= min(p.x, r.x) + and q.y <= max(p.y, r.y) + and q.y >= min(p.y, r.y) + ) + + +def _sign(num): + if num > 0: + return 1 + elif num < 0: + return -1 + else: + return 0 + + +# check if a polygon diagonal intersects any polygon segments +def _intersects_polygon(a, b): + p = a + while True: + pi = p.i + ai = a.i + bi = b.i + pnext = p.next + pnexti = pnext.i + if (pi != ai and pnexti != ai and pi != bi and pnexti != bi) and _intersects( + p, pnext, a, b + ): + return True + + p = pnext + if p == a: + break + + return False + + +# check if a polygon diagonal is locally inside the polygon +def _locally_inside(a, b): + aprev = a.prev + anext = a.next + if _area(aprev, a, anext) < 0: + return _area(a, b, anext) >= 0 and _area(a, aprev, b) >= 0 + else: + return _area(a, b, aprev) < 0 or _area(a, anext, b) < 0 + + +# check if the middle point of a polygon diagonal is inside the polygon +def _middle_inside(a, b): + p = a + inside = False + px = (a.x + b.x) / 2 + py = (a.y + b.y) / 2 + while True: + p_x = p.x + p_y = p.y + p_next = p.next + p_next_y = p_next.y + if ( + (p_y > py) != (p_next_y > py) + and p_next.y != p_y + and (px < (p_next.x - p_x) * (py - p_y) / (p_next_y - p_y) + p_x) + ): + inside = not inside + p = p_next + if p == a: + break + + return inside + + +# link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two +# if one belongs to the outer ring and another to a hole, it merges it into a single ring +def _split_polygon(a, b): + a2 = _Node(a.i, a.x, a.y) + b2 = _Node(b.i, b.x, b.y) + an = a.next + bp = b.prev + + a.next = b + b.prev = a + + a2.next = an + an.prev = a2 + b2.next = a2 + a2.prev = b2 + bp.next = b2 + b2.prev = bp + + return b2 + + +# create a node and optionally link it with previous one (in a circular doubly linked list) +def _insert_node(i, x, y, last): + p = _Node(i, x, y) + + if not last: + p.prev = p + p.next = p + + else: + p.next = last.next + p.prev = last + last.next.prev = p + last.next = p + + return p + + +def _remove_node(p): + p.next.prev = p.prev + p.prev.next = p.next + + if p.prev_z: + p.prev_z.next_z = p.next_z + + if p.next_z: + p.next_z.prev_z = p.prev_z + + +class _Node: + __slots__ = ["i", "x", "y", "prev", "next", "z", "prev_z", "next_z", "steiner"] + i: int + x: float + y: float + prev: Optional["_Node"] + next: Optional["_Node"] + z: Optional[int] + prev_z: Optional["_Node"] + next_z: Optional["_Node"] + steiner: bool + + def __init__(self, i, x, y): + # vertex index in coordinates array + self.i = i + + # vertex coordinates + self.x = x + self.y = y + + # previous and next vertex nodes in a polygon ring + self.prev = None + self.next = None + + # z-order curve value + self.z = None + + # previous and next nodes in z-order + self.prev_z = None + self.next_z = None + + # indicates whether this is a steiner point + self.steiner = False + + +def _signed_area(data, start, end, dim): + sum = 0 + j = end - dim + for i in range(start, end, dim): + sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]) + j = i + + return sum + + +# return a percentage difference between the polygon area and its triangulation area +# used to verify correctness of triangulation +def deviation(data, hole_indices, dim, triangles): + has_holes = hole_indices and len(hole_indices) + outer_len = hole_indices[0] * dim if has_holes else len(data) + + polygon_area = abs(_signed_area(data, 0, outer_len, dim)) + if has_holes: + _len = len(hole_indices) + for i in range(_len): + start = hole_indices[i] * dim + end = hole_indices[i + 1] * dim if i < _len - 1 else len(data) + polygon_area -= abs(_signed_area(data, start, end, dim)) + + triangles_area = 0 + for i in range(0, len(triangles), 3): + a = triangles[i] * dim + b = triangles[i + 1] * dim + c = triangles[i + 2] * dim + triangles_area += abs( + (data[a] - data[c]) * (data[b + 1] - data[a + 1]) + - (data[a] - data[b]) * (data[c + 1] - data[a + 1]) + ) + + if polygon_area == 0 and triangles_area == 0: + return 0 + return abs((triangles_area - polygon_area) / polygon_area) + + +# turn a polygon in a multi-dimensional array form (e.g. as in GeoJSON) into a form Earcut accepts +def flatten(data): + dim = len(data[0][0]) + vertices = [] + holes = [] + hole_index = 0 + + for i in range(len(data)): + for j in range(len(data[i])): + for d in range(dim): + vertices.append(data[i][j][d]) + + if i > 0: + hole_index += len(data[i - 1]) + holes.append(hole_index) + + return (vertices, holes, dim) diff --git a/fastplotlib/utils/triangulation.py b/fastplotlib/utils/triangulation.py new file mode 100644 index 000000000..7abe089de --- /dev/null +++ b/fastplotlib/utils/triangulation.py @@ -0,0 +1,70 @@ +import logging + +import numpy as np +from .mapbox_earcut import earcut as mapbox_earcut + + +logger = logging.getLogger("fastplotlib") + + +# Note: the current triangulation is in pure Python. If the results or performance of the current implementation +# proves inadequate, we can have a look at Bermuda: https://github.com/napari/bermuda + + +def triangulate(positions, method="earcut"): + """Triangulate the given vertex positions. + + Returns an Nx3 integer array of faces that form a surface-mesh over the + given positions, where N is the length of the positions minus 2, + expressed in (local) vertex indices. The faces won't contain any + forbidden_edges. + """ + if len(positions) < 3: + return np.zeros((0,), np.int32) + if len(positions) == 3: + return np.array([0, 1, 2], np.int32) + + # Anticipating more variations ... + if method == "earcut": + method = "mapbox_earcut" + + if method == "naive": + faces = _triangulate_naive(positions) + elif method == "mapbox_earcut": + positions2d = positions[:, :2].flatten() + faces = mapbox_earcut(positions2d) + faces = np.array(faces, np.int32).reshape(-1, 3) + else: + raise ValueError(f"Invalid triangulation method: {method}") + + return faces + + +def _triangulate_naive(positions, forbidden_edges=None): + """This tesselation algorithm simply creates edges from one vertex to all the others.""" + + nverts = len(positions) + nfaces = nverts - 2 + forbidden_edges = forbidden_edges or [] + + # Determine a good point to be a reference + forbidden_start_points = set() + for i1, i2 in forbidden_edges: + forbidden_start_points.add(i1) + forbidden_start_points.add(i2) + for i in range(len(positions)): + if i not in forbidden_start_points: + start_point = i + break + else: + # In real meshes this cannot happen, but it can from the POV of this function's API + raise RuntimeError("Cannot tesselate.") + + # Collect the faces + faces = [] + i0 = start_point + for i in range(start_point, start_point + nfaces): + i1 = (i + 1) % nverts + i2 = (i + 2) % nverts + faces.append([i0, i1, i2]) + return np.array(faces, np.int32) diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py index c8ad67f39..393b13273 100644 --- a/fastplotlib/widgets/image_widget/_sliders.py +++ b/fastplotlib/widgets/image_widget/_sliders.py @@ -56,19 +56,15 @@ def update(self): flag_index_changed = False # reset vmin-vmax using full orig data - imgui.push_font(self._fa_icons) if imgui.button(label=fa.ICON_FA_CIRCLE_HALF_STROKE + fa.ICON_FA_FILM): self._image_widget.reset_vmin_vmax() - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("reset contrast limits using full movie/stack") # reset vmin-vmax using currently displayed ImageGraphic data - imgui.push_font(self._fa_icons) imgui.same_line() if imgui.button(label=fa.ICON_FA_CIRCLE_HALF_STROKE): self._image_widget.reset_vmin_vmax_frame() - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("reset contrast limits using current frame") @@ -78,7 +74,6 @@ def update(self): # buttons and slider UI elements for each dim for dim in self._image_widget.slider_dims: imgui.push_id(f"{self._id_counter}_{dim}") - imgui.push_font(self._fa_icons) if self._playing[dim]: # show pause button if playing @@ -119,7 +114,6 @@ def update(self): imgui.same_line() # loop checkbox _, self._loop = imgui.checkbox(label=fa.ICON_FA_ROTATE, v=self._loop) - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("loop playback") diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 650097951..715fe3489 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -562,7 +562,7 @@ def __init__( subplot.add_graphic(ig) if self._histogram_widget: - hlut = HistogramLUTTool(data=d, image_graphic=ig, name="histogram_lut") + hlut = HistogramLUTTool(data=d, images=ig, name="histogram_lut") subplot.docks["right"].add_graphic(hlut) subplot.docks["right"].size = 80 @@ -929,6 +929,11 @@ def set_data( for i, (new_array, current_array, subplot) in enumerate( zip(new_data, self._data, self.figure) ): + # if the new array is the same as the existing array, skip + # this allows setting just a subset of the arrays in the ImageWidget + if new_data is self._data[i]: + continue + # check last two dims (x and y) to see if data shape is changing old_data_shape = self._data[i].shape[-self.n_img_dims[i] :] self._data[i] = new_array @@ -944,7 +949,7 @@ def set_data( if self._histogram_widget: # set hlut tool to use new graphic - subplot.docks["right"]["histogram_lut"].image_graphic = new_graphic + subplot.docks["right"]["histogram_lut"].images = new_graphic # delete old graphic after setting hlut tool to new graphic # this ensures gc diff --git a/pyproject.toml b/pyproject.toml index cda3b65b1..9ec1d6ce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,8 @@ keywords = [ requires-python = ">= 3.10" dependencies = [ "numpy>=1.23.0", - "pygfx==0.12.0", - "wgpu>=0.20.0", + "pygfx==0.15", + "wgpu", # Let pygfx constrain the wgpu version "cmap>=0.1.3", # (this comment keeps this list multiline in VSCode) ] @@ -39,6 +39,7 @@ docs = [ "imageio[ffmpeg]", "matplotlib", "scikit-learn", + "ome-zarr", ] notebook = [ "jupyterlab", @@ -54,8 +55,9 @@ tests = [ "imageio[ffmpeg]", "scikit-learn", "tqdm", + "ome-zarr", ] -imgui = ["imgui-bundle>=1.6.0,<1.92.0"] +imgui = ["wgpu[imgui]"] dev = ["fastplotlib[docs,notebook,tests,imgui]"] [project.urls] diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 85e0be669..865eab27f 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -1,5 +1,6 @@ import inspect import pathlib +import re import black @@ -19,6 +20,8 @@ for name, obj in inspect.getmembers(graphics): if inspect.isclass(obj): + if obj.__name__ == "Graphic": + continue # skip the base class modules.append(obj) @@ -30,6 +33,7 @@ def generate_add_graphics_methods(): f.write("from typing import *\n\n") f.write("import numpy\n\n") + f.write("import pygfx\n\n") f.write("from ..graphics import *\n") f.write("from ..graphics._base import Graphic\n\n") @@ -50,10 +54,9 @@ def generate_add_graphics_methods(): for m in modules: cls = m - if cls.__name__ == "Graphic": - # skip base class - continue - method_name = cls.type + cls_name = cls.__name__.replace("Graphic", "") + # from https://stackoverflow.com/a/1176023 + method_name = re.sub(r"(? n_subplots with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4", "5", "6", "7"] - ) + fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "5", "6", "7"]) with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4", None, "6", "7"] - ) + fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", None, "6", "7"]) with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", None, "3", "4", None, "6", "7"] - ) + fpl.Figure(shape=(2, 3), names=["1", None, "3", "4", None, "6", "7"]) diff --git a/tests/test_image_volume_graphic.py b/tests/test_image_volume_graphic.py new file mode 100644 index 000000000..3cb574e78 --- /dev/null +++ b/tests/test_image_volume_graphic.py @@ -0,0 +1,190 @@ +import numpy as np +from numpy import testing as npt +import imageio.v3 as iio + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics.features import GraphicFeatureEvent +from fastplotlib.utils import make_colors + + +# load only first 128 planes because we set a limit for the tests +SIMPLE_IMAGE = iio.imread("imageio:stent.npz")[:128] + +EVENT_RETURN_VALUE: GraphicFeatureEvent = 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, GraphicFeatureEvent) + 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, + zpl_slice: slice, +): + image_graphic.data[row_slice, col_slice, zpl_slice] = 1 + data_values = image_graphic.data.value + npt.assert_almost_equal(data_values[row_slice, col_slice, zpl_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 :]) + npt.assert_almost_equal( + data_values[:, :, : zpl_slice.start], data[:, :, : zpl_slice.start] + ) + npt.assert_almost_equal( + data_values[:, :, zpl_slice.stop :], data[:, :, zpl_slice.stop :] + ) + + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent) + 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, zpl_slice) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], 1) + + +def test_gray(): + fig = fpl.Figure() + ig = fig[0, 0].add_image_volume(SIMPLE_IMAGE) + assert isinstance(ig, fpl.ImageVolumeGraphic) + + 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, SIMPLE_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, 0].data, SIMPLE_IMAGE) + + assert isinstance(ig._material, pygfx.VolumeMipMaterial) + assert isinstance(ig._material.map, pygfx.TextureMap) + assert isinstance(ig._material.map.texture, pygfx.Texture) + + 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.texture.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.texture.data, new_colors) + + assert ig.interpolation == "linear" + for child in ig.world_object.children: + assert child.material.interpolation == "linear" + + ig.interpolation = "nearest" + assert ig.interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.interpolation == "nearest" + check_event(graphic=ig, feature="interpolation", value="nearest") + + assert ig.cmap_interpolation == "linear" + for child in ig.world_object.children: + assert child.material.map.min_filter == "linear" + assert child.material.map.mag_filter == "linear" + + ig.cmap_interpolation = "nearest" + assert ig.cmap_interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.map.min_filter == "nearest" + assert child.material.map.mag_filter == "nearest" + + check_event(graphic=ig, feature="cmap_interpolation", value="nearest") + + # make sure they all use the same material + for child in ig.world_object.children: + assert ig._material is child.material + + # render modes + ig.mode = "mip" + assert isinstance(ig._material, pygfx.VolumeMipMaterial) + for child in ig.world_object.children: + assert ig._material is child.material + ig.mode = "minip" + assert isinstance(ig._material, pygfx.VolumeMinipMaterial) + for child in ig.world_object.children: + assert ig._material is child.material + ig.mode = "iso" + assert isinstance(ig._material, pygfx.VolumeIsoMaterial) + for child in ig.world_object.children: + assert ig._material is child.material + + ig.threshold = 50 + assert ig._material.threshold == 50 + ig.emissive = (1, 0, 0, 1) + assert tuple(ig._material.emissive) == (1.0, 0.0, 0.0, 1.0) + ig.shininess = 40 + assert ig._material.shininess == 40 + + ig.mode = "slice" + assert isinstance(ig._material, pygfx.VolumeSliceMaterial) + for child in ig.world_object.children: + assert ig._material is child.material + ig.plane = (0, 0.5, 0.5, -100) + npt.assert_almost_equal(ig._material.plane, np.array([0.0, 0.5, 0.5, -100.0])) + + npt.assert_almost_equal(ig.vmin, SIMPLE_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, SIMPLE_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, SIMPLE_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, SIMPLE_IMAGE.max()) + + check_set_slice(SIMPLE_IMAGE, ig, slice(50, 60), slice(20, 30), slice(80, 100)) + + # test setting all values + ig.data = 1 + npt.assert_almost_equal(ig.data.value, 1) diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py new file mode 100644 index 000000000..65ead392e --- /dev/null +++ b/tests/test_markers_buffer_manager.py @@ -0,0 +1,143 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import fastplotlib as fpl +import pygfx +from fastplotlib.graphics.features import GraphicFeatureEvent, VertexMarkers +from fastplotlib.graphics.features._scatter import marker_names, vectorized_user_markers_to_std_markers + +from .utils import ( + generate_slice_indices, + generate_positions_spiral_data, +) + + +EVENT_RETURN_VALUE: GraphicFeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +MARKERS1 = list("osD+x^v<>*") +MARKERS2 = list(">+vx*")) +def test_uniform_markers(marker): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter(data, markers=marker, uniform_marker=True) + + marker_full_name = marker_names.get(marker) + + assert isinstance(scatter.world_object.material, pygfx.PointsMarkerMaterial) + assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.uniform + assert isinstance(scatter._markers, UniformMarker) + + assert scatter.markers == marker_full_name + assert scatter.world_object.material.marker == marker_full_name + + # test changes and event + scatter.add_event_handler(event_handler, "markers") + scatter.markers = "o" + assert scatter.markers == pygfx.MarkerShape.circle + assert scatter.world_object.material.marker == pygfx.MarkerShape.circle + + check_event(scatter, "markers", pygfx.MarkerShape.circle) + + +@pytest.mark.parametrize("to_type", [list, tuple, np.array]) +@pytest.mark.parametrize("uniform_marker", [True, False]) +def test_incompatible_marker_args(to_type, uniform_marker): + markers = ["o"] * 3 + ["s"] * 3 + ["+"] * 3 + ["x"] + + markers = to_type(markers) + + data = generate_positions_spiral_data("xyz") + + fig = fpl.Figure() + + if uniform_marker: + with pytest.raises(TypeError): + scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=True) + + else: + scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=False) + assert isinstance(scatter._markers, VertexMarkers) + assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.vertex + + +def test_uniform_custom_sdf(): + lower_right_triangle_sdf = """ + // hardcode square root of 2 + let m_sqrt_2 = 1.4142135; + + // given a distance from an origin point, this defines the hypotenuse of a lower right triangle + let distance = (-coord.x + coord.y) / m_sqrt_2; + + // return distance for this position + return distance * size; + """ + + data = generate_positions_spiral_data("xyz") + + fig = fpl.Figure() + + scatter = fig[0, 0].add_scatter( + data, markers="custom", uniform_marker=True, custom_sdf=lower_right_triangle_sdf + ) + + assert scatter.markers == "custom" + assert scatter.world_object.material.marker == "custom" + assert scatter.world_object.material.custom_sdf == lower_right_triangle_sdf + +# test with both list[str] and 2D numpy array inputs as colors +@pytest.mark.parametrize("edge_colors",[generate_color_inputs("multi")[0], generate_color_inputs("multi")[1]]) +def test_edge_colors(edge_colors): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, + edge_colors=edge_colors, + uniform_edge_color=False, + ) + + assert isinstance(scatter._edge_colors, VertexColors) + + npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH) + + assert ( + scatter.edge_colors.buffer is scatter.world_object.geometry.edge_colors + ) + + # test changes, don't need to test extensively here since it's tested in the main VertexColors test + new_colors, array = generate_color_inputs("multi2") + scatter.edge_colors = new_colors + npt.assert_almost_equal(scatter.edge_colors.value, array) + + +@pytest.mark.parametrize("edge_color", ["r", (1, 0, 0), [1, 0, 0], np.array([1, 0, 0])]) +def test_uniform_edge_colors(edge_color): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, edge_colors=edge_color, uniform_edge_color=True + ) + + assert isinstance(scatter._edge_colors, UniformEdgeColor) + assert scatter.edge_colors == pygfx.Color(edge_color) + assert scatter.world_object.material.edge_color == pygfx.Color(edge_color) + + # test changes and event + scatter.add_event_handler(event_handler, "edge_colors") + scatter.edge_colors = "g" + + assert scatter.edge_colors == pygfx.Color("g") + assert scatter.world_object.material.edge_color == pygfx.Color("g") + + check_event(scatter, "edge_colors", pygfx.Color("g")) + + +@pytest.mark.parametrize("edge_colors", [generate_color_inputs("multi")[0],generate_color_inputs("multi")[1]]) +@pytest.mark.parametrize("uniform_edge_color", [False, True]) +def test_incompatible_edge_colors_args(edge_colors, uniform_edge_color): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + if uniform_edge_color: + with pytest.raises(TypeError): + scatter = fig[0, 0].add_scatter( + data=data, + edge_colors=edge_colors, + uniform_edge_color=uniform_edge_color, + ) + + +@pytest.mark.parametrize("edge_width", [0.0, 0.5, 1.0, 5.0]) +def test_edge_width(edge_width): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, + edge_width=edge_width, + ) + + assert isinstance(scatter._edge_width, EdgeWidth) + assert scatter.world_object.material.edge_width == edge_width + assert scatter.edge_width == edge_width + + # test changes and events + scatter.add_event_handler(event_handler, "edge_width") + scatter.edge_width = 2.0 + + npt.assert_almost_equal(scatter.edge_width, 2.0) + npt.assert_almost_equal(scatter.world_object.material.edge_width, 2.0) + + check_event(scatter, "edge_width", 2.0) + + +def test_uniform_rotation(): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, + point_rotations=np.pi / 2, + ) + + assert scatter.point_rotation_mode == "uniform" + npt.assert_almost_equal(scatter.point_rotations, np.pi / 2) + + # test changes and events + scatter.add_event_handler(event_handler, "point_rotations") + scatter.point_rotations = np.pi / 3 + + npt.assert_almost_equal(scatter.point_rotations, np.pi / 3) + + check_event(scatter, "point_rotations", np.pi / 3) + + +def test_sprite(): + image = np.array( + [ + [1, 0, 1], + [0, 1, 0], + [1, 1, 1], + ] + ) + + data = generate_positions_spiral_data("xyz") + + fig = fpl.Figure() + + scatter = fig[0, 0].add_scatter( + data=data, + mode="image", + image=image, + ) + + # make sure the image is a fpl TextureArray + assert isinstance(scatter.image, TextureArray) + # make sure the sprite is the TextureArray buffer, i.e. a pygfx.Texture + assert scatter.world_object.material.sprite is scatter.image.buffer[0, 0] + assert scatter.image.buffer.size == 1 + + npt.assert_almost_equal(scatter.image.value, image) + npt.assert_almost_equal(scatter.image.buffer[0, 0].data, image) + + # test changes and event + + image2 = np.array( + [ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0] + ] + ) + + scatter.add_event_handler(event_handler, "image") + + scatter.image = image2 + npt.assert_almost_equal(scatter.image.buffer[0, 0].data, image2) + + assert EVENT_RETURN_VALUE.graphic is scatter + assert EVENT_RETURN_VALUE.target is scatter.world_object + assert EVENT_RETURN_VALUE.type == "image" + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], image2) diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py index 2f55eab27..d1260e27c 100644 --- a/tests/test_sizes_buffer_manager.py +++ b/tests/test_sizes_buffer_manager.py @@ -2,7 +2,7 @@ from numpy import testing as npt import pytest -from fastplotlib.graphics.features import PointsSizesFeature +from fastplotlib.graphics.features import VertexPointSizes from .utils import generate_slice_indices @@ -28,7 +28,7 @@ def generate_data(input_type: str) -> np.ndarray | float: @pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]]) def test_create_buffer(data): - sizes = PointsSizesFeature(data, n_datapoints=10) + sizes = VertexPointSizes(data, n_datapoints=10) if isinstance(data, float): npt.assert_almost_equal(sizes[:], generate_data("float")) @@ -50,7 +50,7 @@ def test_slice(slice_method: dict, user_input: str): size = slice_method["size"] others = slice_method["others"] - sizes = PointsSizesFeature(data, n_datapoints=10) + sizes = VertexPointSizes(data, n_datapoints=10) match user_input: case "float": diff --git a/tests/test_texture_array_volume.py b/tests/test_texture_array_volume.py new file mode 100644 index 000000000..f2d28501b --- /dev/null +++ b/tests/test_texture_array_volume.py @@ -0,0 +1,206 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics.features import TextureArrayVolume +from fastplotlib.graphics.image_volume import _VolumeTile + + +MAX_TEXTURE_SIZE_3D = 128 + + +def make_data(z: int, 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, 100, n_cols) + + sine = np.sin(np.sqrt(xs)) + + data = np.dstack( + [ + np.column_stack([sine * i for i in range(n_rows)]).astype(np.float32) * j + for j in range(z) + ] + ) + + return data.T + + +def check_texture_array( + data: np.ndarray, + ta: TextureArrayVolume, + buffer_size: int, + buffer_shape: tuple[int, int, int], + zdim_indices_size: int, + row_indices_size: int, + col_indices_size: int, + zdim_indices_values: np.ndarray, + 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.zdim_indices.size == zdim_indices_size + assert ta.row_indices.size == row_indices_size + assert ta.col_indices.size == col_indices_size + + npt.assert_array_equal(ta.zdim_indices, zdim_indices_values) + 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_z, chunk_row, chunk_col = chunk_index + + data_z_start_index = chunk_z * MAX_TEXTURE_SIZE_3D + data_row_start_index = chunk_row * MAX_TEXTURE_SIZE_3D + data_col_start_index = chunk_col * MAX_TEXTURE_SIZE_3D + + data_z_stop_index = min(data.shape[0], data_z_start_index + MAX_TEXTURE_SIZE_3D) + + data_row_stop_index = min( + data.shape[1], data_row_start_index + MAX_TEXTURE_SIZE_3D + ) + data_col_stop_index = min( + data.shape[2], data_col_start_index + MAX_TEXTURE_SIZE_3D + ) + + zdim_slice = slice(data_z_start_index, data_z_stop_index) + 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 == (zdim_slice, row_slice, col_slice) + + +def check_set_slice(data, ta, zdim_slice, row_slice, col_slice): + ta[zdim_slice, row_slice, col_slice] = 1 + npt.assert_almost_equal(ta[zdim_slice, row_slice, col_slice], 1) + + # make sure other vals unchanged + npt.assert_almost_equal(ta[: zdim_slice.start], data[: zdim_slice.start]) + npt.assert_almost_equal(ta[zdim_slice.stop :], data[zdim_slice.stop :]) + + 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_volume_graphic(data) -> fpl.ImageVolumeGraphic: + fig = fpl.Figure(cameras="3d") + return fig[0, 0].add_image_volume(data, offset=(0, 0, 0)) + + +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, _VolumeTile) + assert img.geometry.grid is texture + assert img.world.z == data_slice[0].start + assert img.world.x == data_slice[2].start + assert img.world.y == data_slice[1].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(32, 64, 64) + + if test_graphic: + graphic = make_image_volume_graphic(data) + ta = graphic.data + else: + ta = TextureArrayVolume(data) + + check_texture_array( + data=data, + ta=ta, + buffer_size=1, + buffer_shape=(1, 1, 1), + zdim_indices_size=1, + row_indices_size=1, + col_indices_size=1, + zdim_indices_values=np.array([0]), + 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(5, 20), slice(10, 40), slice(20, 50)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_texture_at_limit(test_graphic): + # tests TextureArray with data that is 512 x 512 x 512 + data = make_data(MAX_TEXTURE_SIZE_3D, MAX_TEXTURE_SIZE_3D, MAX_TEXTURE_SIZE_3D) + + if test_graphic: + graphic = make_image_volume_graphic(data) + ta = graphic.data + else: + ta = TextureArrayVolume(data) + + check_texture_array( + data=data, + ta=ta, + buffer_size=1, + buffer_shape=(1, 1, 1), + zdim_indices_size=1, + row_indices_size=1, + col_indices_size=1, + zdim_indices_values=np.array([0]), + 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(5, 40), slice(10, 100), slice(20, 110)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_high_cols(test_graphic): + data = make_data(10, 100, 300) + + if test_graphic: + graphic = make_image_volume_graphic(data) + ta = graphic.data + else: + ta = TextureArrayVolume(data) + + check_texture_array( + data, + ta=ta, + buffer_size=3, + buffer_shape=(1, 1, 3), + zdim_indices_size=1, + row_indices_size=1, + col_indices_size=3, + zdim_indices_values=np.array([0]), + row_indices_values=np.array([0]), + col_indices_values=np.array([0, MAX_TEXTURE_SIZE_3D, 2 * MAX_TEXTURE_SIZE_3D]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(2, 7), slice(60, 90), slice(100, 180)) diff --git a/tests/utils.py b/tests/utils.py index bc9a092c8..6da080433 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -148,6 +148,23 @@ def generate_color_inputs( array = np.vstack([pygfx.Color(c) for c in s]) return [s, array] + if name == "multi2": + # a second multi option + s = [ + "g", + "r", + "cyan", + "magenta", + "b", + "black", + "white", + "purple", + "yellow", + "pink", + ] + array = np.vstack([pygfx.Color(c) for c in s]) + return [s, array] + color = pygfx.Color(name) s = name @@ -172,3 +189,37 @@ def generate_color_inputs( [1.0, 0.6470588445663452, 0.0, 1.0], ] ) + + +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, + ), +}