diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 470e2e5a5..f17941405 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -49,7 +49,7 @@ jobs: - name: build docs run: | cd docs - RTD_BUILD=1 make html SPHINXOPTS="-W --keep-going" + DOCS_BUILD=1 make html SPHINXOPTS="-W --keep-going" # set environment variable `DOCS_VERSION_DIR` to either the pr-branch name, "dev", or the release version tag - name: set output pr diff --git a/.gitignore b/.gitignore index 950f261c0..e6316728d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ dmypy.json # diffs from visual regression tests examples/desktop/diffs/*.png docs/source/_gallery/ +docs/source/_imgui_images/ diff --git a/README.md b/README.md index da5ed64f8..c8e64e65e 100644 --- a/README.md +++ b/README.md @@ -63,31 +63,28 @@ Questions, issues, ideas? You are welcome to post an [issue](https://github.com/ To install use pip: -```bash -# with imgui and jupyterlab -pip install -U "fastplotlib[notebook,imgui]" +### With imgui support (recommended) -# minimal install, install glfw, pyqt6 or pyside6 separately -pip install -U fastplotlib +Without jupyterlab support, install desired GUI framework such as glfw, PyQt6, or PySide6 separately. -# with imgui -pip install -U "fastplotlib[imgui]" + pip install -U "fastplotlib[imgui]" -# to use in jupyterlab without imgui -pip install -U "fastplotlib[notebook]" -``` +With jupyterlab support. -We strongly recommend installing ``simplejpeg`` for use in notebooks, you must first install [libjpeg-turbo](https://libjpeg-turbo.org/) + pip install -U "fastplotlib[notebook,imgui]" -- If you use ``conda``, you can get ``libjpeg-turbo`` through conda. -- If you are on linux, you can get it through your distro's package manager. -- For Windows and Mac compiled binaries are available on their release page: https://github.com/libjpeg-turbo/libjpeg-turbo/releases +### Without imgui -Once you have ``libjpeg-turbo``: +Minimal, install desired GUI library such as PyQt6, PySide6, or glfw separately. + + pip install fastplotlib + +With jupyterlab support only. + + pip install -U "fastplotlib[notebook]" + +Fastplotlib is also available on conda-forge. For imgui support you will need to separately install `imgui-bundle`, and for jupyterlab you will need to install `jupyter-rfb` and `simplejpeg` which are all available on conda-forge. -```bash -pip install simplejpeg -``` > **Note:** > `fastplotlib` and `pygfx` are fast evolving projects, the version available through pip might be outdated, you will need to follow the "For developers" instructions below if you want the latest features. You can find the release history here: https://github.com/fastplotlib/fastplotlib/releases diff --git a/docs/source/api/axes/Axes.rst b/docs/source/api/axes/Axes.rst new file mode 100644 index 000000000..7a98ce384 --- /dev/null +++ b/docs/source/api/axes/Axes.rst @@ -0,0 +1,44 @@ +.. _api.Axes: + +Axes +**** + +==== +Axes +==== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Axes_api + + Axes + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Axes_api + + Axes.auto_grid + Axes.basis + Axes.color + Axes.colors + Axes.grids + Axes.intersection + Axes.offset + Axes.visible + Axes.world_object + Axes.x + Axes.y + Axes.z + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Axes_api + + Axes.update + Axes.update_using_bbox + Axes.update_using_camera + diff --git a/docs/source/api/axes/Grid.rst b/docs/source/api/axes/Grid.rst new file mode 100644 index 000000000..e40ecb907 --- /dev/null +++ b/docs/source/api/axes/Grid.rst @@ -0,0 +1,66 @@ +.. _api.Grid: + +Grid +**** + +==== +Grid +==== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Grid_api + + Grid + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Grid_api + + Grid.axis_color + Grid.axis_thickness + Grid.cast_shadow + Grid.children + Grid.geometry + Grid.id + Grid.infinite + Grid.major_color + Grid.major_step + Grid.major_thickness + Grid.material + Grid.minor_color + Grid.minor_step + Grid.minor_thickness + Grid.parent + Grid.receive_shadow + Grid.render_mask + Grid.render_order + Grid.thickness_space + Grid.up + Grid.visible + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Grid_api + + Grid.add + Grid.add_event_handler + Grid.clear + Grid.get_bounding_box + Grid.get_bounding_sphere + Grid.get_geometry_bounding_box + Grid.get_world_bounding_box + Grid.get_world_bounding_sphere + Grid.handle_event + Grid.iter + Grid.look_at + Grid.release_pointer_capture + Grid.remove + Grid.remove_event_handler + Grid.set_pointer_capture + Grid.traverse + diff --git a/docs/source/api/axes/Grids.rst b/docs/source/api/axes/Grids.rst new file mode 100644 index 000000000..d6af4d408 --- /dev/null +++ b/docs/source/api/axes/Grids.rst @@ -0,0 +1,59 @@ +.. _api.Grids: + +Grids +***** + +===== +Grids +===== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Grids_api + + Grids + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Grids_api + + Grids.cast_shadow + Grids.children + Grids.geometry + Grids.id + Grids.material + Grids.parent + Grids.receive_shadow + Grids.render_mask + Grids.render_order + Grids.up + Grids.visible + Grids.xy + Grids.xz + Grids.yz + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Grids_api + + Grids.add + Grids.add_event_handler + Grids.clear + Grids.get_bounding_box + Grids.get_bounding_sphere + Grids.get_geometry_bounding_box + Grids.get_world_bounding_box + Grids.get_world_bounding_sphere + Grids.handle_event + Grids.iter + Grids.look_at + Grids.release_pointer_capture + Grids.remove + Grids.remove_event_handler + Grids.set_pointer_capture + Grids.traverse + diff --git a/docs/source/api/axes/Ruler.rst b/docs/source/api/axes/Ruler.rst new file mode 100644 index 000000000..e0641b821 --- /dev/null +++ b/docs/source/api/axes/Ruler.rst @@ -0,0 +1,74 @@ +.. _api.Ruler: + +Ruler +***** + +===== +Ruler +===== +.. currentmodule:: fastplotlib.axes + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Ruler_api + + Ruler + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Ruler_api + + Ruler.cast_shadow + Ruler.children + Ruler.color + Ruler.end_pos + Ruler.end_value + Ruler.geometry + Ruler.id + Ruler.label + Ruler.line + Ruler.line_width + Ruler.material + Ruler.min_tick_distance + Ruler.parent + Ruler.points + Ruler.receive_shadow + Ruler.render_mask + Ruler.render_order + Ruler.start_pos + Ruler.start_value + Ruler.text + Ruler.tick_format + Ruler.tick_marker + Ruler.tick_side + Ruler.tick_size + Ruler.ticks + Ruler.ticks_at_end_points + Ruler.up + Ruler.visible + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Ruler_api + + Ruler.add + Ruler.add_event_handler + Ruler.clear + Ruler.get_bounding_box + Ruler.get_bounding_sphere + Ruler.get_geometry_bounding_box + Ruler.get_world_bounding_box + Ruler.get_world_bounding_sphere + Ruler.handle_event + Ruler.iter + Ruler.look_at + Ruler.release_pointer_capture + Ruler.remove + Ruler.remove_event_handler + Ruler.set_pointer_capture + Ruler.traverse + Ruler.update + diff --git a/docs/source/api/axes/index.rst b/docs/source/api/axes/index.rst new file mode 100644 index 000000000..92703eff6 --- /dev/null +++ b/docs/source/api/axes/index.rst @@ -0,0 +1,10 @@ +Axes +**** + +.. toctree:: + :maxdepth: 1 + + Grid + Grids + Ruler + Axes diff --git a/docs/source/api/graphic_features/ImageGamma.rst b/docs/source/api/graphic_features/ImageGamma.rst new file mode 100644 index 000000000..d49347e87 --- /dev/null +++ b/docs/source/api/graphic_features/ImageGamma.rst @@ -0,0 +1,35 @@ +.. _api.ImageGamma: + +ImageGamma +********** + +========== +ImageGamma +========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageGamma_api + + ImageGamma.add_event_handler + ImageGamma.block_events + ImageGamma.clear_event_handlers + ImageGamma.remove_event_handler + ImageGamma.set_value + diff --git a/docs/source/api/graphic_features/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst index 004881282..e57431ca7 100644 --- a/docs/source/api/graphic_features/TextureArray.rst +++ b/docs/source/api/graphic_features/TextureArray.rst @@ -22,7 +22,10 @@ Properties TextureArray.buffer TextureArray.col_indices + TextureArray.colorspace + TextureArray.cpu_buffer TextureArray.row_indices + TextureArray.shape TextureArray.value Methods diff --git a/docs/source/api/graphic_features/TextureYUV.rst b/docs/source/api/graphic_features/TextureYUV.rst new file mode 100644 index 000000000..485de904b --- /dev/null +++ b/docs/source/api/graphic_features/TextureYUV.rst @@ -0,0 +1,39 @@ +.. _api.TextureYUV: + +TextureYUV +********** + +========== +TextureYUV +========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextureYUV_api + + TextureYUV + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextureYUV_api + + TextureYUV.colorrange + TextureYUV.colorspace + TextureYUV.cpu_buffer + TextureYUV.texture + TextureYUV.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextureYUV_api + + TextureYUV.add_event_handler + TextureYUV.block_events + TextureYUV.clear_event_handlers + TextureYUV.remove_event_handler + TextureYUV.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 71268ddab..b73f4f17c 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -22,7 +22,9 @@ Graphic Features VertexPointSizes UniformSize TextureArray + TextureYUV ImageCmap + ImageGamma ImageVmin ImageVmax ImageInterpolation diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index f94892949..c6f393507 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -24,11 +24,12 @@ Properties Graphic.alpha_mode Graphic.axes Graphic.block_events + Graphic.block_handlers Graphic.deleted Graphic.event_handlers + Graphic.imgui_right_click Graphic.name Graphic.offset - Graphic.right_click_menu Graphic.rotation Graphic.scale Graphic.supported_events @@ -43,10 +44,13 @@ Methods Graphic.add_axes Graphic.add_event_handler + Graphic.append_imgui_right_click Graphic.clear_event_handlers Graphic.format_pick_info Graphic.map_model_to_world Graphic.map_world_to_model Graphic.remove_event_handler + Graphic.remove_imgui_right_click Graphic.rotate + Graphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index e6d02c54b..6190343b8 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -24,15 +24,19 @@ Properties ImageGraphic.alpha_mode ImageGraphic.axes ImageGraphic.block_events + ImageGraphic.block_handlers ImageGraphic.cmap ImageGraphic.cmap_interpolation + ImageGraphic.colorspace + ImageGraphic.cpu_buffer ImageGraphic.data ImageGraphic.deleted ImageGraphic.event_handlers + ImageGraphic.gamma + ImageGraphic.imgui_right_click ImageGraphic.interpolation ImageGraphic.name ImageGraphic.offset - ImageGraphic.right_click_menu ImageGraphic.rotation ImageGraphic.scale ImageGraphic.supported_events @@ -53,11 +57,14 @@ Methods ImageGraphic.add_linear_selector ImageGraphic.add_polygon_selector ImageGraphic.add_rectangle_selector + ImageGraphic.append_imgui_right_click ImageGraphic.clear_event_handlers ImageGraphic.format_pick_info ImageGraphic.map_model_to_world ImageGraphic.map_world_to_model ImageGraphic.remove_event_handler + ImageGraphic.remove_imgui_right_click ImageGraphic.reset_vmin_vmax ImageGraphic.rotate + ImageGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst index 8031f12f1..b1f8a8dfb 100644 --- a/docs/source/api/graphics/ImageVolumeGraphic.rst +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -24,18 +24,20 @@ Properties ImageVolumeGraphic.alpha_mode ImageVolumeGraphic.axes ImageVolumeGraphic.block_events + ImageVolumeGraphic.block_handlers ImageVolumeGraphic.cmap ImageVolumeGraphic.cmap_interpolation ImageVolumeGraphic.data ImageVolumeGraphic.deleted ImageVolumeGraphic.emissive ImageVolumeGraphic.event_handlers + ImageVolumeGraphic.gamma + ImageVolumeGraphic.imgui_right_click ImageVolumeGraphic.interpolation ImageVolumeGraphic.mode ImageVolumeGraphic.name ImageVolumeGraphic.offset ImageVolumeGraphic.plane - ImageVolumeGraphic.right_click_menu ImageVolumeGraphic.rotation ImageVolumeGraphic.scale ImageVolumeGraphic.shininess @@ -56,11 +58,14 @@ Methods ImageVolumeGraphic.add_axes ImageVolumeGraphic.add_event_handler + ImageVolumeGraphic.append_imgui_right_click ImageVolumeGraphic.clear_event_handlers ImageVolumeGraphic.format_pick_info ImageVolumeGraphic.map_model_to_world ImageVolumeGraphic.map_world_to_model ImageVolumeGraphic.remove_event_handler + ImageVolumeGraphic.remove_imgui_right_click ImageVolumeGraphic.reset_vmin_vmax ImageVolumeGraphic.rotate + ImageVolumeGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ImageYUVGraphic.rst b/docs/source/api/graphics/ImageYUVGraphic.rst new file mode 100644 index 000000000..6db01387c --- /dev/null +++ b/docs/source/api/graphics/ImageYUVGraphic.rst @@ -0,0 +1,71 @@ +.. _api.ImageYUVGraphic: + +ImageYUVGraphic +*************** + +=============== +ImageYUVGraphic +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageYUVGraphic_api + + ImageYUVGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageYUVGraphic_api + + ImageYUVGraphic.alpha + ImageYUVGraphic.alpha_mode + ImageYUVGraphic.axes + ImageYUVGraphic.block_events + ImageYUVGraphic.block_handlers + ImageYUVGraphic.cmap + ImageYUVGraphic.cmap_interpolation + ImageYUVGraphic.colorrange + ImageYUVGraphic.colorspace + ImageYUVGraphic.cpu_buffer + ImageYUVGraphic.data + ImageYUVGraphic.deleted + ImageYUVGraphic.event_handlers + ImageYUVGraphic.gamma + ImageYUVGraphic.imgui_right_click + ImageYUVGraphic.interpolation + ImageYUVGraphic.name + ImageYUVGraphic.offset + ImageYUVGraphic.rotation + ImageYUVGraphic.scale + ImageYUVGraphic.supported_events + ImageYUVGraphic.tooltip_format + ImageYUVGraphic.visible + ImageYUVGraphic.vmax + ImageYUVGraphic.vmin + ImageYUVGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageYUVGraphic_api + + ImageYUVGraphic.add_axes + ImageYUVGraphic.add_event_handler + ImageYUVGraphic.add_linear_region_selector + ImageYUVGraphic.add_linear_selector + ImageYUVGraphic.add_polygon_selector + ImageYUVGraphic.add_rectangle_selector + ImageYUVGraphic.append_imgui_right_click + ImageYUVGraphic.clear_event_handlers + ImageYUVGraphic.format_pick_info + ImageYUVGraphic.map_model_to_world + ImageYUVGraphic.map_world_to_model + ImageYUVGraphic.remove_event_handler + ImageYUVGraphic.remove_imgui_right_click + ImageYUVGraphic.reset_vmin_vmax + ImageYUVGraphic.rotate + ImageYUVGraphic.set_imgui_right_click + diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index 5d0603ab7..c9f145d38 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -24,18 +24,19 @@ Properties LineCollection.alpha_mode LineCollection.axes LineCollection.block_events + LineCollection.block_handlers LineCollection.cmap LineCollection.colors LineCollection.data LineCollection.deleted LineCollection.event_handlers LineCollection.graphics + LineCollection.imgui_right_click LineCollection.metadatas LineCollection.name LineCollection.names LineCollection.offset LineCollection.offsets - LineCollection.right_click_menu LineCollection.rotation LineCollection.rotations LineCollection.scale @@ -58,11 +59,14 @@ Methods LineCollection.add_linear_selector LineCollection.add_polygon_selector LineCollection.add_rectangle_selector + LineCollection.append_imgui_right_click LineCollection.clear_event_handlers LineCollection.format_pick_info LineCollection.map_model_to_world LineCollection.map_world_to_model LineCollection.remove_event_handler LineCollection.remove_graphic + LineCollection.remove_imgui_right_click LineCollection.rotate + LineCollection.set_imgui_right_click diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 428e8ef56..4faf77c5c 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -24,14 +24,16 @@ Properties LineGraphic.alpha_mode LineGraphic.axes LineGraphic.block_events + LineGraphic.block_handlers LineGraphic.cmap + LineGraphic.color_mode LineGraphic.colors LineGraphic.data LineGraphic.deleted LineGraphic.event_handlers + LineGraphic.imgui_right_click LineGraphic.name LineGraphic.offset - LineGraphic.right_click_menu LineGraphic.rotation LineGraphic.scale LineGraphic.size_space @@ -52,10 +54,13 @@ Methods LineGraphic.add_linear_selector LineGraphic.add_polygon_selector LineGraphic.add_rectangle_selector + LineGraphic.append_imgui_right_click LineGraphic.clear_event_handlers LineGraphic.format_pick_info LineGraphic.map_model_to_world LineGraphic.map_world_to_model LineGraphic.remove_event_handler + LineGraphic.remove_imgui_right_click LineGraphic.rotate + LineGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index e7ac21343..f2a3f9958 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -24,18 +24,19 @@ Properties LineStack.alpha_mode LineStack.axes LineStack.block_events + LineStack.block_handlers LineStack.cmap LineStack.colors LineStack.data LineStack.deleted LineStack.event_handlers LineStack.graphics + LineStack.imgui_right_click LineStack.metadatas LineStack.name LineStack.names LineStack.offset LineStack.offsets - LineStack.right_click_menu LineStack.rotation LineStack.rotations LineStack.scale @@ -58,11 +59,14 @@ Methods LineStack.add_linear_selector LineStack.add_polygon_selector LineStack.add_rectangle_selector + LineStack.append_imgui_right_click LineStack.clear_event_handlers LineStack.format_pick_info LineStack.map_model_to_world LineStack.map_world_to_model LineStack.remove_event_handler LineStack.remove_graphic + LineStack.remove_imgui_right_click LineStack.rotate + LineStack.set_imgui_right_click diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst index ec27f1e4e..4ed70bf37 100644 --- a/docs/source/api/graphics/MeshGraphic.rst +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -24,11 +24,13 @@ Properties MeshGraphic.alpha_mode MeshGraphic.axes MeshGraphic.block_events + MeshGraphic.block_handlers MeshGraphic.clim MeshGraphic.cmap MeshGraphic.colors MeshGraphic.deleted MeshGraphic.event_handlers + MeshGraphic.imgui_right_click MeshGraphic.indices MeshGraphic.mapcoords MeshGraphic.mode @@ -36,7 +38,6 @@ Properties MeshGraphic.offset MeshGraphic.plane MeshGraphic.positions - MeshGraphic.right_click_menu MeshGraphic.rotation MeshGraphic.scale MeshGraphic.supported_events @@ -51,10 +52,13 @@ Methods MeshGraphic.add_axes MeshGraphic.add_event_handler + MeshGraphic.append_imgui_right_click MeshGraphic.clear_event_handlers MeshGraphic.format_pick_info MeshGraphic.map_model_to_world MeshGraphic.map_world_to_model MeshGraphic.remove_event_handler + MeshGraphic.remove_imgui_right_click MeshGraphic.rotate + MeshGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst index 94c75f999..9045a3e10 100644 --- a/docs/source/api/graphics/PolygonGraphic.rst +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -24,12 +24,14 @@ Properties PolygonGraphic.alpha_mode PolygonGraphic.axes PolygonGraphic.block_events + PolygonGraphic.block_handlers PolygonGraphic.clim PolygonGraphic.cmap PolygonGraphic.colors PolygonGraphic.data PolygonGraphic.deleted PolygonGraphic.event_handlers + PolygonGraphic.imgui_right_click PolygonGraphic.indices PolygonGraphic.mapcoords PolygonGraphic.mode @@ -37,7 +39,6 @@ Properties PolygonGraphic.offset PolygonGraphic.plane PolygonGraphic.positions - PolygonGraphic.right_click_menu PolygonGraphic.rotation PolygonGraphic.scale PolygonGraphic.supported_events @@ -52,10 +53,13 @@ Methods PolygonGraphic.add_axes PolygonGraphic.add_event_handler + PolygonGraphic.append_imgui_right_click PolygonGraphic.clear_event_handlers PolygonGraphic.format_pick_info PolygonGraphic.map_model_to_world PolygonGraphic.map_world_to_model PolygonGraphic.remove_event_handler + PolygonGraphic.remove_imgui_right_click PolygonGraphic.rotate + PolygonGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterCollection.rst b/docs/source/api/graphics/ScatterCollection.rst new file mode 100644 index 000000000..f71116948 --- /dev/null +++ b/docs/source/api/graphics/ScatterCollection.rst @@ -0,0 +1,73 @@ +.. _api.ScatterCollection: + +ScatterCollection +***************** + +================= +ScatterCollection +================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterCollection_api + + ScatterCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterCollection_api + + ScatterCollection.alpha + ScatterCollection.alpha_mode + ScatterCollection.axes + ScatterCollection.block_events + ScatterCollection.block_handlers + ScatterCollection.cmap + ScatterCollection.colors + ScatterCollection.data + ScatterCollection.deleted + ScatterCollection.event_handlers + ScatterCollection.graphics + ScatterCollection.imgui_right_click + ScatterCollection.markers + ScatterCollection.metadatas + ScatterCollection.name + ScatterCollection.names + ScatterCollection.offset + ScatterCollection.offsets + ScatterCollection.rotation + ScatterCollection.rotations + ScatterCollection.scale + ScatterCollection.sizes + ScatterCollection.supported_events + ScatterCollection.tooltip_format + ScatterCollection.visible + ScatterCollection.visibles + ScatterCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ScatterCollection_api + + ScatterCollection.add_axes + ScatterCollection.add_event_handler + ScatterCollection.add_graphic + ScatterCollection.add_linear_region_selector + ScatterCollection.add_linear_selector + ScatterCollection.add_polygon_selector + ScatterCollection.add_rectangle_selector + ScatterCollection.append_imgui_right_click + ScatterCollection.clear_event_handlers + ScatterCollection.format_pick_info + ScatterCollection.map_model_to_world + ScatterCollection.map_world_to_model + ScatterCollection.remove_event_handler + ScatterCollection.remove_graphic + ScatterCollection.remove_imgui_right_click + ScatterCollection.rotate + ScatterCollection.set_imgui_right_click + diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index cf8e1224d..c9f988820 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -24,7 +24,9 @@ Properties ScatterGraphic.alpha_mode ScatterGraphic.axes ScatterGraphic.block_events + ScatterGraphic.block_handlers ScatterGraphic.cmap + ScatterGraphic.color_mode ScatterGraphic.colors ScatterGraphic.data ScatterGraphic.deleted @@ -32,13 +34,13 @@ Properties ScatterGraphic.edge_width ScatterGraphic.event_handlers ScatterGraphic.image + ScatterGraphic.imgui_right_click ScatterGraphic.markers ScatterGraphic.mode ScatterGraphic.name ScatterGraphic.offset ScatterGraphic.point_rotation_mode ScatterGraphic.point_rotations - ScatterGraphic.right_click_menu ScatterGraphic.rotation ScatterGraphic.scale ScatterGraphic.size_space @@ -55,10 +57,13 @@ Methods ScatterGraphic.add_axes ScatterGraphic.add_event_handler + ScatterGraphic.append_imgui_right_click ScatterGraphic.clear_event_handlers ScatterGraphic.format_pick_info ScatterGraphic.map_model_to_world ScatterGraphic.map_world_to_model ScatterGraphic.remove_event_handler + ScatterGraphic.remove_imgui_right_click ScatterGraphic.rotate + ScatterGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/ScatterStack.rst b/docs/source/api/graphics/ScatterStack.rst new file mode 100644 index 000000000..ee0d7d679 --- /dev/null +++ b/docs/source/api/graphics/ScatterStack.rst @@ -0,0 +1,75 @@ +.. _api.ScatterStack: + +ScatterStack +************ + +============ +ScatterStack +============ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterStack_api + + ScatterStack + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ScatterStack_api + + ScatterStack.alpha + ScatterStack.alpha_mode + ScatterStack.axes + ScatterStack.block_events + ScatterStack.block_handlers + ScatterStack.cmap + ScatterStack.colors + ScatterStack.data + ScatterStack.deleted + ScatterStack.event_handlers + ScatterStack.graphics + ScatterStack.imgui_right_click + ScatterStack.markers + ScatterStack.metadatas + ScatterStack.name + ScatterStack.names + ScatterStack.offset + ScatterStack.offsets + ScatterStack.rotation + ScatterStack.rotations + ScatterStack.scale + ScatterStack.separation + ScatterStack.separation_axis + ScatterStack.sizes + ScatterStack.supported_events + ScatterStack.tooltip_format + ScatterStack.visible + ScatterStack.visibles + ScatterStack.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ScatterStack_api + + ScatterStack.add_axes + ScatterStack.add_event_handler + ScatterStack.add_graphic + ScatterStack.add_linear_region_selector + ScatterStack.add_linear_selector + ScatterStack.add_polygon_selector + ScatterStack.add_rectangle_selector + ScatterStack.append_imgui_right_click + ScatterStack.clear_event_handlers + ScatterStack.format_pick_info + ScatterStack.map_model_to_world + ScatterStack.map_world_to_model + ScatterStack.remove_event_handler + ScatterStack.remove_graphic + ScatterStack.remove_imgui_right_click + ScatterStack.rotate + ScatterStack.set_imgui_right_click + diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst index 228dbede1..a1088fa81 100644 --- a/docs/source/api/graphics/SurfaceGraphic.rst +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -24,12 +24,14 @@ Properties SurfaceGraphic.alpha_mode SurfaceGraphic.axes SurfaceGraphic.block_events + SurfaceGraphic.block_handlers SurfaceGraphic.clim SurfaceGraphic.cmap SurfaceGraphic.colors SurfaceGraphic.data SurfaceGraphic.deleted SurfaceGraphic.event_handlers + SurfaceGraphic.imgui_right_click SurfaceGraphic.indices SurfaceGraphic.mapcoords SurfaceGraphic.mode @@ -37,7 +39,6 @@ Properties SurfaceGraphic.offset SurfaceGraphic.plane SurfaceGraphic.positions - SurfaceGraphic.right_click_menu SurfaceGraphic.rotation SurfaceGraphic.scale SurfaceGraphic.supported_events @@ -52,10 +53,13 @@ Methods SurfaceGraphic.add_axes SurfaceGraphic.add_event_handler + SurfaceGraphic.append_imgui_right_click SurfaceGraphic.clear_event_handlers SurfaceGraphic.format_pick_info SurfaceGraphic.map_model_to_world SurfaceGraphic.map_world_to_model SurfaceGraphic.remove_event_handler + SurfaceGraphic.remove_imgui_right_click SurfaceGraphic.rotate + SurfaceGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index da4909686..2260306a7 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -24,15 +24,16 @@ Properties TextGraphic.alpha_mode TextGraphic.axes TextGraphic.block_events + TextGraphic.block_handlers TextGraphic.deleted TextGraphic.event_handlers TextGraphic.face_color TextGraphic.font_size + TextGraphic.imgui_right_click TextGraphic.name TextGraphic.offset TextGraphic.outline_color TextGraphic.outline_thickness - TextGraphic.right_click_menu TextGraphic.rotation TextGraphic.scale TextGraphic.supported_events @@ -48,10 +49,13 @@ Methods TextGraphic.add_axes TextGraphic.add_event_handler + TextGraphic.append_imgui_right_click TextGraphic.clear_event_handlers TextGraphic.format_pick_info TextGraphic.map_model_to_world TextGraphic.map_world_to_model TextGraphic.remove_event_handler + TextGraphic.remove_imgui_right_click TextGraphic.rotate + TextGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst index ec7d891c0..353e42ada 100644 --- a/docs/source/api/graphics/VectorsGraphic.rst +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -24,13 +24,14 @@ Properties VectorsGraphic.alpha_mode VectorsGraphic.axes VectorsGraphic.block_events + VectorsGraphic.block_handlers VectorsGraphic.deleted VectorsGraphic.directions VectorsGraphic.event_handlers + VectorsGraphic.imgui_right_click VectorsGraphic.name VectorsGraphic.offset VectorsGraphic.positions - VectorsGraphic.right_click_menu VectorsGraphic.rotation VectorsGraphic.scale VectorsGraphic.supported_events @@ -45,10 +46,13 @@ Methods VectorsGraphic.add_axes VectorsGraphic.add_event_handler + VectorsGraphic.append_imgui_right_click VectorsGraphic.clear_event_handlers VectorsGraphic.format_pick_info VectorsGraphic.map_model_to_world VectorsGraphic.map_world_to_model VectorsGraphic.remove_event_handler + VectorsGraphic.remove_imgui_right_click VectorsGraphic.rotate + VectorsGraphic.set_imgui_right_click diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index bac85e6c1..6253b68a7 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -8,6 +8,7 @@ Graphics LineGraphic ScatterGraphic ImageGraphic + ImageYUVGraphic ImageVolumeGraphic VectorsGraphic MeshGraphic @@ -16,3 +17,5 @@ Graphics TextGraphic LineCollection LineStack + ScatterCollection + ScatterStack diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index 54e91b24f..ee7f16eb0 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -42,7 +42,6 @@ Methods Figure.export Figure.export_numpy Figure.get_pygfx_render_area - Figure.open_popup Figure.remove_animation Figure.remove_subplot Figure.show diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index 46e0c6ed3..ace763861 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -25,8 +25,9 @@ Properties ImguiFigure.canvas ImguiFigure.controllers ImguiFigure.default_imgui_font - ImguiFigure.guis ImguiFigure.imgui_renderer + ImguiFigure.imgui_right_click + ImguiFigure.imgui_windows ImguiFigure.layout ImguiFigure.names ImguiFigure.renderer @@ -38,17 +39,20 @@ Methods :toctree: ImguiFigure_api ImguiFigure.add_animations - ImguiFigure.add_gui + ImguiFigure.add_imgui_window ImguiFigure.add_subplot + ImguiFigure.append_imgui_right_click + ImguiFigure.append_imgui_window ImguiFigure.clear ImguiFigure.clear_animations ImguiFigure.close ImguiFigure.export ImguiFigure.export_numpy ImguiFigure.get_pygfx_render_area - ImguiFigure.open_popup - ImguiFigure.register_popup ImguiFigure.remove_animation + ImguiFigure.remove_imgui_right_click + ImguiFigure.remove_imgui_window ImguiFigure.remove_subplot + ImguiFigure.set_imgui_right_click ImguiFigure.show diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 0916859b9..09bd14e39 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -31,6 +31,8 @@ Properties Subplot.docks Subplot.frame Subplot.graphics + Subplot.imgui_right_click + Subplot.imgui_windows Subplot.legends Subplot.name Subplot.objects @@ -42,6 +44,8 @@ Properties Subplot.toolbar Subplot.tooltip Subplot.viewport + Subplot.x_range + Subplot.y_range Methods ~~~~~~~ @@ -52,15 +56,21 @@ Methods Subplot.add_graphic Subplot.add_image Subplot.add_image_volume + Subplot.add_image_yuv + Subplot.add_imgui_window Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack Subplot.add_mesh Subplot.add_polygon Subplot.add_scatter + Subplot.add_scatter_collection + Subplot.add_scatter_stack Subplot.add_surface Subplot.add_text Subplot.add_vectors + Subplot.append_imgui_right_click + Subplot.append_imgui_window Subplot.auto_scale Subplot.center_graphic Subplot.center_scene @@ -74,4 +84,7 @@ Methods Subplot.map_world_to_screen Subplot.remove_animation Subplot.remove_graphic + Subplot.remove_imgui_right_click + Subplot.remove_imgui_window + Subplot.set_imgui_right_click diff --git a/docs/source/api/selectors/CollectionHighlightSelector.rst b/docs/source/api/selectors/CollectionHighlightSelector.rst new file mode 100644 index 000000000..4ebd29fce --- /dev/null +++ b/docs/source/api/selectors/CollectionHighlightSelector.rst @@ -0,0 +1,42 @@ +.. _api.CollectionHighlightSelector: + +CollectionHighlightSelector +*************************** + +=========================== +CollectionHighlightSelector +=========================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: CollectionHighlightSelector_api + + CollectionHighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: CollectionHighlightSelector_api + + CollectionHighlightSelector.alpha + CollectionHighlightSelector.color + CollectionHighlightSelector.graphics + CollectionHighlightSelector.lut + CollectionHighlightSelector.lut_wrap + CollectionHighlightSelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: CollectionHighlightSelector_api + + CollectionHighlightSelector.add_event_handler + CollectionHighlightSelector.add_graphic + CollectionHighlightSelector.append + CollectionHighlightSelector.clear + CollectionHighlightSelector.remove + CollectionHighlightSelector.remove_event_handler + CollectionHighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/HighlightSelector.rst b/docs/source/api/selectors/HighlightSelector.rst new file mode 100644 index 000000000..82b09e86c --- /dev/null +++ b/docs/source/api/selectors/HighlightSelector.rst @@ -0,0 +1,42 @@ +.. _api.HighlightSelector: + +HighlightSelector +***************** + +================= +HighlightSelector +================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: HighlightSelector_api + + HighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: HighlightSelector_api + + HighlightSelector.alpha + HighlightSelector.color + HighlightSelector.graphics + HighlightSelector.lut + HighlightSelector.lut_wrap + HighlightSelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: HighlightSelector_api + + HighlightSelector.add_event_handler + HighlightSelector.add_graphic + HighlightSelector.append + HighlightSelector.clear + HighlightSelector.remove + HighlightSelector.remove_event_handler + HighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/ImageHighlightSelector.rst b/docs/source/api/selectors/ImageHighlightSelector.rst new file mode 100644 index 000000000..2c2a0a23e --- /dev/null +++ b/docs/source/api/selectors/ImageHighlightSelector.rst @@ -0,0 +1,45 @@ +.. _api.ImageHighlightSelector: + +ImageHighlightSelector +********************** + +====================== +ImageHighlightSelector +====================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageHighlightSelector_api + + ImageHighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageHighlightSelector_api + + ImageHighlightSelector.alpha + ImageHighlightSelector.color + ImageHighlightSelector.graphics + ImageHighlightSelector.lut + ImageHighlightSelector.lut_wrap + ImageHighlightSelector.options_alpha + ImageHighlightSelector.options_color + ImageHighlightSelector.selection + ImageHighlightSelector.selection_options + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageHighlightSelector_api + + ImageHighlightSelector.add_event_handler + ImageHighlightSelector.add_graphic + ImageHighlightSelector.append + ImageHighlightSelector.clear + ImageHighlightSelector.remove + ImageHighlightSelector.remove_event_handler + ImageHighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/ImageVisibilitySelector.rst b/docs/source/api/selectors/ImageVisibilitySelector.rst new file mode 100644 index 000000000..89f59a701 --- /dev/null +++ b/docs/source/api/selectors/ImageVisibilitySelector.rst @@ -0,0 +1,37 @@ +.. _api.ImageVisibilitySelector: + +ImageVisibilitySelector +*********************** + +======================= +ImageVisibilitySelector +======================= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVisibilitySelector_api + + ImageVisibilitySelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVisibilitySelector_api + + ImageVisibilitySelector.axis + ImageVisibilitySelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVisibilitySelector_api + + ImageVisibilitySelector.add_event_handler + ImageVisibilitySelector.append + ImageVisibilitySelector.clear + ImageVisibilitySelector.pop + ImageVisibilitySelector.remove + ImageVisibilitySelector.remove_event_handler + diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index eb48497cd..2b781a886 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -25,15 +25,16 @@ Properties LinearRegionSelector.axes LinearRegionSelector.axis LinearRegionSelector.block_events + LinearRegionSelector.block_handlers LinearRegionSelector.deleted LinearRegionSelector.edge_color LinearRegionSelector.event_handlers LinearRegionSelector.fill_color + LinearRegionSelector.imgui_right_click LinearRegionSelector.limits LinearRegionSelector.name LinearRegionSelector.offset LinearRegionSelector.parent - LinearRegionSelector.right_click_menu LinearRegionSelector.rotation LinearRegionSelector.scale LinearRegionSelector.selection @@ -50,6 +51,7 @@ Methods LinearRegionSelector.add_axes LinearRegionSelector.add_event_handler + LinearRegionSelector.append_imgui_right_click LinearRegionSelector.clear_event_handlers LinearRegionSelector.format_pick_info LinearRegionSelector.get_selected_data @@ -58,5 +60,7 @@ Methods LinearRegionSelector.map_model_to_world LinearRegionSelector.map_world_to_model LinearRegionSelector.remove_event_handler + LinearRegionSelector.remove_imgui_right_click LinearRegionSelector.rotate + LinearRegionSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearRegionSelectors.rst b/docs/source/api/selectors/LinearRegionSelectors.rst new file mode 100644 index 000000000..867e43b9f --- /dev/null +++ b/docs/source/api/selectors/LinearRegionSelectors.rst @@ -0,0 +1,60 @@ +.. _api.LinearRegionSelectors: + +LinearRegionSelectors +********************* + +===================== +LinearRegionSelectors +===================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectors_api + + LinearRegionSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectors_api + + LinearRegionSelectors.alpha + LinearRegionSelectors.alpha_mode + LinearRegionSelectors.axes + LinearRegionSelectors.block_events + LinearRegionSelectors.block_handlers + LinearRegionSelectors.deleted + LinearRegionSelectors.event_handlers + LinearRegionSelectors.imgui_right_click + LinearRegionSelectors.name + LinearRegionSelectors.offset + LinearRegionSelectors.rotation + LinearRegionSelectors.scale + LinearRegionSelectors.selection + LinearRegionSelectors.supported_events + LinearRegionSelectors.tooltip_format + LinearRegionSelectors.visible + LinearRegionSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearRegionSelectors_api + + LinearRegionSelectors.add_axes + LinearRegionSelectors.add_event_handler + LinearRegionSelectors.append + LinearRegionSelectors.append_imgui_right_click + LinearRegionSelectors.clear + LinearRegionSelectors.clear_event_handlers + LinearRegionSelectors.format_pick_info + LinearRegionSelectors.map_model_to_world + LinearRegionSelectors.map_world_to_model + LinearRegionSelectors.remove + LinearRegionSelectors.remove_event_handler + LinearRegionSelectors.remove_imgui_right_click + LinearRegionSelectors.rotate + LinearRegionSelectors.set_imgui_right_click + diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index 2aa334748..eef5a5175 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -25,15 +25,16 @@ Properties LinearSelector.axes LinearSelector.axis LinearSelector.block_events + LinearSelector.block_handlers LinearSelector.deleted LinearSelector.edge_color LinearSelector.event_handlers LinearSelector.fill_color + LinearSelector.imgui_right_click LinearSelector.limits LinearSelector.name LinearSelector.offset LinearSelector.parent - LinearSelector.right_click_menu LinearSelector.rotation LinearSelector.scale LinearSelector.selection @@ -50,6 +51,7 @@ Methods LinearSelector.add_axes LinearSelector.add_event_handler + LinearSelector.append_imgui_right_click LinearSelector.clear_event_handlers LinearSelector.format_pick_info LinearSelector.get_selected_data @@ -58,5 +60,7 @@ Methods LinearSelector.map_model_to_world LinearSelector.map_world_to_model LinearSelector.remove_event_handler + LinearSelector.remove_imgui_right_click LinearSelector.rotate + LinearSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/LinearSelectors.rst b/docs/source/api/selectors/LinearSelectors.rst new file mode 100644 index 000000000..f01de0e7c --- /dev/null +++ b/docs/source/api/selectors/LinearSelectors.rst @@ -0,0 +1,60 @@ +.. _api.LinearSelectors: + +LinearSelectors +*************** + +=============== +LinearSelectors +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelectors_api + + LinearSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: LinearSelectors_api + + LinearSelectors.alpha + LinearSelectors.alpha_mode + LinearSelectors.axes + LinearSelectors.block_events + LinearSelectors.block_handlers + LinearSelectors.deleted + LinearSelectors.event_handlers + LinearSelectors.imgui_right_click + LinearSelectors.name + LinearSelectors.offset + LinearSelectors.rotation + LinearSelectors.scale + LinearSelectors.selection + LinearSelectors.supported_events + LinearSelectors.tooltip_format + LinearSelectors.visible + LinearSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: LinearSelectors_api + + LinearSelectors.add_axes + LinearSelectors.add_event_handler + LinearSelectors.append + LinearSelectors.append_imgui_right_click + LinearSelectors.clear + LinearSelectors.clear_event_handlers + LinearSelectors.format_pick_info + LinearSelectors.map_model_to_world + LinearSelectors.map_world_to_model + LinearSelectors.remove + LinearSelectors.remove_event_handler + LinearSelectors.remove_imgui_right_click + LinearSelectors.rotate + LinearSelectors.set_imgui_right_click + diff --git a/docs/source/api/selectors/PolygonSelectors.rst b/docs/source/api/selectors/PolygonSelectors.rst new file mode 100644 index 000000000..f0855e78a --- /dev/null +++ b/docs/source/api/selectors/PolygonSelectors.rst @@ -0,0 +1,60 @@ +.. _api.PolygonSelectors: + +PolygonSelectors +**************** + +================ +PolygonSelectors +================ +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonSelectors_api + + PolygonSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonSelectors_api + + PolygonSelectors.alpha + PolygonSelectors.alpha_mode + PolygonSelectors.axes + PolygonSelectors.block_events + PolygonSelectors.block_handlers + PolygonSelectors.deleted + PolygonSelectors.event_handlers + PolygonSelectors.imgui_right_click + PolygonSelectors.name + PolygonSelectors.offset + PolygonSelectors.rotation + PolygonSelectors.scale + PolygonSelectors.selection + PolygonSelectors.supported_events + PolygonSelectors.tooltip_format + PolygonSelectors.visible + PolygonSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PolygonSelectors_api + + PolygonSelectors.add_axes + PolygonSelectors.add_event_handler + PolygonSelectors.append + PolygonSelectors.append_imgui_right_click + PolygonSelectors.clear + PolygonSelectors.clear_event_handlers + PolygonSelectors.format_pick_info + PolygonSelectors.map_model_to_world + PolygonSelectors.map_world_to_model + PolygonSelectors.remove + PolygonSelectors.remove_event_handler + PolygonSelectors.remove_imgui_right_click + PolygonSelectors.rotate + PolygonSelectors.set_imgui_right_click + diff --git a/docs/source/api/selectors/PositionsHighlightSelector.rst b/docs/source/api/selectors/PositionsHighlightSelector.rst new file mode 100644 index 000000000..6c2722b60 --- /dev/null +++ b/docs/source/api/selectors/PositionsHighlightSelector.rst @@ -0,0 +1,42 @@ +.. _api.PositionsHighlightSelector: + +PositionsHighlightSelector +************************** + +========================== +PositionsHighlightSelector +========================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PositionsHighlightSelector_api + + PositionsHighlightSelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PositionsHighlightSelector_api + + PositionsHighlightSelector.alpha + PositionsHighlightSelector.color + PositionsHighlightSelector.graphics + PositionsHighlightSelector.lut + PositionsHighlightSelector.lut_wrap + PositionsHighlightSelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PositionsHighlightSelector_api + + PositionsHighlightSelector.add_event_handler + PositionsHighlightSelector.add_graphic + PositionsHighlightSelector.append + PositionsHighlightSelector.clear + PositionsHighlightSelector.remove + PositionsHighlightSelector.remove_event_handler + PositionsHighlightSelector.remove_graphic + diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index 51f6801a4..bf75fa7e2 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -25,15 +25,16 @@ Properties RectangleSelector.axes RectangleSelector.axis RectangleSelector.block_events + RectangleSelector.block_handlers RectangleSelector.deleted RectangleSelector.edge_color RectangleSelector.event_handlers RectangleSelector.fill_color + RectangleSelector.imgui_right_click RectangleSelector.limits RectangleSelector.name RectangleSelector.offset RectangleSelector.parent - RectangleSelector.right_click_menu RectangleSelector.rotation RectangleSelector.scale RectangleSelector.selection @@ -50,6 +51,7 @@ Methods RectangleSelector.add_axes RectangleSelector.add_event_handler + RectangleSelector.append_imgui_right_click RectangleSelector.clear_event_handlers RectangleSelector.format_pick_info RectangleSelector.get_selected_data @@ -58,5 +60,7 @@ Methods RectangleSelector.map_model_to_world RectangleSelector.map_world_to_model RectangleSelector.remove_event_handler + RectangleSelector.remove_imgui_right_click RectangleSelector.rotate + RectangleSelector.set_imgui_right_click diff --git a/docs/source/api/selectors/RectangleSelectors.rst b/docs/source/api/selectors/RectangleSelectors.rst new file mode 100644 index 000000000..b1a7e4e78 --- /dev/null +++ b/docs/source/api/selectors/RectangleSelectors.rst @@ -0,0 +1,60 @@ +.. _api.RectangleSelectors: + +RectangleSelectors +****************** + +================== +RectangleSelectors +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectors_api + + RectangleSelectors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectors_api + + RectangleSelectors.alpha + RectangleSelectors.alpha_mode + RectangleSelectors.axes + RectangleSelectors.block_events + RectangleSelectors.block_handlers + RectangleSelectors.deleted + RectangleSelectors.event_handlers + RectangleSelectors.imgui_right_click + RectangleSelectors.name + RectangleSelectors.offset + RectangleSelectors.rotation + RectangleSelectors.scale + RectangleSelectors.selection + RectangleSelectors.supported_events + RectangleSelectors.tooltip_format + RectangleSelectors.visible + RectangleSelectors.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: RectangleSelectors_api + + RectangleSelectors.add_axes + RectangleSelectors.add_event_handler + RectangleSelectors.append + RectangleSelectors.append_imgui_right_click + RectangleSelectors.clear + RectangleSelectors.clear_event_handlers + RectangleSelectors.format_pick_info + RectangleSelectors.map_model_to_world + RectangleSelectors.map_world_to_model + RectangleSelectors.remove + RectangleSelectors.remove_event_handler + RectangleSelectors.remove_imgui_right_click + RectangleSelectors.rotate + RectangleSelectors.set_imgui_right_click + diff --git a/docs/source/api/selectors/SelectionVector.rst b/docs/source/api/selectors/SelectionVector.rst new file mode 100644 index 000000000..10acf180e --- /dev/null +++ b/docs/source/api/selectors/SelectionVector.rst @@ -0,0 +1,35 @@ +.. _api.SelectionVector: + +SelectionVector +*************** + +=============== +SelectionVector +=============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SelectionVector_api + + SelectionVector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SelectionVector_api + + SelectionVector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SelectionVector_api + + SelectionVector.add_selector + SelectionVector.append + SelectionVector.clear + SelectionVector.clear_selectables + SelectionVector.remove + diff --git a/docs/source/api/selectors/SelectorCollection.rst b/docs/source/api/selectors/SelectorCollection.rst new file mode 100644 index 000000000..2b6495ad5 --- /dev/null +++ b/docs/source/api/selectors/SelectorCollection.rst @@ -0,0 +1,60 @@ +.. _api.SelectorCollection: + +SelectorCollection +****************** + +================== +SelectorCollection +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SelectorCollection_api + + SelectorCollection + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SelectorCollection_api + + SelectorCollection.alpha + SelectorCollection.alpha_mode + SelectorCollection.axes + SelectorCollection.block_events + SelectorCollection.block_handlers + SelectorCollection.deleted + SelectorCollection.event_handlers + SelectorCollection.imgui_right_click + SelectorCollection.name + SelectorCollection.offset + SelectorCollection.rotation + SelectorCollection.scale + SelectorCollection.selection + SelectorCollection.supported_events + SelectorCollection.tooltip_format + SelectorCollection.visible + SelectorCollection.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SelectorCollection_api + + SelectorCollection.add_axes + SelectorCollection.add_event_handler + SelectorCollection.append + SelectorCollection.append_imgui_right_click + SelectorCollection.clear + SelectorCollection.clear_event_handlers + SelectorCollection.format_pick_info + SelectorCollection.map_model_to_world + SelectorCollection.map_world_to_model + SelectorCollection.remove + SelectorCollection.remove_event_handler + SelectorCollection.remove_imgui_right_click + SelectorCollection.rotate + SelectorCollection.set_imgui_right_click + diff --git a/docs/source/api/selectors/VisibilitySelector.rst b/docs/source/api/selectors/VisibilitySelector.rst new file mode 100644 index 000000000..2b03c5914 --- /dev/null +++ b/docs/source/api/selectors/VisibilitySelector.rst @@ -0,0 +1,38 @@ +.. _api.VisibilitySelector: + +VisibilitySelector +****************** + +================== +VisibilitySelector +================== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VisibilitySelector_api + + VisibilitySelector + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VisibilitySelector_api + + VisibilitySelector.lut + VisibilitySelector.lut_wrap + VisibilitySelector.selection + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VisibilitySelector_api + + VisibilitySelector.add_event_handler + VisibilitySelector.append + VisibilitySelector.clear + VisibilitySelector.pop + VisibilitySelector.remove + VisibilitySelector.remove_event_handler + diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst index 4a0caf8af..b33c5216e 100644 --- a/docs/source/api/selectors/index.rst +++ b/docs/source/api/selectors/index.rst @@ -7,3 +7,15 @@ Selectors LinearSelector LinearRegionSelector RectangleSelector + HighlightSelector + PositionsHighlightSelector + CollectionHighlightSelector + ImageHighlightSelector + VisibilitySelector + ImageVisibilitySelector + SelectorCollection + LinearSelectors + LinearRegionSelectors + RectangleSelectors + PolygonSelectors + SelectionVector diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst deleted file mode 100644 index b3498dd68..000000000 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ /dev/null @@ -1,57 +0,0 @@ -.. _api.HistogramLUTTool: - -HistogramLUTTool -**************** - -================ -HistogramLUTTool -================ -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool.alpha - HistogramLUTTool.alpha_mode - HistogramLUTTool.axes - HistogramLUTTool.block_events - HistogramLUTTool.cmap - HistogramLUTTool.deleted - HistogramLUTTool.event_handlers - HistogramLUTTool.images - HistogramLUTTool.name - HistogramLUTTool.offset - HistogramLUTTool.right_click_menu - HistogramLUTTool.rotation - HistogramLUTTool.scale - HistogramLUTTool.supported_events - HistogramLUTTool.tooltip_format - HistogramLUTTool.visible - HistogramLUTTool.vmax - HistogramLUTTool.vmin - HistogramLUTTool.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HistogramLUTTool_api - - HistogramLUTTool.add_axes - HistogramLUTTool.add_event_handler - HistogramLUTTool.clear_event_handlers - HistogramLUTTool.format_pick_info - HistogramLUTTool.map_model_to_world - HistogramLUTTool.map_world_to_model - HistogramLUTTool.remove_event_handler - HistogramLUTTool.rotate - HistogramLUTTool.set_data - diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst index 2bff8fb50..7a06fd5a0 100644 --- a/docs/source/api/tools/index.rst +++ b/docs/source/api/tools/index.rst @@ -4,7 +4,6 @@ Tools .. toctree:: :maxdepth: 1 - HistogramLUTTool TextBox Tooltip Cursor diff --git a/docs/source/api/ui/BaseGUI.rst b/docs/source/api/ui/BaseGUI.rst deleted file mode 100644 index 788e1414a..000000000 --- a/docs/source/api/ui/BaseGUI.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _api.BaseGUI: - -BaseGUI -******* - -======= -BaseGUI -======= -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - BaseGUI - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: BaseGUI_api - - BaseGUI.update - diff --git a/docs/source/api/ui/EdgeWindow.rst b/docs/source/api/ui/EdgeWindow.rst deleted file mode 100644 index 5835ab847..000000000 --- a/docs/source/api/ui/EdgeWindow.rst +++ /dev/null @@ -1,38 +0,0 @@ -.. _api.EdgeWindow: - -EdgeWindow -********** - -========== -EdgeWindow -========== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow.height - EdgeWindow.location - EdgeWindow.size - EdgeWindow.width - EdgeWindow.x - EdgeWindow.y - -Methods -~~~~~~~ -.. autosummary:: - :toctree: EdgeWindow_api - - EdgeWindow.draw_window - EdgeWindow.get_rect - EdgeWindow.update - diff --git a/docs/source/api/ui/ImguiBase.rst b/docs/source/api/ui/ImguiBase.rst new file mode 100644 index 000000000..078ca67c6 --- /dev/null +++ b/docs/source/api/ui/ImguiBase.rst @@ -0,0 +1,30 @@ +.. _api.ImguiBase: + +ImguiBase +********* + +========= +ImguiBase +========= +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + ImguiBase + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiBase_api + + ImguiBase.draw + diff --git a/docs/source/api/ui/ImguiPopup.rst b/docs/source/api/ui/ImguiPopup.rst new file mode 100644 index 000000000..481bccc6a --- /dev/null +++ b/docs/source/api/ui/ImguiPopup.rst @@ -0,0 +1,37 @@ +.. _api.ImguiPopup: + +ImguiPopup +********** + +========== +ImguiPopup +========== +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup.graphic + ImguiPopup.is_open + ImguiPopup.parent + ImguiPopup.subplot + ImguiPopup.window_flags + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiPopup_api + + ImguiPopup.draw + ImguiPopup.open + ImguiPopup.update + diff --git a/docs/source/api/ui/ImguiWindow.rst b/docs/source/api/ui/ImguiWindow.rst new file mode 100644 index 000000000..b921d299d --- /dev/null +++ b/docs/source/api/ui/ImguiWindow.rst @@ -0,0 +1,38 @@ +.. _api.ImguiWindow: + +ImguiWindow +*********** + +=========== +ImguiWindow +=========== +.. currentmodule:: fastplotlib.ui + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow.height + ImguiWindow.location + ImguiWindow.size + ImguiWindow.width + ImguiWindow.window_flags + ImguiWindow.x + ImguiWindow.y + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImguiWindow_api + + ImguiWindow.draw + ImguiWindow.update + diff --git a/docs/source/api/ui/Popup.rst b/docs/source/api/ui/Popup.rst deleted file mode 100644 index 5e924db94..000000000 --- a/docs/source/api/ui/Popup.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _api.Popup: - -Popup -***** - -===== -Popup -===== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - Popup - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Popup_api - - Popup.open - Popup.update - diff --git a/docs/source/api/ui/Window.rst b/docs/source/api/ui/Window.rst deleted file mode 100644 index 63c384261..000000000 --- a/docs/source/api/ui/Window.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. _api.Window: - -Window -****** - -====== -Window -====== -.. currentmodule:: fastplotlib.ui - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Window_api - - Window - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Window_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Window_api - - Window.update - diff --git a/docs/source/api/ui/index.rst b/docs/source/api/ui/index.rst index 4f31e651a..471d05ad3 100644 --- a/docs/source/api/ui/index.rst +++ b/docs/source/api/ui/index.rst @@ -4,7 +4,6 @@ UI Bases .. toctree:: :maxdepth: 1 - BaseGUI - Window - EdgeWindow - Popup + ImguiBase + ImguiWindow + ImguiPopup diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst index be7b1a049..6222e22c6 100644 --- a/docs/source/api/utils.rst +++ b/docs/source/api/utils.rst @@ -4,7 +4,3 @@ fastplotlib.utils .. currentmodule:: fastplotlib.utils .. automodule:: fastplotlib.utils.functions :members: - -.. currentmodule:: fastplotlib.utils -.. automodule:: fastplotlib.utils._plot_helpers - :members: diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst deleted file mode 100644 index fbafd4723..000000000 --- a/docs/source/api/widgets/ImageWidget.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _api.ImageWidget: - -ImageWidget -*********** - -=========== -ImageWidget -=========== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget.cmap - ImageWidget.current_index - ImageWidget.data - ImageWidget.figure - ImageWidget.frame_apply - ImageWidget.managed_graphics - ImageWidget.n_img_dims - ImageWidget.n_scrollable_dims - ImageWidget.ndim - ImageWidget.slider_dims - ImageWidget.window_funcs - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageWidget_api - - ImageWidget.add_event_handler - ImageWidget.clear_event_handlers - ImageWidget.close - ImageWidget.remove_event_handler - ImageWidget.reset_vmin_vmax - ImageWidget.reset_vmin_vmax_frame - ImageWidget.set_data - ImageWidget.show - diff --git a/docs/source/api/widgets/NDWidget.rst b/docs/source/api/widgets/NDWidget.rst new file mode 100644 index 000000000..7a09f3bbb --- /dev/null +++ b/docs/source/api/widgets/NDWidget.rst @@ -0,0 +1,35 @@ +.. _api.NDWidget: + +NDWidget +******** + +======== +NDWidget +======== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: NDWidget_api + + NDWidget + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: NDWidget_api + + NDWidget.figure + NDWidget.indices + NDWidget.ndgraphics + NDWidget.ranges + +Methods +~~~~~~~ +.. autosummary:: + :toctree: NDWidget_api + + NDWidget.close + NDWidget.show + diff --git a/docs/source/api/widgets/index.rst b/docs/source/api/widgets/index.rst index 5cb5299f6..fbebc87ec 100644 --- a/docs/source/api/widgets/index.rst +++ b/docs/source/api/widgets/index.rst @@ -4,4 +4,4 @@ Widgets .. toctree:: :maxdepth: 1 - ImageWidget + NDWidget diff --git a/docs/source/conf.py b/docs/source/conf.py index ead9f05c4..0ffecdcc3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,6 +21,7 @@ EXAMPLES_DIR = Path.joinpath(ROOT_DIR, "examples") sys.path.insert(0, str(ROOT_DIR)) +sys.path.insert(0, str(Path(__file__).parent.joinpath("_ext"))) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -42,6 +43,7 @@ "sphinx_copybutton", "sphinx_design", "sphinx_gallery.gen_gallery", + "imgui_docs", ] sphinx_gallery_conf = { @@ -56,7 +58,7 @@ "../../examples/image", "../../examples/image_volume", "../../examples/heatmap", - "../../examples/image_widget", + # "../../examples/image_widget", "../../examples/gridplot", "../../examples/window_layouts", "../../examples/controllers", @@ -69,6 +71,7 @@ "../../examples/events", "../../examples/selection_tools", "../../examples/spaces_transforms", + "../../examples/ndwidget", "../../examples/machine_learning", "../../examples/guis", "../../examples/ipywidgets", diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index 0be967a36..5ad6dbb04 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -9,6 +9,7 @@ from fastplotlib.layouts import Subplot from fastplotlib import graphics from fastplotlib.graphics import features, selectors +from fastplotlib import axes from fastplotlib import tools from fastplotlib import widgets from fastplotlib import utils @@ -22,6 +23,7 @@ GRAPHICS_DIR = API_DIR.joinpath("graphics") GRAPHIC_FEATURES_DIR = API_DIR.joinpath("graphic_features") SELECTORS_DIR = API_DIR.joinpath("selectors") +AXES_DIR = API_DIR.joinpath("axes") TOOLS_DIR = API_DIR.joinpath("tools") WIDGETS_DIR = API_DIR.joinpath("widgets") UI_DIR = API_DIR.joinpath("ui") @@ -33,6 +35,7 @@ GRAPHICS_DIR, GRAPHIC_FEATURES_DIR, SELECTORS_DIR, + AXES_DIR, TOOLS_DIR, WIDGETS_DIR, UI_DIR, @@ -295,7 +298,12 @@ def main(): ) ############################################################################## # ** GraphicFeature classes ** # - feature_classes = [getattr(features, f) for f in features.__all__] + # `features.__all__` also exports type aliases, such as TupleYUV, which has no docs page + feature_classes = [ + getattr(features, f) + for f in features.__all__ + if inspect.isclass(getattr(features, f)) + ] feature_class_names = [f.__name__ for f in feature_classes] @@ -370,6 +378,33 @@ def main(): source_path=TOOLS_DIR.joinpath(f"{tool_cls.__name__}.rst"), ) + ############################################################################## + # ** Aes classes ** # + axes_classes = [getattr(axes, obj) for obj in axes.__all__] + + axes_class_names = [a.__name__ for a in axes_classes] + + axes_class_names_str = "\n ".join([""] + axes_class_names) + + with open(AXES_DIR.joinpath("index.rst"), "w") as f: + f.write( + f"Axes\n" + f"****\n" + f"\n" + f".. toctree::\n" + f" :maxdepth: 1\n" + f"{axes_class_names_str}\n" + ) + + for axes_cls in axes_classes: + generate_page( + page_name=axes_cls.__name__, + classes=[axes_cls], + modules=["fastplotlib.axes"], + source_path=AXES_DIR.joinpath(f"{axes_cls.__name__}.rst"), + ) + + ############################################################################## # ** Widget classes ** # widget_classes = [getattr(widgets, w) for w in widgets.__all__] @@ -397,7 +432,7 @@ def main(): ) ############################################################################## # ** UI classes ** # - ui_classes = [ui.BaseGUI, ui.Window, ui.EdgeWindow, ui.Popup] + ui_classes = [ui.ImguiBase, ui.ImguiWindow, ui.ImguiPopup] ui_class_names = [cls.__name__ for cls in ui_classes] @@ -424,7 +459,6 @@ def main(): ############################################################################## utils_str = generate_functions_module(utils.functions, "fastplotlib.utils") - utils_str += generate_functions_module(utils._plot_helpers, "fastplotlib.utils", generate_header=False) with open(API_DIR.joinpath("utils.rst"), "w") as f: f.write(utils_str) @@ -475,14 +509,15 @@ def write_table(name, feature_cls): continue f.write(f"{graphic_cls.__name__}\n") f.write("-" * len(graphic_cls.__name__) + "\n\n") - for name, type_ in graphic_cls._features.items(): - if isinstance(type_, tuple): - for t in type_: - if t is None: - continue - f.write(write_table(name, t)) - else: - f.write(write_table(name, type_)) + if hasattr(graphic_cls, "_features"): # some selectors like Highlight etc. don't have "graphic features" + for name, type_ in graphic_cls._features.items(): + if isinstance(type_, tuple): + for t in type_: + if t is None: + continue + f.write(write_table(name, t)) + else: + f.write(write_table(name, type_)) if __name__ == "__main__": diff --git a/docs/source/imgui/guide.rst b/docs/source/imgui/guide.rst new file mode 100644 index 000000000..4ba55bf0d --- /dev/null +++ b/docs/source/imgui/guide.rst @@ -0,0 +1,270 @@ +imgui UIs +========= + +`imgui `_ UIs are rendered directly onto the same canvas as the ``Figure``, so +the same UI code runs on every GUI backend: glfw, Qt, wx, and jupyter. + +imgui support requires ``imgui-bundle``, see the installation section of the user guide. When ``imgui-bundle`` is +installed ``fastplotlib.Figure`` is an ``ImguiFigure``, and every subplot gets a toolbar and a standard right-click +menu. + +There are two things you can add to a ``Figure``: + +* ``ImguiWindow`` - a window drawn within the Figure. It can float over the plots, be fixed to a rect, or occupy space + on an edge of the Figure or of a Subplot. +* ``ImguiPopup`` - a popup opened by a right-click on the Figure, a Subplot, or a Graphic. + +Both are written in the same way, either as a function or as a subclass. + +Floating and fixed windows +-------------------------- + +A floating window is drawn over the plots. imgui sizes it to fit its contents, it appears at the top left of the +canvas, and the user can move, resize, and collapse it. The function draws the imgui elements and is called on every +render, the object it is added to is an optional argument:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure(size=(700, 560)) + figure[0, 0].add_line(np.random.rand(100), name="line") + + @figure.add_imgui_window(location="floating") + def gui(fig): + line = fig[0, 0]["line"] + + changed, thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + if changed: + line.thickness = thickness + + if imgui.button("randomize"): + line.data[:, 1] = np.random.rand(100) + +``add_imgui_window`` can also be given the function directly instead of decorating it, which is useful when the same +function is used more than once:: + + figure.add_imgui_window(gui, location="floating") + +A window can instead be fixed to a ``rect`` of the canvas, ``(x, y, width, height)``, or to an ``extent``, +``(xmin, xmax, ymin, ymax)``. These are fractional if the width and height are ``<= 1``, and in pixels otherwise. A +fixed window cannot be moved, resized, or collapsed:: + + @figure.add_imgui_window(extent=(0.6, 0.98, 0.05, 0.25)) + def gui(): + imgui.text("fixed to a fractional extent") + +Figure edge windows +------------------- + +An edge window occupies canvas space along one edge of the Figure, so it never covers the plots. ``location`` is one of +``"left"``, ``"right"``, ``"top"``, ``"bottom"``, and ``size`` is the thickness in pixels, which is required:: + + @figure.add_imgui_window(location="right", size=200, title="controls") + def gui(fig): + ... + +If ``title`` is not given no title bar is drawn. The "bottom" and "right" Figure edge windows can be resized by +dragging their inner border, and collapsed by double-clicking it. + +Subplot edge windows +-------------------- + +You can add imgui windows that are confined to a subplot edge:: + + @figure[0, 0].add_imgui_window(location="right", size=130, title="image") + def gui(subplot): + if imgui.button("noise"): + subplot["image"].data = np.random.rand(128, 128) + +Each subplot also has a toolbar, an imgui window at the ``"toolbar"`` location that you can append elements to:: + + from imgui_bundle import icons_fontawesome_6 as fa + + @figure[0, 0].append_imgui_window(location="toolbar") + def toolbar_extra(subplot): + imgui.same_line() + _, subplot.axes.visible = imgui.checkbox(fa.ICON_FA_RULER_COMBINED, subplot.axes.visible) + +``subplot.toolbar = False`` hides it, and ``add_imgui_window(location="toolbar")`` replaces it. + +Appending, replacing, and removing +---------------------------------- + +Windows are keyed by location, and ``add_imgui_window`` replaces the window at that location. +``append_imgui_window`` adds more UI elements to the window that is already there, it raises if there is none:: + + @figure.append_imgui_window(location="right") + def more(fig): + imgui.text("appended below the elements of the existing window") + +``remove_imgui_window`` removes and returns the window at a location, which can be added again later:: + + window = figure.remove_imgui_window("right") + +``figure.imgui_windows`` and ``subplot.imgui_windows`` return the windows keyed by location. + +Subclassing ``ImguiWindow`` +--------------------------- + +Subclass ``ImguiWindow`` and implement ``update()`` when you need something more complex, such as a UI that keeps +state. Pass what the UI needs into ``__init__``, an instance is not bound to a Figure until it is added:: + + from fastplotlib.ui import ImguiWindow + + class Controls(ImguiWindow): + def __init__(self, line): + super().__init__() + + self._line = line + self._ys = line.data[:, 1].copy() + self._amplitude = 1.0 + + def update(self): + changed, self._amplitude = imgui.slider_float( + "amplitude", v=self._amplitude, v_min=0.1, v_max=10.0 + ) + if changed: + self._line.data[:, 1] = self._ys * self._amplitude + + figure.add_imgui_window(Controls(line), location="right", size=200, title="controls") + +Within ``update()`` the window's pixel rect is available as ``x``, ``y``, ``width``, and ``height``. ``size`` is +settable, and setting it on an edge or toolbar window triggers a re-layout of the Figure. + +``fastplotlib.ui.ChangeFlag`` is useful when several elements modify the same thing. It is a bool that stays ``True`` +once it has been set to ``True``:: + + from fastplotlib.ui import ChangeFlag + + changed = ChangeFlag(False) + changed.value, vmin = imgui.slider_float("vmin", v=image.vmin, v_min=0, v_max=255) + changed.value, vmax = imgui.slider_float("vmax", v=image.vmax, v_min=0, v_max=255) + + if changed: + image.vmin, image.vmax = vmin, vmax + +For full control of the imgui window, override ``draw()`` instead of ``update()``. You are then responsible for +creating the window with ``imgui.begin()`` and ``imgui.end()``, and ``update()`` is not used. This is how you use +window flags that must be set when the window is created, such as ``imgui.WindowFlags_.menu_bar`` for a menu bar, +see :ref:`imgui.WindowFlags_ `. The examples gallery has a menu bar example. + +Right-click popups +------------------ + +A popup is opened by a right-click. It is not restricted to menu items, any imgui elements can be used. + +A popup can be set on the Figure, where it replaces the standard right-click menu, on a Subplot, or on a Graphic. The +most specific one wins: the popup of the graphic under the pointer, else the popup of the subplot that was clicked, +else the popup of the Figure:: + + @figure.set_imgui_right_click() + def popup(fig): + if imgui.menu_item("autoscale all", "", False)[0]: + for subplot in fig: + subplot.auto_scale() + + @figure[0, 1].set_imgui_right_click() + def subplot_popup(subplot): + imgui.text(f"subplot: {subplot.name}") + +A popup takes the object it is set on as an optional argument, and the function can be passed directly instead of +decorating. Each call wraps the function in its own popup, so the same function can be set on any number of graphics:: + + def contrast(image): + changed, vals = imgui.slider_float2("vmin / vmax", (image.vmin, image.vmax), 0, 255) + if changed: + image.vmin, image.vmax = vals + + img1.set_imgui_right_click(contrast) + img2.set_imgui_right_click(contrast) + +Only one popup can be set on an object. A graphic must be added to a subplot of an ``ImguiFigure`` before a popup can +be set on it. ``append_imgui_right_click`` adds more UI elements to the popup that is set, +``remove_imgui_right_click`` removes and returns it, and ``imgui_right_click`` returns the popup that is set. + +Extending the standard right-click menu +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Figure's popup is a ``StandardRightClickMenu``. Append to it to keep its items and add your own:: + + @figure.append_imgui_right_click() + def extra_items(fig): + imgui.separator() + _, fig.imgui_show_fps = imgui.checkbox("show fps", fig.imgui_show_fps) + +Subclassing ``ImguiPopup`` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Subclass ``ImguiPopup`` and implement ``update()``, which contains only the imgui elements. ``subplot`` and ``graphic`` +are what the popup was opened on, ``graphic`` is ``None`` if the click was not on a graphic, and ``parent`` is the +object the popup is set on:: + + from fastplotlib.ui import ImguiPopup + + class MyPopup(ImguiPopup): + def update(self): + imgui.text(f"subplot: {self.subplot.name}") + + if imgui.menu_item("autoscale", "", False)[0]: + self.subplot.auto_scale() + + figure.set_imgui_right_click(MyPopup()) + +To keep the standard items, subclass ``StandardRightClickMenu`` and call ``super().update()``:: + + from fastplotlib.ui import StandardRightClickMenu + + class MyMenu(StandardRightClickMenu): + def update(self): + super().update() + + imgui.separator() + if imgui.menu_item("my item", "", False)[0]: + ... + +``window_flags`` can be passed to ``set_imgui_right_click`` and is a settable property, see +:ref:`imgui.WindowFlags_ `. ``is_open`` tells you whether the popup is currently open. + +A window that must stay open after the popup closes cannot be drawn in ``update()``, which only runs while the popup is +open. Override ``draw()`` and draw it after the popup:: + + class MyPopup(ImguiPopup): + def __init__(self): + super().__init__() + self._window_open = False + + def update(self): + if imgui.menu_item("Open window", "", False)[0]: + self._window_open = True + + def draw(self): + super().draw() + + if self._window_open: + _, self._window_open = imgui.begin("my window", True) + imgui.text("stays open after the popup closes") + imgui.end() + +Built-in imgui UIs +------------------ + +* ``SubplotToolbar`` - the toolbar of each subplot. +* ``StandardRightClickMenu`` - the Figure's default right-click popup: fps, autoscale, center, maintain aspect, flip + axes, grids, FOV, and controller options. +* ``ImguiColorbar`` - an ``ImguiWindow`` that shows a colorbar for one or more images, with draggable vmin and vmax, a + colormap picker, gamma, and an optional precomputed histogram:: + + from fastplotlib.ui import ImguiColorbar + + colorbar = ImguiColorbar(images=image, histogram=np.histogram(data, bins=100)) + figure[0, 0].add_imgui_window(colorbar, location="right", size=100) + +Writing imgui elements +---------------------- + +fastplotlib does not wrap imgui, you call ``imgui_bundle`` directly, so any imgui element can be used. The +:doc:`imgui element reference ` documents each element as it exists in ``imgui_bundle``, with +its signature, its arguments, its flags, and an example of what it looks like. + +The ImGUI section of the examples gallery has complete examples. diff --git a/docs/source/imgui/index.rst b/docs/source/imgui/index.rst new file mode 100644 index 000000000..f29f86dbf --- /dev/null +++ b/docs/source/imgui/index.rst @@ -0,0 +1,11 @@ +imgui +***** + +The guide walks you through how to use and integrate imgui with fastplotlib. The reference covers the imgui +elements themselves. + +.. toctree:: + :maxdepth: 2 + + guide + reference/index diff --git a/docs/source/imgui/reference/elements.rst b/docs/source/imgui/reference/elements.rst new file mode 100644 index 000000000..b5c17e1d0 --- /dev/null +++ b/docs/source/imgui/reference/elements.rst @@ -0,0 +1,3284 @@ +Elements +======== + +The imgui elements as they exist in ``imgui_bundle``. Each element is shown with the code that produced its +image, which runs as it is written. See the :doc:`imgui guide ` for adding a UI to a Figure. + +An argument typed ``ImVec2`` or ``ImVec4`` also takes a tuple or a list. + +The examples use ``imgui``, ``icons_fontawesome_6 as fa`` and ``numpy as np``. + +Text +---- + +Text elements are read-only, they display a value that the user cannot edit. + +text +^^^^ + +.. imgui-signature:: text + +**Parameters** + +* ``fmt`` - the text to draw + +.. imgui-example:: + + n_peaks = 137 + + imgui.text(f"peaks found: {n_peaks}") + +text_colored +^^^^^^^^^^^^ + +.. imgui-signature:: text_colored + +**Parameters** + +* ``col`` - text color, ``(r, g, b, a)`` in ``0.0`` to ``1.0`` +* ``fmt`` - the text to draw + +.. imgui-example:: + + vmin, vmax = 180.0, 60.0 + + if vmin > vmax: + imgui.text_colored((1.0, 0.3, 0.3, 1.0), f"{fa.ICON_FA_TRIANGLE_EXCLAMATION} vmin > vmax") + +text_disabled +^^^^^^^^^^^^^ + +.. imgui-signature:: text_disabled + +**Parameters** + +* ``fmt`` - the text to draw + +.. imgui-example:: + + selected = None + + imgui.text("selection:") + imgui.same_line() + + if selected is None: + imgui.text_disabled("none") + else: + imgui.text(selected) + +text_wrapped +^^^^^^^^^^^^ + +.. imgui-signature:: text_wrapped + +**Parameters** + +* ``fmt`` - the text to draw, wrapped at the right edge of the window + +.. imgui-example:: + :width: 220 + + imgui.text_wrapped("the filter runs on the full frame, it can take a few seconds for large images") + +label_text +^^^^^^^^^^ + +.. imgui-signature:: label_text + +**Parameters** + +* ``label`` - drawn to the right of the value, aligned the same way as the label of a slider or an input +* ``fmt`` - the value to draw + +.. imgui-example:: + + data = np.random.randint(0, 4096, (512, 512), dtype=np.uint16) + + imgui.label_text("shape", str(data.shape)) + imgui.label_text("dtype", str(data.dtype)) + imgui.label_text("range", f"{data.min()} - {data.max()}") + +bullet_text +^^^^^^^^^^^ + +.. imgui-signature:: bullet_text + +**Parameters** + +* ``fmt`` - the text to draw after the bullet + +.. imgui-example:: + + imgui.text("controller:") + imgui.bullet_text("left click drag to pan") + imgui.bullet_text("right click drag to zoom") + imgui.bullet_text("scroll to zoom about the cursor") + +separator_text +^^^^^^^^^^^^^^ + +.. imgui-signature:: separator_text + +**Parameters** + +* ``label`` - the text to draw in the separator + +.. imgui-example:: + + thickness, sigma = 4.0, 1.0 + + imgui.separator_text("line") + changed, thickness = imgui.slider_float("thickness", v=thickness, v_min=1.0, v_max=20.0) + + imgui.separator_text("image") + changed, sigma = imgui.slider_float("gaussian sigma", v=sigma, v_min=0.1, v_max=10.0) + +Widgets +------- + +button +^^^^^^ + +.. imgui-signature:: button + +**Parameters** + +* ``label`` - drawn on the button, ``"##hidden"`` suppresses it +* ``size`` - ``(width, height)``, a zero component is sized to the label, a negative one fills the available space + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + if imgui.button("autoscale"): + print("autoscale clicked") + + if imgui.button(fa.ICON_FA_TRASH): + print("trash clicked") + if imgui.is_item_hovered(): + imgui.set_tooltip("remove all graphics") + +small_button +^^^^^^^^^^^^ + +.. imgui-signature:: small_button + +**Parameters** + +* ``label`` - drawn on the button + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + imgui.text(f"vmin {vmin:.0f}, vmax {vmax:.0f}") + imgui.same_line() + + if imgui.small_button("reset"): + vmin, vmax = 0.0, 255.0 + +arrow_button +^^^^^^^^^^^^ + +.. imgui-signature:: arrow_button + +**Parameters** + +* ``str_id`` - identifies the button, it is not drawn +* ``dir`` - ``imgui.Dir.left``, ``right``, ``up`` or ``down`` + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + channel, n_channels = 1, 4 + + if imgui.arrow_button("previous", imgui.Dir.left): + channel = max(0, channel - 1) + + imgui.same_line() + imgui.text(f"channel {channel}") + + imgui.same_line() + if imgui.arrow_button("next", imgui.Dir.right): + channel = min(n_channels - 1, channel + 1) + +invisible_button +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: invisible_button + +**Parameters** + +* ``str_id`` - identifies the button, nothing is drawn +* ``size`` - ``(width, height)`` of the area that responds to the pointer + +**Returns:** ``True`` on the frame the button is clicked + +An invisible button gives the pointer behavior of a button to an area that you draw yourself. The pointer is over the +button in the image below, so the bar is drawn in its highlighted color. + +.. imgui-example:: + :interact: hover 40 20 + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + imgui.invisible_button("threshold-bar", (120, 24)) + + color = (1.0, 0.8, 0.2, 1.0) if imgui.is_item_hovered() else (0.4, 0.4, 0.4, 1.0) + draw_list.add_rect_filled( + position, (position.x + 120, position.y + 24), imgui.color_convert_float4_to_u32(color) + ) + +checkbox +^^^^^^^^ + +.. imgui-signature:: checkbox + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``v`` - the current state + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + axes_visible, grid_visible = True, False + + changed, axes_visible = imgui.checkbox("axes", axes_visible) + changed, grid_visible = imgui.checkbox("grid", grid_visible) + +checkbox_flags +^^^^^^^^^^^^^^ + +.. imgui-signature:: checkbox_flags + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``flags`` - the ``int`` that holds the bits +* ``flags_value`` - the bit that this checkbox sets and clears + +**Returns:** ``(changed, flags)`` + +The box is checked when the bit is set, and is drawn filled when ``flags_value`` holds several bits and only some of +them are set. + +.. imgui-example:: + + slider_flags = int(imgui.SliderFlags_.logarithmic) + + changed, slider_flags = imgui.checkbox_flags( + "logarithmic", slider_flags, int(imgui.SliderFlags_.logarithmic) + ) + changed, slider_flags = imgui.checkbox_flags( + "no input", slider_flags, int(imgui.SliderFlags_.no_input) + ) + +radio_button +^^^^^^^^^^^^ + +.. imgui-signature:: radio_button + +**Parameters** + +* ``label`` - drawn to the right of the button +* ``active`` - whether this button is the selected one +* ``v``, ``v_button`` - the variable that holds the selection, and the value of this button + +**Returns:** ``True`` on the frame the button is clicked, or ``(changed, v)`` for the second form + +Use radio buttons for a small number of options that are all worth showing, a combo box is better for a long list. + +.. imgui-example:: + + mode = 1 + + for i, label in enumerate(["line", "scatter", "heatmap"]): + if imgui.radio_button(label, mode == i): + mode = i + +progress_bar +^^^^^^^^^^^^ + +.. imgui-signature:: progress_bar + +**Parameters** + +* ``fraction`` - ``0.0`` to ``1.0`` +* ``size_arg`` - ``(width, height)``, the default fills the available width +* ``overlay`` - text drawn on the bar, the percentage is drawn if it is not given + +.. imgui-example:: + :width: 280 + + n_done, n_frames = 317, 500 + + imgui.progress_bar(n_done / n_frames, overlay=f"{n_done} / {n_frames} frames") + +bullet +^^^^^^ + +.. imgui-signature:: bullet + +**Parameters** + +none + +.. imgui-example:: + + shape = (500, 512, 512) + + imgui.bullet() + imgui.text(f"{shape[0]} frames") + + imgui.bullet() + imgui.text(f"{shape[1]} x {shape[2]} pixels") + +Sliders +------- + +A slider is dragged between a lower and an upper bound. A drag has no bound by default and changes its value by how +far the pointer moves, which suits a value with no natural range. Ctrl+click either of them to type a value instead. + +``format`` is a printf format, it is applied to the value drawn on the element, e.g. ``"%.1f px"``. + +slider_float +^^^^^^^^^^^^ + +.. imgui-signature:: slider_float + +**Parameters** + +* ``label`` - drawn to the right of the slider, ``"##hidden"`` suppresses it +* ``v`` - the current value +* ``v_min``, ``v_max`` - the bounds, the value is clamped to them +* ``format`` - printf format of the value drawn on the slider + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + thickness = 4.0 + + changed, thickness = imgui.slider_float("thickness", v=thickness, v_min=1.0, v_max=20.0) + +slider_float2 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float2 + +Two values on one row, sharing one pair of bounds. Pass a list and use the list that comes back. + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to both components +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + vmin_vmax = [12.0, 208.0] + + changed, vmin_vmax = imgui.slider_float2("vmin / vmax", vmin_vmax, 0.0, 255.0, format="%.0f") + +slider_float3 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float3 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + spacing = [1.0, 1.0, 3.0] + + changed, spacing = imgui.slider_float3("voxel spacing", spacing, 0.1, 10.0, format="%.2f") + +slider_float4 +^^^^^^^^^^^^^ + +.. imgui-signature:: slider_float4 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + extent = [0.1, 0.9, 0.1, 0.9] + + changed, extent = imgui.slider_float4("extent", extent, 0.0, 1.0, format="%.2f") + +slider_int +^^^^^^^^^^ + +.. imgui-signature:: slider_int + +**Parameters** + +* ``label`` - drawn to the right of the slider +* ``v`` - the current value +* ``v_min``, ``v_max`` - the bounds, the value is clamped to them +* ``format`` - printf format of the value drawn on the slider + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + n_bins = 100 + + changed, n_bins = imgui.slider_int("bins", v=n_bins, v_min=10, v_max=500) + +slider_int2 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int2 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to both components +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + crop = [64, 448] + + changed, crop = imgui.slider_int2("crop rows", crop, 0, 512) + +slider_int3 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int3 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + stride = [1, 2, 2] + + changed, stride = imgui.slider_int3("stride", stride, 1, 8) + +slider_int4 +^^^^^^^^^^^ + +.. imgui-signature:: slider_int4 + +**Parameters** + +* ``label`` - drawn to the right of the sliders +* ``v`` - the current values +* ``v_min``, ``v_max`` - the bounds, applied to every component +* ``format`` - printf format of the values drawn on the sliders + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + roi = [64, 64, 256, 256] + + changed, roi = imgui.slider_int4("roi", roi, 0, 512) + +slider_angle +^^^^^^^^^^^^ + +.. imgui-signature:: slider_angle + +The value is in radians, the bounds and the value drawn on the slider are in degrees. + +**Parameters** + +* ``label`` - drawn to the right of the slider +* ``v_rad`` - the current angle, in radians +* ``v_degrees_min``, ``v_degrees_max`` - the bounds, in degrees +* ``format`` - printf format of the angle drawn on the slider + +**Returns:** ``(changed, v_rad)`` + +.. imgui-example:: + + rotation = 0.6 + + changed, rotation = imgui.slider_angle("rotation", v_rad=rotation, v_degrees_min=-180, v_degrees_max=180) + +drag_float +^^^^^^^^^^ + +.. imgui-signature:: drag_float + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v`` - the current value +* ``v_speed`` - how much the value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the value drawn on the element + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + sigma = 1.4 + + changed, sigma = imgui.drag_float("gaussian sigma", v=sigma, v_speed=0.05, v_min=0.1, v_max=20.0) + +drag_float2 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float2 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to both components, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + origin = [0.0, 0.0] + + changed, origin = imgui.drag_float2("origin", origin, v_speed=0.5) + +drag_float3 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float3 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to every component, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + offset = [0.0, 0.0, 0.0] + + changed, offset = imgui.drag_float3("offset", offset, v_speed=0.5) + +drag_float4 +^^^^^^^^^^^ + +.. imgui-signature:: drag_float4 + +**Parameters** + +* ``label`` - drawn to the right of the elements +* ``v`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, applied to every component, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the values drawn on the elements + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + bounds = [0.0, 512.0, 0.0, 512.0] + + changed, bounds = imgui.drag_float4("bounds", bounds, v_speed=1.0, format="%.0f") + +drag_float_range2 +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: drag_float_range2 + +Two values that cannot cross, the lower one is dragged from the left half and the upper one from the right half. + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v_current_min``, ``v_current_max`` - the current values +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the lower value +* ``format_max`` - printf format of the upper value, ``format`` is used for both if it is not given + +**Returns:** ``(changed, v_current_min, v_current_max)`` + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + changed, vmin, vmax = imgui.drag_float_range2( + "vmin / vmax", vmin, vmax, v_speed=1.0, v_min=0.0, v_max=255.0, format="%.0f" + ) + +drag_int +^^^^^^^^ + +.. imgui-signature:: drag_int + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v`` - the current value +* ``v_speed`` - how much the value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the value drawn on the element + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + window = 30 + + changed, window = imgui.drag_int("window size", v=window, v_speed=1.0, v_min=1, v_max=500) + +drag_int_range2 +^^^^^^^^^^^^^^^ + +.. imgui-signature:: drag_int_range2 + +**Parameters** + +* ``label`` - drawn to the right of the element +* ``v_current_min``, ``v_current_max`` - the current values, they cannot cross +* ``v_speed`` - how much a value changes per pixel of pointer movement +* ``v_min``, ``v_max`` - the bounds, there is no bound while ``v_min >= v_max`` +* ``format`` - printf format of the lower value +* ``format_max`` - printf format of the upper value, ``format`` is used for both if it is not given + +**Returns:** ``(changed, v_current_min, v_current_max)`` + +.. imgui-example:: + + first, last = 40, 260 + + changed, first, last = imgui.drag_int_range2("frames", first, last, v_min=0, v_max=500) + +Input +----- + +Input elements are typed into. A slider or a drag is better for a value that is explored by eye, an input is better +for a value that is known. + +input_text +^^^^^^^^^^ + +.. imgui-signature:: input_text + +**Parameters** + +* ``label`` - drawn to the right of the field, ``"##hidden"`` suppresses it +* ``str`` - the current text +* ``callback``, ``user_data`` - an imgui input callback, for completion or filtering + +**Returns:** ``(changed, str)`` - ``changed`` is ``True`` on every keystroke unless +:ref:`imgui.InputTextFlags_ ` asks otherwise + +.. imgui-example:: + :interact: click 60 18; type "a" + + name = "roi-1" + + changed, name = imgui.input_text("graphic name", name) + +input_text_multiline +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: input_text_multiline + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``str`` - the current text +* ``size`` - ``(width, height)`` of the field, a zero component is a default size +* ``callback``, ``user_data`` - an imgui input callback + +**Returns:** ``(changed, str)`` + +.. imgui-example:: + + notes = "frame 42\nsaturated pixels\nrecheck vmax" + + changed, notes = imgui.input_text_multiline("notes", notes, (220, 70)) + +input_text_with_hint +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: input_text_with_hint + +The hint is drawn in the field while it is empty, use it instead of a label when there is no room for one. + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``hint`` - drawn in the field while ``str`` is empty +* ``str`` - the current text +* ``callback``, ``user_data`` - an imgui input callback + +**Returns:** ``(changed, str)`` + +.. imgui-example:: + + pattern = "" + + changed, pattern = imgui.input_text_with_hint("##filter", "filter graphics", pattern) + +input_float +^^^^^^^^^^^ + +.. imgui-signature:: input_float + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by, they are not drawn while it is ``0.0`` +* ``step_fast`` - amount used while ctrl is held +* ``format`` - printf format of the value in the field + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + threshold = 0.75 + + changed, threshold = imgui.input_float("threshold", v=threshold, step=0.05, step_fast=0.5) + +input_float2 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float2 + +Two, three, and four fields on one row. Pass a list and use the list that comes back. + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + pixel_size = [0.325, 0.325] + + changed, pixel_size = imgui.input_float2("pixel size (um)", pixel_size, format="%.3f") + +input_float3 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float3 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + origin = [0.0, 0.0, 0.0] + + changed, origin = imgui.input_float3("origin", origin, format="%.1f") + +input_float4 +^^^^^^^^^^^^ + +.. imgui-signature:: input_float4 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values +* ``format`` - printf format of the values in the fields + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + bounds = [0.0, 512.0, 0.0, 512.0] + + changed, bounds = imgui.input_float4("bounds", bounds, format="%.0f") + +input_int +^^^^^^^^^ + +.. imgui-signature:: input_int + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by +* ``step_fast`` - amount used while ctrl is held + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + n_components = 8 + + changed, n_components = imgui.input_int("components", v=n_components, step=1, step_fast=10) + +input_int2 +^^^^^^^^^^ + +.. imgui-signature:: input_int2 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + shape = [512, 512] + + changed, shape = imgui.input_int2("output shape", shape) + +input_int3 +^^^^^^^^^^ + +.. imgui-signature:: input_int3 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + chunks = [1, 256, 256] + + changed, chunks = imgui.input_int3("chunks", chunks) + +input_int4 +^^^^^^^^^^ + +.. imgui-signature:: input_int4 + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``v`` - the current values + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + + roi = [64, 64, 256, 256] + + changed, roi = imgui.input_int4("roi", roi) + +input_double +^^^^^^^^^^^^ + +.. imgui-signature:: input_double + +**Parameters** + +* ``label`` - drawn to the right of the field +* ``v`` - the current value +* ``step`` - amount the ``-`` and ``+`` buttons change the value by, they are not drawn while it is ``0.0`` +* ``step_fast`` - amount used while ctrl is held +* ``format`` - printf format of the value in the field + +**Returns:** ``(changed, v)`` + +.. imgui-example:: + :width: 260 + + exposure = 0.008 + + changed, exposure = imgui.input_double("exposure (s)", v=exposure, step=0.001, format="%.4f") + +Selection +--------- + +combo +^^^^^ + +.. imgui-signature:: combo + +**Parameters** + +* ``label`` - drawn to the right of the box, ``"##hidden"`` suppresses it +* ``current_item`` - index of the selected item +* ``items`` - the items, as a sequence of strings +* ``popup_max_height_in_items`` - how many items the open list shows before it scrolls + +**Returns:** ``(changed, current_item)`` + +.. imgui-example:: + + mode, modes = 1, ["mip", "minip", "iso", "slice"] + + changed, mode = imgui.combo("render mode", mode, modes) + +The list is drawn while the box is open: + +.. imgui-example:: + :name: combo_open + :interact: click 60 18 + + mode, modes = 1, ["mip", "minip", "iso", "slice"] + + changed, mode = imgui.combo("render mode", mode, modes) + +begin_combo +^^^^^^^^^^^ + +.. imgui-signature:: begin_combo + +Use these instead of ``combo`` when the items are not plain strings, the body draws whatever it likes. Call +``end_combo`` only when ``begin_combo`` returned ``True``. + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``preview_value`` - drawn in the box while it is closed + +.. imgui-example:: + :name: begin_combo + :interact: click 60 18 + + selected, graphics = "line-1", ["line-1", "line-2", "scatter-1"] + + if imgui.begin_combo("graphic", selected): + for name in graphics: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + + imgui.end_combo() + +end_combo +^^^^^^^^^ + +.. imgui-signature:: end_combo + +Call it only when the matching ``begin_combo`` returned ``True``. + +**Parameters** + +none + +list_box +^^^^^^^^ + +.. imgui-signature:: list_box + +A list box shows several items at once, a combo box hides them until it is opened. + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``current_item`` - index of the selected item +* ``items`` - the items, as a sequence of strings +* ``height_in_items`` - how many items are visible before the box scrolls + +**Returns:** ``(changed, current_item)`` + +.. imgui-example:: + + selected, graphics = 0, ["line-1", "line-2", "scatter-1", "image-1"] + + changed, selected = imgui.list_box("graphics", selected, graphics, height_in_items=4) + +begin_list_box +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_list_box + +**Parameters** + +* ``label`` - drawn to the right of the box +* ``size`` - ``(width, height)``, a zero component is a default size + +.. imgui-example:: + :name: begin_list_box + + selected, graphics = "line-1", ["line-1", "line-2", "scatter-1"] + + if imgui.begin_list_box("graphics", (160, 70)): + for name in graphics: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + + imgui.end_list_box() + +end_list_box +^^^^^^^^^^^^ + +.. imgui-signature:: end_list_box + +Call it only when the matching ``begin_list_box`` returned ``True``. + +**Parameters** + +none + +selectable +^^^^^^^^^^ + +.. imgui-signature:: selectable + +A row of text that can be selected, and the item to build lists out of. + +**Parameters** + +* ``label`` - drawn in the row +* ``p_selected`` - whether this row is drawn as selected +* ``size`` - ``(width, height)``, a zero component fills the available width + +**Returns:** ``(clicked, p_selected)`` + +.. imgui-example:: + + selected = "scatter-1" + + for name in ["line-1", "line-2", "scatter-1"]: + clicked, _ = imgui.selectable(name, name == selected) + if clicked: + selected = name + +Color +----- + +A color is a list of floats in ``0.0`` to ``1.0``, three of them for RGB and four for RGBA. The ``3`` and ``4`` +variants differ only in whether they include alpha. + +color_edit3 +^^^^^^^^^^^ + +.. imgui-signature:: color_edit3 + +A row of numeric fields with a color square at its right end. Clicking the square opens a picker, right-clicking it +opens a menu of display options. + +**Parameters** + +* ``label`` - drawn to the right of the fields, ``"##hidden"`` suppresses it +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.9, 0.3, 0.2] + + changed, color = imgui.color_edit3("line color", color) + +color_edit4 +^^^^^^^^^^^ + +.. imgui-signature:: color_edit4 + +``color_edit3`` with an alpha field. + +**Parameters** + +* ``label`` - drawn to the right of the fields +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.9, 0.3, 0.2, 0.5] + + changed, color = imgui.color_edit4("fill color", color) + +color_picker3 +^^^^^^^^^^^^^ + +.. imgui-signature:: color_picker3 + +The full picker, drawn inline. ``color_edit3`` is the compact element and opens this in a popup when its square is +clicked. + +**Parameters** + +* ``label`` - drawn above the picker +* ``col`` - the current color + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.2, 0.6, 0.95] + + changed, color = imgui.color_picker3("##picker", color) + +color_picker4 +^^^^^^^^^^^^^ + +.. imgui-signature:: color_picker4 + +``color_picker3`` with an alpha bar. + +**Parameters** + +* ``label`` - drawn to the right of the picker +* ``col`` - the current color +* ``ref_col`` - a second color drawn beside the current one, to compare against + +**Returns:** ``(changed, col)`` + +.. imgui-example:: + + color = [0.2, 0.6, 0.95, 0.7] + + changed, color = imgui.color_picker4("##picker4", color) + +color_button +^^^^^^^^^^^^ + +.. imgui-signature:: color_button + +**Parameters** + +* ``desc_id`` - identifies the button, and is shown in its tooltip +* ``col`` - the color to draw, ``(r, g, b, a)`` +* ``size`` - ``(width, height)``, a zero component is a square the height of one row + +**Returns:** ``True`` on the frame the button is clicked + +.. imgui-example:: + + for name, color in [("magenta", (1.0, 0.0, 1.0, 1.0)), ("cyan", (0.0, 1.0, 1.0, 1.0))]: + if imgui.color_button(name, color, size=(40, 20)): + print(f"{name} clicked") + + imgui.same_line() + imgui.text(name) + +set_color_edit_options +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_color_edit_options + +Sets the defaults for every color element that follows, so each one does not have to pass the same flags. Call it once +when the UI is created. + +**Parameters** + +* ``flags`` - the options to apply + +.. imgui-example:: + + imgui.set_color_edit_options(int(imgui.ColorEditFlags_.float) | int(imgui.ColorEditFlags_.display_hsv)) + + color = [0.9, 0.3, 0.2] + changed, color = imgui.color_edit3("line color", color) + +Trees and tabs +-------------- + +tree_node +^^^^^^^^^ + +.. imgui-signature:: tree_node + +Returns ``True`` while the node is open, in which case its contents are drawn and ``tree_pop`` must be called. The +node is opened and closed by the user, clicking the arrow. + +**Parameters** + +* ``label`` - drawn next to the arrow, and used as the id +* ``str_id``, ``ptr_id`` - an id given separately, for when the label is not unique or changes between frames +* ``fmt`` - the text to draw when an id is given separately + +.. imgui-example:: + :interact: click 20 18 + + if imgui.tree_node("image-1"): + imgui.text("512 x 512, uint16") + imgui.text("vmin 12, vmax 208") + imgui.tree_pop() + +tree_node_ex +^^^^^^^^^^^^ + +.. imgui-signature:: tree_node_ex + +``tree_node`` with flags, e.g. to have the node start open, or to draw it without an arrow. + +**Parameters** + +* ``label`` - drawn next to the arrow, and used as the id +* ``str_id``, ``ptr_id`` - an id given separately +* ``fmt`` - the text to draw when an id is given separately + +.. imgui-example:: + + if imgui.tree_node_ex("image-1", flags=imgui.TreeNodeFlags_.default_open): + imgui.text("512 x 512, uint16") + imgui.tree_pop() + +tree_pop +^^^^^^^^ + +.. imgui-signature:: tree_pop + +**Parameters** + +none + +collapsing_header +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: collapsing_header + +A header that shows and hides a section. Unlike a tree node it does not indent its contents and needs no +``tree_pop``, which makes it the element for grouping controls. + +**Parameters** + +* ``label`` - drawn in the header +* ``p_visible`` - when given, a close button is drawn and this is set to ``False`` when it is clicked + +**Returns:** ``True`` while the header is open, or ``(open, p_visible)`` for the second form + +.. imgui-example:: + + sigma = 1.4 + + if imgui.collapsing_header("filter", flags=imgui.TreeNodeFlags_.default_open): + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + + if imgui.collapsing_header("export"): + imgui.text("not shown while the header is closed") + +set_next_item_open +^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_item_open + +Opens or closes the next tree node or collapsing header from code, rather than waiting for the user to click it. + +**Parameters** + +* ``is_open`` - the state to set +* ``cond`` - an ``imgui.Cond_`` value, e.g. ``once`` to set it only the first time + +.. imgui-example:: + + imgui.set_next_item_open(True, imgui.Cond_.once) + + if imgui.tree_node("image-1"): + imgui.text("open because set_next_item_open was called") + imgui.tree_pop() + +begin_tab_bar +^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tab_bar + +**Parameters** + +* ``str_id`` - identifies the tab bar, it is not drawn + +.. imgui-example:: + :name: begin_tab_bar + + if imgui.begin_tab_bar("panels"): + if imgui.begin_tab_item("image")[0]: + imgui.text("512 x 512, uint16") + imgui.end_tab_item() + + if imgui.begin_tab_item("filter")[0]: + imgui.text("gaussian, sigma 1.4") + imgui.end_tab_item() + + imgui.end_tab_bar() + +end_tab_bar +^^^^^^^^^^^ + +.. imgui-signature:: end_tab_bar + +Call it only when the matching ``begin_tab_bar`` returned ``True``. + +**Parameters** + +none + +begin_tab_item +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tab_item + +**Parameters** + +* ``label`` - drawn on the tab +* ``p_open`` - when given, a close button is drawn on the tab and this is set to ``False`` when it is clicked + +**Returns:** ``(selected, p_open)``, draw the contents and call ``end_tab_item`` while ``selected`` + +.. imgui-example:: + :name: begin_tab_item + :interact: click 90 22 + + if imgui.begin_tab_bar("panels"): + for label in ["image", "filter", "export"]: + selected, _ = imgui.begin_tab_item(label) + if selected: + imgui.text(f"{label} panel") + imgui.end_tab_item() + + imgui.end_tab_bar() + +end_tab_item +^^^^^^^^^^^^ + +.. imgui-signature:: end_tab_item + +Call it only when the matching ``begin_tab_item`` returned ``True``. + +**Parameters** + +none + +tab_item_button +^^^^^^^^^^^^^^^ + +.. imgui-signature:: tab_item_button + +**Parameters** + +* ``label`` - drawn on the tab + +**Returns:** ``True`` on the frame the tab is clicked + +.. imgui-example:: + + if imgui.begin_tab_bar("panels"): + if imgui.begin_tab_item("image")[0]: + imgui.end_tab_item() + + if imgui.tab_item_button("+"): + print("add panel") + + imgui.end_tab_bar() + +Menus +----- + +A menu bar belongs to a window, so the window has to be created with ``imgui.WindowFlags_.menu_bar``. + +begin_menu_bar +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_menu_bar + +**Parameters** + +none + +.. imgui-example:: + :name: begin_menu_bar + :window: none + :interact: click 30 22 + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((220, 120)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("File"): + imgui.menu_item("Open", "Ctrl+O", False) + imgui.menu_item("Save", "Ctrl+S", False) + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +end_menu_bar +^^^^^^^^^^^^ + +.. imgui-signature:: end_menu_bar + +Call it only when the matching ``begin_menu_bar`` returned ``True``. + +**Parameters** + +none + +begin_main_menu_bar +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_main_menu_bar + +A bar pinned across the top of the canvas, it is not part of any window. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_main_menu_bar + :window: none + :size: 260, 90 + :interact: click 60 10 + + if imgui.begin_main_menu_bar(): + if imgui.begin_menu("File"): + imgui.menu_item("Open", "Ctrl+O", False) + imgui.end_menu() + + if imgui.begin_menu("Help"): + imgui.menu_item("Version", "", False) + imgui.end_menu() + + imgui.end_main_menu_bar() + +end_main_menu_bar +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: end_main_menu_bar + +Call it only when the matching ``begin_main_menu_bar`` returned ``True``. + +**Parameters** + +none + +begin_menu +^^^^^^^^^^ + +.. imgui-signature:: begin_menu + +Returns ``True`` while the menu is open, in which case its items are drawn and ``end_menu`` must be called. A +``begin_menu`` inside another one is a submenu. + +**Parameters** + +* ``label`` - drawn on the menu +* ``enabled`` - a disabled menu is drawn greyed out and cannot be opened + +.. imgui-example:: + :name: begin_menu + :window: none + :size: 300, 140 + :interact: click 30 22; hover 45 66 + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((240, 130)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("Graphics"): + imgui.menu_item("Add line", "", False) + + if imgui.begin_menu("Add image"): + imgui.menu_item("from file", "", False) + imgui.menu_item("from array", "", False) + imgui.end_menu() + + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +end_menu +^^^^^^^^ + +.. imgui-signature:: end_menu + +Call it only when the matching ``begin_menu`` returned ``True``. + +**Parameters** + +none + +menu_item +^^^^^^^^^ + +.. imgui-signature:: menu_item + +**Parameters** + +* ``label`` - drawn on the item +* ``shortcut`` - drawn right-aligned on the item, it is a label only and does not bind the key +* ``p_selected`` - when ``True`` a check mark is drawn, pass it a bool to make the item a toggle +* ``enabled`` - a disabled item is drawn greyed out and cannot be clicked + +**Returns:** ``(clicked, p_selected)`` + +.. imgui-example:: + :window: none + :interact: click 30 22 + + show_fps = True + + imgui.set_next_window_pos((0, 0)) + imgui.set_next_window_size((230, 120)) + imgui.begin("controls", flags=imgui.WindowFlags_.menu_bar) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("View"): + clicked, show_fps = imgui.menu_item("Show fps", "", show_fps) + imgui.menu_item("Autoscale", "A", False) + imgui.menu_item("Reset camera", "", False, enabled=False) + imgui.end_menu() + + imgui.end_menu_bar() + + imgui.end() + +Popups and tooltips +------------------- + +A popup is opened by ``open_popup`` and drawn by ``begin_popup``, which returns ``True`` only while it is open. Both +have to be called for the same window, so calling ``open_popup`` from inside a menu does not open a popup that +``begin_popup`` draws outside of it. + +open_popup +^^^^^^^^^^ + +.. imgui-signature:: open_popup + +**Parameters** + +* ``str_id`` - identifies the popup, ``begin_popup`` is called with the same id +* ``id_`` - an integer id instead of a string one +* ``popup_flags`` - options such as not opening over a popup that is already open + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + if imgui.begin_popup("options"): + imgui.menu_item("reset vmin / vmax", "", False) + imgui.menu_item("reset gamma", "", False) + imgui.end_popup() + +begin_popup +^^^^^^^^^^^ + +.. imgui-signature:: begin_popup + +Call ``end_popup`` only when ``begin_popup`` returned ``True``. The popup closes when the user clicks outside it, or +when a menu item inside it is clicked. + +**Parameters** + +* ``str_id`` - the id that ``open_popup`` was called with + +.. imgui-example:: + :name: begin_popup + :interact: click 30 18 + + sigma = 1.4 + + if imgui.button("filter"): + imgui.open_popup("filter") + + if imgui.begin_popup("filter"): + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.end_popup() + +end_popup +^^^^^^^^^ + +.. imgui-signature:: end_popup + +Call it only when the matching ``begin_popup`` returned ``True``. + +**Parameters** + +none + +begin_popup_modal +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_modal + +A modal has a title bar and blocks everything behind it until it is closed. Passing ``p_open`` draws a close button in +its title bar. + +**Parameters** + +* ``name`` - the id that ``open_popup`` was called with, and the title +* ``p_open`` - when given, a close button is drawn and imgui closes the modal when it is clicked + +**Returns:** ``(open, p_open)`` + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("about"): + imgui.open_popup("About") + + if imgui.begin_popup_modal("About", True)[0]: + imgui.text("fastplotlib") + imgui.end_popup() + +close_current_popup +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: close_current_popup + +Closes the popup being drawn, for a control that should dismiss it. A menu item already does this on its own. + +**Parameters** + +none + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + if imgui.begin_popup("options"): + imgui.text("apply the filter to every frame?") + + if imgui.button("cancel"): + imgui.close_current_popup() + + imgui.end_popup() + +begin_popup_context_item +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_context_item + +Opens on a right-click on the element that precedes it, so a right-click menu needs no ``open_popup`` of its own. + +**Parameters** + +* ``str_id`` - identifies the popup, the preceding element is used when it is not given +* ``popup_flags`` - which mouse button opens it, right by default + +.. imgui-example:: + :interact: right_click 40 18 + + imgui.button("line-1") + + if imgui.begin_popup_context_item(): + imgui.menu_item("hide", "", False) + imgui.menu_item("delete", "", False) + imgui.end_popup() + +begin_popup_context_window +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_popup_context_window + +Opens on a right-click anywhere in the window that is not over an element. + +**Parameters** + +* ``str_id`` - identifies the popup +* ``popup_flags`` - which mouse button opens it, right by default + +.. imgui-example:: + :width: 180 + :interact: right_click 120 40 + + imgui.text("right click the window") + + if imgui.begin_popup_context_window(): + imgui.menu_item("add line", "", False) + imgui.menu_item("add image", "", False) + imgui.end_popup() + +is_popup_open +^^^^^^^^^^^^^ + +.. imgui-signature:: is_popup_open + +**Parameters** + +* ``str_id`` - the id the popup was opened with +* ``flags`` - use ``imgui.PopupFlags_.any_popup_id`` to ask about any popup + +**Returns:** ``True`` while the popup is open + +.. imgui-example:: + :interact: click 30 18 + + if imgui.button("options"): + imgui.open_popup("options") + + imgui.same_line() + imgui.text(f"open: {imgui.is_popup_open('options')}") + + if imgui.begin_popup("options"): + imgui.menu_item("reset", "", False) + imgui.end_popup() + +set_tooltip +^^^^^^^^^^^ + +.. imgui-signature:: set_tooltip + +**Parameters** + +* ``fmt`` - the text to draw in the tooltip + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button(fa.ICON_FA_MAXIMIZE) + + if imgui.is_item_hovered(): + imgui.set_tooltip("autoscale scene") + +set_item_tooltip +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_item_tooltip + +The same as ``set_tooltip`` behind an ``is_item_hovered`` check, for the common case of a tooltip on the element that +precedes it. + +**Parameters** + +* ``fmt`` - the text to draw in the tooltip + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button(fa.ICON_FA_ALIGN_CENTER) + imgui.set_item_tooltip("center scene") + +begin_tooltip +^^^^^^^^^^^^^ + +.. imgui-signature:: begin_tooltip + +A tooltip that holds any elements, not only text. Call ``end_tooltip`` only when ``begin_tooltip`` returned ``True``. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_tooltip + :interact: hover 30 18 + + imgui.button("image-1") + + if imgui.is_item_hovered() and imgui.begin_tooltip(): + imgui.text("image-1") + imgui.separator() + imgui.label_text("shape", "(512, 512)") + imgui.label_text("dtype", "uint16") + imgui.end_tooltip() + +end_tooltip +^^^^^^^^^^^ + +.. imgui-signature:: end_tooltip + +Call it only when the matching ``begin_tooltip`` returned ``True``. + +**Parameters** + +none + +Layout +------ + +Elements are stacked vertically in the order they are called. These change where the next element goes, so most of them +draw nothing by themselves and are shown here between elements that do. + +same_line +^^^^^^^^^ + +.. imgui-signature:: same_line + +**Parameters** + +* ``offset_from_start_x`` - x position in window coordinates, the default continues after the previous element +* ``spacing`` - gap in pixels, the default uses the style spacing + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.button("reset") + +new_line +^^^^^^^^ + +.. imgui-signature:: new_line + +**Parameters** + +none + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.new_line() + imgui.button("reset") + +separator +^^^^^^^^^ + +.. imgui-signature:: separator + +**Parameters** + +none + +.. imgui-example:: + + imgui.text("filter") + imgui.separator() + imgui.text("export") + +spacing +^^^^^^^ + +.. imgui-signature:: spacing + +**Parameters** + +none + +.. imgui-example:: + + imgui.button("apply") + imgui.spacing() + imgui.spacing() + imgui.button("reset") + +dummy +^^^^^ + +.. imgui-signature:: dummy + +An empty element of a given size, to leave a gap that spacing cannot make. It takes no pointer input, unlike +``invisible_button``. + +**Parameters** + +* ``size`` - ``(width, height)`` of the gap + +.. imgui-example:: + + imgui.button("apply") + imgui.same_line() + imgui.dummy((40, 0)) + imgui.same_line() + imgui.button("delete") + +indent +^^^^^^ + +.. imgui-signature:: indent + +**Parameters** + +* ``indent_w`` - width in pixels, the default uses the style indent + +.. imgui-example:: + :name: indent + + imgui.text("filter") + imgui.indent() + imgui.text("gaussian, sigma 1.4") + imgui.text("applied to every frame") + imgui.unindent() + imgui.text("export") + +unindent +^^^^^^^^ + +.. imgui-signature:: unindent + +**Parameters** + +* ``indent_w`` - width in pixels, the default uses the style indent + +begin_group +^^^^^^^^^^^ + +.. imgui-signature:: begin_group + +Everything between them becomes one item, so ``same_line`` places the whole group and ``is_item_hovered`` covers all of +it. + +**Parameters** + +none + +.. imgui-example:: + :name: begin_group + + imgui.begin_group() + imgui.text("vmin") + imgui.text("12") + imgui.end_group() + + imgui.same_line() + imgui.dummy((20, 0)) + imgui.same_line() + + imgui.begin_group() + imgui.text("vmax") + imgui.text("208") + imgui.end_group() + +end_group +^^^^^^^^^ + +.. imgui-signature:: end_group + +Ends the group, and makes everything in it one item for ``same_line`` and the item queries. + +**Parameters** + +none + +align_text_to_frame_padding +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: align_text_to_frame_padding + +Text is drawn without a frame, so on a row shared with a slider or a button it sits too high. Call this before the text +to line them up. + +**Parameters** + +none + +.. imgui-example:: + + sigma = 1.4 + + imgui.align_text_to_frame_padding() + imgui.text("sigma") + imgui.same_line() + changed, sigma = imgui.slider_float("##sigma", v=sigma, v_min=0.1, v_max=10.0) + +set_next_item_width +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_item_width + +**Parameters** + +* ``item_width`` - width in pixels, a negative value leaves that many pixels between the element and the right edge + +.. imgui-example:: + + vmin, vmax = 12.0, 208.0 + + imgui.set_next_item_width(80) + changed, vmin = imgui.slider_float("vmin", v=vmin, v_min=0.0, v_max=255.0, format="%.0f") + + imgui.set_next_item_width(80) + changed, vmax = imgui.slider_float("vmax", v=vmax, v_min=0.0, v_max=255.0, format="%.0f") + +push_item_width +^^^^^^^^^^^^^^^ + +.. imgui-signature:: push_item_width + +The same as ``set_next_item_width`` but for every element until ``pop_item_width``. + +**Parameters** + +* ``item_width`` - width in pixels, a negative value leaves that many pixels between the element and the right edge + +.. imgui-example:: + :name: push_item_width + + vmin, vmax = 12.0, 208.0 + + imgui.push_item_width(80) + changed, vmin = imgui.slider_float("vmin", v=vmin, v_min=0.0, v_max=255.0, format="%.0f") + changed, vmax = imgui.slider_float("vmax", v=vmax, v_min=0.0, v_max=255.0, format="%.0f") + imgui.pop_item_width() + +pop_item_width +^^^^^^^^^^^^^^ + +.. imgui-signature:: pop_item_width + +Pops the width that ``push_item_width`` pushed. + +**Parameters** + +none + +calc_text_size +^^^^^^^^^^^^^^ + +.. imgui-signature:: calc_text_size + +**Parameters** + +* ``text`` - the text to measure +* ``text_end`` - measure up to this substring +* ``hide_text_after_double_hash`` - ignore everything after ``##``, as the elements do with their labels +* ``wrap_width`` - measure as if the text were wrapped at this width + +**Returns:** the size, use ``.x`` and ``.y`` + +.. imgui-example:: + + label = "vmin / vmax" + size = imgui.calc_text_size(label) + + imgui.text(label) + imgui.text(f"that text is {size.x:.0f} x {size.y:.0f} px") + +get_content_region_avail +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_content_region_avail + +The space left in the window from the current position, which is how an element is sized to fill the window. + +**Parameters** + +none + +**Returns:** the available size, use ``.x`` and ``.y`` + +.. imgui-example:: + :width: 200 + + available = imgui.get_content_region_avail() + + imgui.text(f"{available.x:.0f} x {available.y:.0f} px left") + imgui.button("fill the width", (available.x, 0)) + +get_cursor_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: get_cursor_pos + +Where the next element goes, in window coordinates. + +**Parameters** + +* ``local_pos`` - ``(x, y)`` in window coordinates + +.. imgui-example:: + :name: set_cursor_pos + + imgui.set_cursor_pos((60, 30)) + imgui.button("moved") + +set_cursor_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: set_cursor_pos + +Moves the position of the next element, in window coordinates. + +**Parameters** + +* ``local_pos`` - ``(x, y)`` in window coordinates + +.. imgui-example:: + + imgui.set_cursor_pos((60, 30)) + imgui.button("moved") + +get_cursor_screen_pos +^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_cursor_screen_pos + +The same position in canvas coordinates, which is what a draw list takes. + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates + +.. imgui-example:: + :name: get_cursor_screen_pos + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + draw_list.add_rect_filled( + position, + (position.x + 60, position.y + 20), + imgui.color_convert_float4_to_u32((0.2, 0.6, 0.95, 1.0)), + ) + imgui.dummy((60, 20)) + +set_cursor_screen_pos +^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_cursor_screen_pos + +Moves the position of the next element, in canvas coordinates. + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates + +get_text_line_height +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_text_line_height + +The height of a line of text, and the height of an element that has a frame such as a button or a slider. Use them to +size something you draw yourself so that it lines up with the elements around it. + +**Parameters** + +none + +.. imgui-example:: + :name: get_frame_height + + imgui.text(f"text line: {imgui.get_text_line_height():.0f} px") + imgui.text(f"framed element: {imgui.get_frame_height():.0f} px") + +get_frame_height +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_frame_height + +The height of an element that has a frame, such as a button or a slider. + +**Parameters** + +none + +**Returns:** the height in pixels + +.. imgui-example:: + + imgui.text(f"framed element: {imgui.get_frame_height():.0f} px") + +Windows +------- + +In fastplotlib the window is created for you, ``ImguiWindow.update()`` draws into it. These are for a window you create +yourself, inside an overridden ``ImguiWindow.draw()``. + +begin +^^^^^ + +.. imgui-signature:: begin + +``end`` is called whether or not ``begin`` returned ``True``. ``begin`` returns ``False`` when the window is collapsed, +in which case its contents can be skipped. + +**Parameters** + +* ``name`` - the title, and the id of the window, ``"title##id"`` separates the two +* ``p_open`` - when given, a close button is drawn in the title bar and this is set to ``False`` when it is clicked + +**Returns:** ``(expanded, p_open)`` + +.. imgui-example:: + :window: none + :size: 240, 120 + + expanded, open_ = imgui.begin("filter", True) + + if expanded: + imgui.text("gaussian") + + imgui.end() + +end +^^^ + +.. imgui-signature:: end + +Called whether or not ``begin`` returned ``True``. + +**Parameters** + +none + +begin_child +^^^^^^^^^^^ + +.. imgui-signature:: begin_child + +A region within a window, with its own scrolling and clipping. Use it for a list that should scroll on its own. + +**Parameters** + +* ``str_id``, ``id_`` - identifies the region +* ``size`` - ``(width, height)``, a zero component fills the available space, a negative one leaves that many pixels + +.. imgui-example:: + :name: begin_child + + if imgui.begin_child("graphics", (160, 80), child_flags=imgui.ChildFlags_.borders): + for i in range(8): + imgui.text(f"line-{i}") + + imgui.end_child() + +end_child +^^^^^^^^^ + +.. imgui-signature:: end_child + +Call it only when the matching ``begin_child`` returned ``True``. + +**Parameters** + +none + +set_next_window_pos +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_pos + +**Parameters** + +* ``pos`` - ``(x, y)`` in canvas coordinates +* ``cond`` - an ``imgui.Cond_`` value, e.g. ``appearing`` to place it only when it first appears so the user can move it +* ``pivot`` - which point of the window lands on ``pos``, ``(0.5, 0.5)`` centers it there + +.. imgui-example:: + :window: none + :size: 260, 130 + + imgui.set_next_window_pos((40, 30)) + imgui.set_next_window_size((160, 60)) + imgui.begin("filter") + imgui.text("placed at 40, 30") + imgui.end() + +set_next_window_size +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_size + +**Parameters** + +* ``size`` - ``(width, height)``, a zero component makes that axis fit its contents +* ``cond`` - an ``imgui.Cond_`` value + +.. imgui-example:: + :window: none + :size: 240, 120 + + imgui.set_next_window_size((150, 0)) + imgui.begin("filter") + imgui.text("fixed width, auto height") + imgui.end() + +set_next_window_collapsed +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_next_window_collapsed + +**Parameters** + +* ``collapsed`` - the state to set +* ``cond`` - an ``imgui.Cond_`` value + +.. imgui-example:: + :window: none + :size: 240, 90 + + imgui.set_next_window_collapsed(True) + imgui.begin("filter") + imgui.text("not drawn while collapsed") + imgui.end() + +get_window_pos +^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_pos + +The position and size of the window being drawn. For laying out contents, ``get_content_region_avail`` is what you +want, since it accounts for padding and for the position within the window. + +**Parameters** + +none + +.. imgui-example:: + :name: get_window_size + :width: 200 + + size = imgui.get_window_size() + + imgui.text(f"window: {size.x:.0f} x {size.y:.0f} px") + +get_window_size +^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_size + +**Parameters** + +none + +**Returns:** the size, use ``.x`` and ``.y`` + +.. imgui-example:: + :width: 200 + + size = imgui.get_window_size() + + imgui.text(f"window: {size.x:.0f} x {size.y:.0f} px") + +get_window_width +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_width + +**Parameters** + +none + +**Returns:** the width in pixels + +get_window_height +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_height + +**Parameters** + +none + +**Returns:** the height in pixels + +get_window_draw_list +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_window_draw_list + +The draw list of the window, for drawing shapes and text yourself. Positions are in canvas coordinates, so they start +from ``get_cursor_screen_pos``. + +**Parameters** + +none + +**Returns:** an ``imgui.ImDrawList`` + +.. imgui-example:: + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + blue = imgui.color_convert_float4_to_u32((0.2, 0.6, 0.95, 1.0)) + + draw_list.add_rect_filled(position, (position.x + 120, position.y + 8), blue) + draw_list.add_circle_filled((position.x + 30, position.y + 30), 8, white) + draw_list.add_text((position.x + 50, position.y + 22), white, "drawn by hand") + + imgui.dummy((120, 45)) + +set_scroll_here_y +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: set_scroll_here_y + +``set_scroll_here_y`` scrolls to the element that was just drawn, which is how a list follows a selection. + +**Parameters** + +* ``center_y_ratio`` - where the element ends up, ``0.0`` top, ``0.5`` center, ``1.0`` bottom +* ``scroll_y`` - the scroll amount in pixels + +.. imgui-example:: + :name: set_scroll_here_y + + if imgui.begin_child("graphics", (160, 70), child_flags=imgui.ChildFlags_.borders): + for i in range(10): + imgui.text(f"line-{i}") + + if i == 6: + imgui.set_scroll_here_y(0.5) + + imgui.end_child() + +get_scroll_y +^^^^^^^^^^^^ + +.. imgui-signature:: get_scroll_y + +**Parameters** + +none + +**Returns:** the scroll amount in pixels + +set_scroll_y +^^^^^^^^^^^^ + +.. imgui-signature:: set_scroll_y + +**Parameters** + +* ``scroll_y`` - the scroll amount in pixels + +Style and ids +------------- + +Every push has a matching pop. A push that is not popped leaks into everything drawn afterwards, including elements +that fastplotlib draws. + +push_id +^^^^^^^ + +.. imgui-signature:: push_id + +imgui identifies an element by its label, so two elements with the same label are the same element and share their +state. Push an id around them to keep them apart, which is what a loop over graphics needs. + +**Parameters** + +* ``str_id``, ``int_id``, ``ptr_id`` - the value to push, it is hashed and is not drawn +* ``str_id_begin``, ``str_id_end`` - a substring to push + +.. imgui-example:: + :name: push_id + + thickness = {"line-1": 4.0, "line-2": 9.0} + + for name in thickness: + imgui.push_id(name) + + imgui.text(name) + imgui.same_line() + changed, thickness[name] = imgui.slider_float("##thickness", v=thickness[name], v_min=1.0, v_max=20.0) + + imgui.pop_id() + +pop_id +^^^^^^ + +.. imgui-signature:: pop_id + +Pops the id that ``push_id`` pushed. + +**Parameters** + +none + +push_style_color +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: push_style_color + +**Parameters** + +* ``idx`` - which color, an ``imgui.Col_`` value +* ``col`` - the color, ``(r, g, b, a)`` or a packed ``int`` +* ``count`` - how many pushes to pop + +.. imgui-example:: + :name: push_style_color + + imgui.push_style_color(imgui.Col_.button, (0.6, 0.15, 0.15, 1.0)) + imgui.push_style_color(imgui.Col_.button_hovered, (0.75, 0.2, 0.2, 1.0)) + + imgui.button("delete graphic") + + imgui.pop_style_color(2) + + imgui.button("keep graphic") + +pop_style_color +^^^^^^^^^^^^^^^ + +.. imgui-signature:: pop_style_color + +**Parameters** + +* ``count`` - how many pushed colors to pop + +push_style_var +^^^^^^^^^^^^^^ + +.. imgui-signature:: push_style_var + +**Parameters** + +* ``idx`` - which variable, an ``imgui.StyleVar_`` value +* ``val`` - a float, or ``(x, y)`` for the variables that are a pair +* ``count`` - how many pushes to pop + +.. imgui-example:: + :name: push_style_var + + imgui.push_style_var(imgui.StyleVar_.frame_rounding, 10.0) + imgui.button("rounded") + imgui.pop_style_var() + + imgui.button("default") + +pop_style_var +^^^^^^^^^^^^^ + +.. imgui-signature:: pop_style_var + +**Parameters** + +* ``count`` - how many pushed variables to pop + +get_style_color_vec4 +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_style_color_vec4 + +**Parameters** + +* ``idx`` - which color, an ``imgui.Col_`` value + +**Returns:** the color, use ``.x``, ``.y``, ``.z``, ``.w`` for r, g, b, a + +.. imgui-example:: + + color = imgui.get_style_color_vec4(imgui.Col_.text) + + imgui.text(f"text color: {color.x:.2f}, {color.y:.2f}, {color.z:.2f}") + +get_color_u32 +^^^^^^^^^^^^^ + +.. imgui-signature:: get_color_u32 + +A draw list takes a packed 32-bit color, not a tuple. ``get_color_u32`` packs a style color or your own color and +applies the global style alpha, ``color_convert_float4_to_u32`` packs a color as it is. + +**Parameters** + +* ``idx`` - which style color, an ``imgui.Col_`` value +* ``col`` - a color, ``(r, g, b, a)`` or a packed ``int`` +* ``alpha_mul`` - multiplies the alpha +* ``in_`` - the color to pack, ``(r, g, b, a)`` + +**Returns:** the packed color + +.. imgui-example:: + :name: get_color_u32 + + draw_list = imgui.get_window_draw_list() + position = imgui.get_cursor_screen_pos() + + draw_list.add_rect_filled( + position, (position.x + 60, position.y + 20), imgui.get_color_u32(imgui.Col_.button) + ) + draw_list.add_rect_filled( + (position.x + 70, position.y), + (position.x + 130, position.y + 20), + imgui.color_convert_float4_to_u32((1.0, 0.8, 0.2, 1.0)), + ) + + imgui.dummy((130, 20)) + +color_convert_float4_to_u32 +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: color_convert_float4_to_u32 + +Packs a color as it is, without applying the style alpha. + +**Parameters** + +* ``in_`` - the color to pack, ``(r, g, b, a)`` + +**Returns:** the packed color + +get_font_size +^^^^^^^^^^^^^ + +.. imgui-signature:: get_font_size + +**Parameters** + +none + +**Returns:** the height of the font in pixels + +.. imgui-example:: + + imgui.text(f"font size: {imgui.get_font_size():.0f} px") + +begin_disabled +^^^^^^^^^^^^^^ + +.. imgui-signature:: begin_disabled + +Everything between them is greyed out and takes no input, for a control that does not apply yet. + +**Parameters** + +* ``disabled`` - pass ``False`` to leave the elements enabled, so the call can be made unconditionally + +.. imgui-example:: + :name: begin_disabled + + apply_filter, sigma = False, 1.4 + + changed, apply_filter = imgui.checkbox("gaussian filter", apply_filter) + + imgui.begin_disabled(not apply_filter) + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.end_disabled() + +end_disabled +^^^^^^^^^^^^ + +.. imgui-signature:: end_disabled + +Ends the block that ``begin_disabled`` started. + +**Parameters** + +none + +Queries +------- + +These ask about the element that was drawn last, about the window, or about the mouse and keyboard. The item queries +refer to the element immediately above them, so they go straight after the element they ask about. + +The examples below print what they return, and the images were captured with the pointer over the element or a button +held down, which is why they read ``True``. + +is_item_hovered +^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_hovered + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button("autoscale") + imgui.text(f"hovered: {imgui.is_item_hovered()}") + +is_item_active +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_active + +.. imgui-example:: + :interact: press 30 18 + + imgui.button("autoscale") + imgui.text(f"active: {imgui.is_item_active()}") + +is_item_clicked +^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_clicked + +**Parameters** + +* ``mouse_button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: press 30 18 + + imgui.button("autoscale") + imgui.text(f"clicked: {imgui.is_item_clicked()}") + +is_item_edited +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_edited + +``is_item_deactivated_after_edit`` is the one to use for work that is too expensive to run while a slider is being +dragged, since it is ``True`` only on the frame the drag ends. + +.. imgui-example:: + :name: is_item_deactivated_after_edit + :interact: drag 60 18 100 18 + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + + imgui.text(f"edited: {imgui.is_item_edited()}") + imgui.text(f"activated: {imgui.is_item_activated()}") + imgui.text(f"finished: {imgui.is_item_deactivated_after_edit()}") + +is_item_activated +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_activated + +``True`` on the frame the element became active, e.g. the frame a drag started. + +**Parameters** + +none + +.. imgui-example:: + :interact: press 60 18 + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.text(f"activated: {imgui.is_item_activated()}") + +is_item_deactivated_after_edit +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_item_deactivated_after_edit + +``True`` only on the frame an edit ends, which is what to use for work that is too expensive to run while a +slider is being dragged. + +**Parameters** + +none + +.. imgui-example:: + :interact: drag 60 18 100 18; release + + sigma = 1.4 + + changed, sigma = imgui.slider_float("sigma", v=sigma, v_min=0.1, v_max=10.0) + imgui.text(f"finished: {imgui.is_item_deactivated_after_edit()}") + +is_any_item_hovered +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_any_item_hovered + +.. imgui-example:: + :interact: hover 30 18 + + imgui.button("autoscale") + imgui.button("center") + + imgui.text(f"any hovered: {imgui.is_any_item_hovered()}") + +is_window_hovered +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_hovered + +.. imgui-example:: + :interact: hover 60 40 + + imgui.text(f"window hovered: {imgui.is_window_hovered()}") + +is_window_focused +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_focused + +.. imgui-example:: + :interact: click 60 40 + + imgui.text(f"window focused: {imgui.is_window_focused()}") + +is_window_appearing +^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_window_appearing + +``True`` on the first frame the window is drawn, for setup that should happen once, such as sizing a table column. + +**Parameters** + +none + +.. imgui-example:: + + imgui.text(f"appearing: {imgui.is_window_appearing()}") + +is_mouse_down +^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_down + +These ask about the mouse anywhere, not about an element. A right-click that should open something belongs in +``begin_popup_context_item`` instead. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``repeat`` - report repeats while the button is held + +.. imgui-example:: + :name: is_mouse_down + :interact: press 60 40 + + imgui.text(f"left down: {imgui.is_mouse_down(0)}") + imgui.text(f"left clicked: {imgui.is_mouse_clicked(0)}") + imgui.text(f"right down: {imgui.is_mouse_down(1)}") + +is_mouse_clicked +^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_clicked + +``True`` on the frame the button goes down. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``repeat`` - report repeats while the button is held + +.. imgui-example:: + :interact: press 60 30 + + imgui.text(f"left clicked: {imgui.is_mouse_clicked(0)}") + +is_mouse_released +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_released + +``True`` on the frame the button goes up. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: click 60 30 + + imgui.text(f"left released: {imgui.is_mouse_released(0)}") + +is_mouse_double_clicked +^^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_double_clicked + +``True`` on the frame of the second click of a double click. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +.. imgui-example:: + :interact: double_click 60 30 + + imgui.text(f"double clicked: {imgui.is_mouse_double_clicked(0)}") + +is_mouse_dragging +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: is_mouse_dragging + +The delta is measured from where the button went down. Reset it each frame to get the movement since the last frame, +which is what a drag handle needs. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``lock_threshold`` - how far the pointer must move before it counts as a drag, the default uses the style threshold + +.. imgui-example:: + :name: is_mouse_dragging + :interact: drag 40 30 90 45 + + delta = imgui.get_mouse_drag_delta(0) + + imgui.text(f"dragging: {imgui.is_mouse_dragging(0)}") + imgui.text(f"delta: {delta.x:.0f}, {delta.y:.0f}") + +get_mouse_drag_delta +^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: get_mouse_drag_delta + +The movement since the button went down, in pixels. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle +* ``lock_threshold`` - how far the pointer must move before it counts as a drag + +**Returns:** the delta, use ``.x`` and ``.y`` + +.. imgui-example:: + :interact: drag 40 30 90 45 + + delta = imgui.get_mouse_drag_delta(0) + + imgui.text(f"delta: {delta.x:.0f}, {delta.y:.0f}") + +reset_mouse_drag_delta +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: reset_mouse_drag_delta + +Sets the delta back to zero, call it each frame to get the movement since the last frame rather than since the +button went down. + +**Parameters** + +* ``button`` - ``0`` left, ``1`` right, ``2`` middle + +get_mouse_pos +^^^^^^^^^^^^^ + +.. imgui-signature:: get_mouse_pos + +**Parameters** + +none + +**Returns:** the pointer position in canvas coordinates, use ``.x`` and ``.y`` + +.. imgui-example:: + :interact: hover 70 30 + + position = imgui.get_mouse_pos() + + imgui.text(f"pointer: {position.x:.0f}, {position.y:.0f}") + +is_key_pressed +^^^^^^^^^^^^^^ + +.. imgui-signature:: is_key_pressed + +**Parameters** + +* ``key`` - an ``imgui.Key`` member, e.g. ``imgui.Key.right_arrow`` +* ``repeat`` - report repeats while the key is held + +.. imgui-example:: + :name: is_key_pressed + :interact: hover 60 30; key right_arrow + + index = 42 + + if imgui.is_key_pressed(imgui.Key.right_arrow): + index += 1 + + if imgui.is_key_pressed(imgui.Key.left_arrow): + index -= 1 + + imgui.text(f"index: {index}") + +is_key_down +^^^^^^^^^^^ + +.. imgui-signature:: is_key_down + +``True`` while the key is held, rather than only on the frame it goes down. + +**Parameters** + +* ``key`` - an ``imgui.Key`` member + +.. imgui-example:: + :interact: hover 60 30; key left_shift + + imgui.text(f"shift held: {imgui.is_key_down(imgui.Key.left_shift)}") + +get_io +^^^^^^ + +.. imgui-signature:: get_io + +The imgui io structure. ``want_capture_mouse`` is the field to know about: it is ``True`` while imgui is using the +pointer, and fastplotlib relies on it to keep clicks on a UI from reaching the plot. + +**Parameters** + +none + +**Returns:** an ``imgui.IO`` + +.. imgui-example:: + :interact: hover 60 30 + + io = imgui.get_io() + + imgui.text(f"framerate: {io.framerate:.0f}") + imgui.text(f"capture mouse: {io.want_capture_mouse}") + +Plots +----- + +These draw a small line plot or histogram from an array of values, for a preview next to the controls. They are not a +plotting library, a fastplotlib subplot is. + +``values`` must be a contiguous ``float32`` array. + +plot_lines +^^^^^^^^^^ + +.. imgui-signature:: plot_lines + +**Parameters** + +* ``label`` - drawn to the right of the plot, ``"##hidden"`` suppresses it +* ``values`` - the values to plot +* ``values_offset`` - index to start from, for a ring buffer +* ``overlay_text`` - text drawn over the plot +* ``scale_min``, ``scale_max`` - the y range, the default fits the values +* ``graph_size`` - ``(width, height)``, a zero component is a default size +* ``stride`` - byte stride between values, for a column of a 2d array + +.. imgui-example:: + + values = np.sin(np.linspace(0, 4 * np.pi, 100)).astype(np.float32) + + imgui.plot_lines("##trace", values, graph_size=(220, 60), overlay_text="channel 0") + +plot_histogram +^^^^^^^^^^^^^^ + +.. imgui-signature:: plot_histogram + +**Parameters** + +* ``label`` - drawn to the right of the plot +* ``values`` - the bin counts +* ``values_offset`` - index to start from +* ``overlay_text`` - text drawn over the plot +* ``scale_min``, ``scale_max`` - the y range, the default fits the values +* ``graph_size`` - ``(width, height)``, a zero component is a default size +* ``stride`` - byte stride between values + +.. imgui-example:: + + data = np.random.normal(loc=120, scale=30, size=100_000) + counts = np.histogram(data, bins=64)[0].astype(np.float32) + + imgui.plot_histogram("##histogram", counts, graph_size=(220, 60)) + +image +^^^^^ + +.. imgui-signature:: image + +Draws a texture that you have uploaded to the GPU and registered with the imgui renderer, which is how +``ImguiColorbar`` draws its colormap bar. There is no example here because the texture has to come from the wgpu +device of the Figure:: + + texture_ref = figure.imgui_renderer.backend.register_texture(texture.create_view()) + imgui.image(texture_ref, (24, 200)) + +**Parameters** + +* ``tex_ref`` - an ``imgui.ImTextureRef`` from ``register_texture`` +* ``image_size`` - ``(width, height)`` to draw it at +* ``uv0``, ``uv1`` - the region of the texture to draw, ``(0, 0)`` to ``(1, 1)`` by default + +image_button +^^^^^^^^^^^^ + +.. imgui-signature:: image_button + +``image`` that responds to a click. + +**Parameters** + +* ``str_id`` - identifies the button +* ``tex_ref`` - an ``imgui.ImTextureRef`` from ``register_texture`` +* ``image_size`` - ``(width, height)`` to draw it at +* ``uv0``, ``uv1`` - the region of the texture to draw +* ``bg_col``, ``tint_col`` - background drawn behind the image, and a color the image is multiplied by + +**Returns:** ``True`` on the frame the button is clicked + +Tables +------ + +A table is opened with ``begin_table``, and ``end_table`` is called only when it returned ``True``. Cells are filled by +walking rows and columns, either with ``table_next_column`` or by setting the column index. + +begin_table +^^^^^^^^^^^ + +.. imgui-signature:: begin_table + +**Parameters** + +* ``str_id`` - identifies the table +* ``columns`` - how many columns +* ``outer_size`` - ``(width, height)`` of the table, a zero height fits the rows +* ``inner_width`` - width of the scrolling region when the table scrolls horizontally + +.. imgui-example:: + :name: begin_table + + graphics = [("line-1", "LineGraphic", True), ("image-1", "ImageGraphic", False)] + + if imgui.begin_table("graphics", 3, flags=imgui.TableFlags_.borders): + for name, kind, visible in graphics: + imgui.table_next_row() + + imgui.table_next_column() + imgui.text(name) + + imgui.table_next_column() + imgui.text(kind) + + imgui.table_next_column() + imgui.text("visible" if visible else "hidden") + + imgui.end_table() + +end_table +^^^^^^^^^ + +.. imgui-signature:: end_table + +Call it only when the matching ``begin_table`` returned ``True``. + +**Parameters** + +none + +table_next_row +^^^^^^^^^^^^^^ + +.. imgui-signature:: table_next_row + +**Parameters** + +* ``min_row_height`` - minimum height of the row in pixels + +.. imgui-example:: + + if imgui.begin_table("frames", 2, flags=imgui.TableFlags_.borders): + for index in range(3): + imgui.table_next_row(min_row_height=24) + + imgui.table_next_column() + imgui.text(f"frame {index}") + + imgui.table_next_column() + imgui.text(f"{index * 40} ms") + + imgui.end_table() + +table_next_column +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_next_column + +``table_next_column`` moves to the next cell, wrapping to the first column of the next row. Use +``table_set_column_index`` to fill cells out of order. + +**Parameters** + +* ``column_n`` - the column to move to + +**Returns:** ``True`` when the column is visible, a clipped or hidden column can be skipped + +.. imgui-example:: + :name: table_set_column_index + + if imgui.begin_table("stats", 2, flags=imgui.TableFlags_.borders): + for label, value in [("vmin", "12"), ("vmax", "208")]: + imgui.table_next_row() + + imgui.table_set_column_index(0) + imgui.text(label) + + imgui.table_set_column_index(1) + imgui.text(value) + + imgui.end_table() + +table_set_column_index +^^^^^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_set_column_index + +Fills a cell out of order, rather than moving to the next one. + +**Parameters** + +* ``column_n`` - the column to move to + +**Returns:** ``True`` when the column is visible + +table_setup_column +^^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_setup_column + +Declare the columns before any row, then ``table_headers_row`` draws one row with their labels. + +**Parameters** + +* ``label`` - the column header +* ``init_width_or_weight`` - a starting width in pixels, or a share of the table width for a stretched column. + imgui rejects it unless the sizing policy is explicit, so pass ``imgui.TableColumnFlags_.width_fixed`` or + ``width_stretch`` with it +* ``user_id`` - an id you can read back when sorting + +.. imgui-example:: + :name: table_headers_row + + if imgui.begin_table("graphics", 2, flags=imgui.TableFlags_.borders): + imgui.table_setup_column("name", flags=imgui.TableColumnFlags_.width_fixed, init_width_or_weight=90) + imgui.table_setup_column("type") + imgui.table_headers_row() + + for name, kind in [("line-1", "LineGraphic"), ("image-1", "ImageGraphic")]: + imgui.table_next_row() + + imgui.table_next_column() + imgui.text(name) + + imgui.table_next_column() + imgui.text(kind) + + imgui.end_table() + +table_headers_row +^^^^^^^^^^^^^^^^^ + +.. imgui-signature:: table_headers_row + +Draws one row of headers from the labels given to ``table_setup_column``. + +**Parameters** + +none diff --git a/docs/source/imgui/reference/flags.rst b/docs/source/imgui/reference/flags.rst new file mode 100644 index 000000000..dffd47bb0 --- /dev/null +++ b/docs/source/imgui/reference/flags.rst @@ -0,0 +1,154 @@ +Flags +===== + +Flags are passed as ``int``. The values are ``enum.IntFlag`` members of the classes below and can be +combined with ``|``:: + + imgui.slider_float( + "gamma", v=gamma, v_min=0.1, v_max=5.0, + flags=imgui.SliderFlags_.logarithmic | imgui.SliderFlags_.no_input, + ) + +``Col_``, ``Cond_``, ``StyleVar_`` hold single values rather than flags, they are listed here because the +elements take them. + +.. _imgui.ButtonFlags_: + +imgui.ButtonFlags\_ +------------------- + +.. imgui-flags:: ButtonFlags_ + +.. _imgui.ChildFlags_: + +imgui.ChildFlags\_ +------------------ + +.. imgui-flags:: ChildFlags_ + +.. _imgui.Col_: + +imgui.Col\_ +----------- + +.. imgui-flags:: Col_ + +.. _imgui.ColorEditFlags_: + +imgui.ColorEditFlags\_ +---------------------- + +.. imgui-flags:: ColorEditFlags_ + +.. _imgui.ComboFlags_: + +imgui.ComboFlags\_ +------------------ + +.. imgui-flags:: ComboFlags_ + +.. _imgui.Cond_: + +imgui.Cond\_ +------------ + +.. imgui-flags:: Cond_ + +.. _imgui.FocusedFlags_: + +imgui.FocusedFlags\_ +-------------------- + +.. imgui-flags:: FocusedFlags_ + +.. _imgui.HoveredFlags_: + +imgui.HoveredFlags\_ +-------------------- + +.. imgui-flags:: HoveredFlags_ + +.. _imgui.InputTextFlags_: + +imgui.InputTextFlags\_ +---------------------- + +.. imgui-flags:: InputTextFlags_ + +.. _imgui.PopupFlags_: + +imgui.PopupFlags\_ +------------------ + +.. imgui-flags:: PopupFlags_ + +.. _imgui.SelectableFlags_: + +imgui.SelectableFlags\_ +----------------------- + +.. imgui-flags:: SelectableFlags_ + +.. _imgui.SliderFlags_: + +imgui.SliderFlags\_ +------------------- + +.. imgui-flags:: SliderFlags_ + +.. _imgui.StyleVar_: + +imgui.StyleVar\_ +---------------- + +.. imgui-flags:: StyleVar_ + +.. _imgui.TabBarFlags_: + +imgui.TabBarFlags\_ +------------------- + +.. imgui-flags:: TabBarFlags_ + +.. _imgui.TabItemFlags_: + +imgui.TabItemFlags\_ +-------------------- + +.. imgui-flags:: TabItemFlags_ + +.. _imgui.TableColumnFlags_: + +imgui.TableColumnFlags\_ +------------------------ + +.. imgui-flags:: TableColumnFlags_ + +.. _imgui.TableFlags_: + +imgui.TableFlags\_ +------------------ + +.. imgui-flags:: TableFlags_ + +.. _imgui.TableRowFlags_: + +imgui.TableRowFlags\_ +--------------------- + +.. imgui-flags:: TableRowFlags_ + +.. _imgui.TreeNodeFlags_: + +imgui.TreeNodeFlags\_ +--------------------- + +.. imgui-flags:: TreeNodeFlags_ + +.. _imgui.WindowFlags_: + +imgui.WindowFlags\_ +------------------- + +.. imgui-flags:: WindowFlags_ + diff --git a/docs/source/imgui/reference/index.rst b/docs/source/imgui/reference/index.rst new file mode 100644 index 000000000..981a247dd --- /dev/null +++ b/docs/source/imgui/reference/index.rst @@ -0,0 +1,8 @@ +imgui reference +*************** + +.. toctree:: + :maxdepth: 3 + + elements + flags diff --git a/docs/source/index.rst b/docs/source/index.rst index c44f4e3a8..68c28a577 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,6 +6,17 @@ Welcome to fastplotlib's documentation! :maxdepth: 2 user_guide/index + +.. toctree:: + :caption: imgui + :maxdepth: 2 + + imgui/index + +.. toctree:: + :caption: Developer notes + :maxdepth: 2 + developer_notes/index .. toctree:: diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 42f168bea..c55c71722 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -471,6 +471,17 @@ cmap | value | str | new cmap name | +----------+------+---------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ @@ -603,6 +614,154 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +ImageYUVGraphic +--------------- + +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 | ++----------+--------------------------------------+--------------------------------------------------+ + +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + +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 | ++----------+------+--------------------------------------------+ + +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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 ------------------ @@ -630,6 +789,17 @@ cmap | value | str | new cmap name | +----------+------+---------------+ +gamma +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new gamma value | ++----------+-------+-----------------+ + vmin ^^^^ @@ -1860,78 +2030,963 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -LinearSelector --------------- - -selection -^^^^^^^^^ - -**extra attributes** +ScatterCollection +----------------- -+--------------------+----------+----------------------------------+ -| attribute | type | description | -+====================+==========+==================================+ -| get_selected_index | callable | returns index under the selector | -+--------------------+----------+----------------------------------+ +data +^^^^ **event info dict** -+----------+-------+-------------------------------+ -| dict key | type | description | -+==========+=======+===============================+ -| value | float | new x or y value of selection | -+----------+-------+-------------------------------+ ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ -name -^^^^ +sizes +^^^^^ **event info dict** -+----------+------+--------------------+ -| dict key | type | description | -+==========+======+====================+ -| value | str | user provided name | -+----------+------+--------------------+ ++----------+----------------------------------------------+----------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==============================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | ++----------+----------------------------------------------+----------------------------------------------+ +| value | int | float | array-like | new size values for points that were changed | ++----------+----------------------------------------------+----------------------------------------------+ -offset -^^^^^^ +sizes +^^^^^ **event info dict** -+----------+---------------------------------+----------------------+ -| dict key | type | description | -+==========+=================================+======================+ -| value | np.ndarray[float, float, float] | new offset (x, y, z) | -+----------+---------------------------------+----------------------+ ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new size value | ++----------+-------+----------------+ -rotation -^^^^^^^^ +colors +^^^^^^ **event info dict** -+----------+----------------------------------------+-------------------------+ -| dict key | type | description | -+==========+========================================+=========================+ -| value | np.ndarray[float, float, float, float] | new rotation quaternion | -+----------+----------------------------------------+-------------------------+ ++------------+--------------------------------------+------------------------------------------------------+ +| 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 | ++------------+--------------------------------------+------------------------------------------------------+ -scale -^^^^^ +colors +^^^^^^ **event info dict** -+----------+----------------------------------------+-------------+ -| dict key | type | description | -+==========+========================================+=============+ -| value | np.ndarray[float, float, float, float] | new scale | -+----------+----------------------------------------+-------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ -alpha -^^^^^ +cmap +^^^^ **event info dict** -+----------+-------+-----------------+ ++----------+-------+--------------------------------+ +| dict key | type | description | ++==========+=======+================================+ +| key | slice | key at cmap colors were sliced | ++----------+-------+--------------------------------+ +| 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 +^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------+ +| dict key | type | description | ++==========+======+==============================+ +| 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 +^^^^ + +**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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +ScatterStack +------------ + +data +^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ + +sizes +^^^^^ + +**event info dict** + ++----------+----------------------------------------------+----------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==============================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced | ++----------+----------------------------------------------+----------------------------------------------+ +| value | int | float | array-like | new size values for points that were changed | ++----------+----------------------------------------------+----------------------------------------------+ + +sizes +^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new size value | ++----------+-------+----------------+ + +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 | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+-------+--------------------------------+ +| dict key | type | description | ++==========+=======+================================+ +| key | slice | key at cmap colors were sliced | ++----------+-------+--------------------------------+ +| 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 +^^^^^^^^^^ + +**event info dict** + ++----------+------+------------------------------+ +| dict key | type | description | ++==========+======+==============================+ +| 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 +^^^^ + +**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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +LinearSelector +-------------- + +selection +^^^^^^^^^ + +**extra attributes** + ++--------------------+----------+----------------------------------+ +| attribute | type | description | ++====================+==========+==================================+ +| get_selected_index | callable | returns index under the selector | ++--------------------+----------+----------------------------------+ + +**event info dict** + ++----------+-------+-------------------------------+ +| dict key | type | description | ++==========+=======+===============================+ +| value | float | new x or y value of selection | ++----------+-------+-------------------------------+ + +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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +LinearRegionSelector +-------------------- + +selection +^^^^^^^^^ + +**extra attributes** + ++----------------------+----------+------------------------------------+ +| attribute | type | description | ++======================+==========+====================================+ +| get_selected_indices | callable | returns indices under the selector | ++----------------------+----------+------------------------------------+ +| get_selected_data | callable | returns data under the selector | ++----------------------+----------+------------------------------------+ + +**event info dict** + ++----------+------------+-----------------------------+ +| dict key | type | description | ++==========+============+=============================+ +| value | np.ndarray | new [min, max] of selection | ++----------+------------+-----------------------------+ + +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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +RectangleSelector +----------------- + +selection +^^^^^^^^^ + +**extra attributes** + ++----------------------+----------+------------------------------------+ +| attribute | type | description | ++======================+==========+====================================+ +| get_selected_indices | callable | returns indices under the selector | ++----------------------+----------+------------------------------------+ +| get_selected_data | callable | returns data under the selector | ++----------------------+----------+------------------------------------+ + +**event info dict** + ++----------+------------+-------------------------------------------+ +| dict key | type | description | ++==========+============+===========================================+ +| value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection | ++----------+------------+-------------------------------------------+ + +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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +HighlightSelector +----------------- + +PositionsHighlightSelector +-------------------------- + +CollectionHighlightSelector +--------------------------- + +ImageHighlightSelector +---------------------- + +VisibilitySelector +------------------ + +ImageVisibilitySelector +----------------------- + +SelectorCollection +------------------ + +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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ | dict key | type | description | +==========+=======+=================+ | value | float | new alpha value | @@ -1970,29 +3025,99 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -LinearRegionSelector --------------------- +LinearSelectors +--------------- -selection -^^^^^^^^^ +name +^^^^ -**extra attributes** +**event info dict** -+----------------------+----------+------------------------------------+ -| attribute | type | description | -+======================+==========+====================================+ -| get_selected_indices | callable | returns indices under the selector | -+----------------------+----------+------------------------------------+ -| get_selected_data | callable | returns data under the selector | -+----------------------+----------+------------------------------------+ ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ **event info dict** -+----------+------------+-----------------------------+ -| dict key | type | description | -+==========+============+=============================+ -| value | np.ndarray | new [min, max] of selection | -+----------+------------+-----------------------------+ ++----------+---------------------------------+----------------------+ +| 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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +LinearRegionSelectors +--------------------- name ^^^^ @@ -2082,29 +3207,99 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ -RectangleSelector ------------------ +RectangleSelectors +------------------ -selection -^^^^^^^^^ +name +^^^^ -**extra attributes** +**event info dict** -+----------------------+----------+------------------------------------+ -| attribute | type | description | -+======================+==========+====================================+ -| get_selected_indices | callable | returns indices under the selector | -+----------------------+----------+------------------------------------+ -| get_selected_data | callable | returns data under the selector | -+----------------------+----------+------------------------------------+ ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ **event info dict** -+----------+------------+-------------------------------------------+ -| dict key | type | description | -+==========+============+===========================================+ -| value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection | -+----------+------------+-------------------------------------------+ ++----------+---------------------------------+----------------------+ +| 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 | ++----------+----------------------------------------+-------------------------+ + +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + +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 | ++----------+------+-------------------------------+ + +PolygonSelectors +---------------- name ^^^^ @@ -2194,3 +3389,6 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +SelectionVector +--------------- + diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index bd0352aa7..c857ebb9c 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -6,31 +6,40 @@ Installation To install use pip: +With imgui support (recommended) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Without jupyterlab support, install desired GUI framework such as glfw, PyQt6, or PySide6 separately. + +.. code-block:: + + pip install -U "fastplotlib[imgui]" + +With jupyterlab support. + .. code-block:: - # with imgui and jupyterlab pip install -U "fastplotlib[notebook,imgui]" - # minimal install, install glfw, pyqt6 or pyside6 separately - pip install -U fastplotlib +.. note:: ``imgui-bundle`` is required for the ``NDWidget`` - # with imgui - pip install -U "fastplotlib[imgui]" +Without imgui +^^^^^^^^^^^^^ - # to use in jupyterlab, no imgui - pip install -U "fastplotlib[notebook]" +Minimal, install desired GUI library such as PyQt6, PySide6, or glfw separately. -We strongly recommend installing ``simplejpeg`` for use in notebooks, you must first install `libjpeg-turbo `_. +.. code-block:: -- If you use ``conda``, you can get ``libjpeg-turbo`` through conda. -- If you are on linux you can get it through your distro's package manager. -- For Windows and Mac compiled binaries are available on their release page: https://github.com/libjpeg-turbo/libjpeg-turbo/releases + pip install fastplotlib -Once you have ``libjpeg-turbo``: +With jupyterlab support only. .. code-block:: - pip install simplejpeg + pip install -U "fastplotlib[notebook]" + +Fastplotlib is also available on conda-forge. For imgui support you will need to separately install ``imgui-bundle``, and for jupyterlab you will need to install ``jupyter-rfb`` and ``simplejpeg`` which are all available on conda-forge. + What is ``fastplotlib``? ------------------------ @@ -553,23 +562,16 @@ are no callbacks, but it is easy to learn if you see a few examples. .. image:: ../_static/guide_imgui.png We specifically use `imgui-bundle `_ for the python bindings in fastplotlib. -There is large community and many resources out there on building UIs using imgui. To install ``fastplotlib`` with ``imgui`` use the ``imgui`` extras option, i.e. ``pip install fastplotlib[imgui]``, or ``pip install imgui_bundle`` if you've already installed fastplotlib. Fastplotlib comes built-in with imgui UIs for subplot toolbars and a standard right-click menu with a number of options. -You can also make custom GUIs and embed them within the canvas, see the examples gallery for detailed examples. - -**Some tips:** - -The ``imgui-bundle`` docs as of March 2025 don't have a nice API list (as far as I know), here is how we go about developing UIs with imgui: - -1. Use the ``pyimgui`` API docs to locate the type of UI element we want, for example if we want a ``slider_int``: https://pyimgui.readthedocs.io/en/latest/reference/imgui.core.html#imgui.core.slider_int - -2. Look at the function signature in the ``imgui-bundle`` sources. You can usually access this easily with your IDE: https://github.com/pthom/imgui_bundle/blob/a5e7d46555832c40e9be277d4747eac5a303dbfc/bindings/imgui_bundle/imgui/__init__.pyi#L1693-L1696 +The standard right-click menu can be extended or replaced, and a right-click popup can also be set on a ``Subplot`` or +a ``Graphic``. You can also make custom GUIs and embed them within the canvas. -3. ``pyimgui`` and ``imgui-bundle`` sometimes don't have the same function signature, so we use a combination of the pyimgui docs and -imgui-bundle function signature to understand and implement the UI element. +The :doc:`imgui guide ` covers adding UIs to a Figure, and the +:doc:`imgui element reference ` documents every element with its signature, its arguments, and +an image of what it draws. ImageWidget ----------- diff --git a/examples/controllers/partial_camera_linking.py b/examples/controllers/partial_camera_linking.py new file mode 100644 index 000000000..5cebe66ce --- /dev/null +++ b/examples/controllers/partial_camera_linking.py @@ -0,0 +1,55 @@ +""" +Partial camera linking +====================== + +You can customize the camera axes that a controller acts on. In this example with two subplots you can pan and zoom +in x-y in each individual subplot, but only the x-axis panning is linked between the two subplots. The y-axis pan +and zoom in independent on each subplot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import pygfx + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +ys_big = np.random.rand(100) * 10 + +# create cameras, fov=0 means Orthographic projection +camera1 = pygfx.PerspectiveCamera(fov=0) +camera2 = pygfx.PerspectiveCamera(fov=0) + +# create controllers, first add the "main" camera for the subplot +controller1 = pygfx.PanZoomController(camera1) +controller2 = pygfx.PanZoomController(camera2) + +# add the other camera to each controller, but only include the 'x' state, i.e. 'y' for height is not included +# this must be done only after adding the "main" cameras to the controller as done above +controller1.add_camera(camera2, include_state={"x", "width"}) +controller2.add_camera(camera1, include_state={"x", "width"}) + +# create figure using these cameras and controllers +figure = fpl.Figure( + shape=(2, 1), + cameras=[camera1, camera2], + controllers=[controller1, controller2], + size=(700, 560) +) + +figure[0, 0].add_line(np.column_stack([xs, ys_big])) +figure[1, 0].add_line(np.column_stack([xs, ys])) + +for subplot in figure: + subplot.camera.zoom = 1.0 + +figure.show(maintain_aspect=False, autoscale=True) + +# 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/events/cmap_event.py b/examples/events/cmap_event.py index 62913cb29..f01f06d6a 100644 --- a/examples/events/cmap_event.py +++ b/examples/events/cmap_event.py @@ -34,7 +34,7 @@ xs = np.linspace(0, 4 * np.pi, 100) ys = np.sin(xs) -figure["sine"].add_line(np.column_stack([xs, ys])) +figure["sine"].add_line(np.column_stack([xs, ys]), color_mode="vertex") # make a 2D gaussian cloud cloud_data = np.random.normal(0, scale=3, size=1000).reshape(500, 2) diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py index cbf546e2a..0e89efcdc 100644 --- a/examples/gridplot/multigraphic_gridplot.py +++ b/examples/gridplot/multigraphic_gridplot.py @@ -106,7 +106,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: gaussian_cloud2 = np.random.multivariate_normal(mean, covariance, n_points) # add the scatter graphics to the figure -figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet") +figure["scatter"].add_scatter(data=gaussian_cloud, sizes=2, cmap="jet", color_mode="vertex") figure["scatter"].add_scatter(data=gaussian_cloud2, colors="r", sizes=2) figure.show() diff --git a/examples/guis/imgui_append.py b/examples/guis/imgui_append.py new file mode 100644 index 000000000..cd8b0e958 --- /dev/null +++ b/examples/guis/imgui_append.py @@ -0,0 +1,49 @@ +""" +ImGUI append to windows +======================= + +You can append imgui elements to an existing window, including the subplot toolbar. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui, icons_fontawesome_6 as fa + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_line(np.random.rand(100), colors="r", name="line") + + +# create an edge window +@figure.add_imgui_window(location="right", size=200, title="controls") +def gui(fig): + if imgui.button("randomize"): + fig[0, 0]["line"].data[:, 1] = np.random.rand(100) + + +# append more elements to the same window +@figure.append_imgui_window(location="right") +def more(fig): + line = fig[0, 0]["line"] + _, line.thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + + +# append a button to the subplot toolbar that toggles axes visibility +@figure[0, 0].append_imgui_window(location="toolbar") +def toolbar_extra(subplot): + imgui.same_line() + _, subplot.axes.visible = imgui.checkbox(fa.ICON_FA_RULER_COMBINED, subplot.axes.visible) + if imgui.is_item_hovered(0): + imgui.set_tooltip("Axes visibility") + + +figure.show(maintain_aspect=False) + + +# 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/guis/imgui_basic.py b/examples/guis/imgui_basic.py index 74d3c3629..11af54eac 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -13,8 +13,8 @@ import numpy as np import fastplotlib as fpl -# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +# subclass from ImguiWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui # make some initial data @@ -29,18 +29,15 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave") -class ImguiExample(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title) - # this UI will modify the line - self._line = self._figure[0, 0]["sine-wave"] - +class ImguiExample(ImguiWindow): + def __init__(self): + super().__init__() # set the default values # wave amplitude self._amplitude = 1 @@ -104,15 +101,10 @@ def _set_data(self): # make GUI instance -gui = ImguiExample( - figure, # the figure this GUI instance should live inside - size=275, # width or height of the GUI window within the figure - location="right", # the edge to place this window at - title="Imgui Window", # window title -) - -# add it to the figure -figure.add_gui(gui) +gui = ImguiExample() + +# add it to the right edge of the figure, 275px wide +figure.add_imgui_window(gui, location="right", size=275, title="Imgui Window") figure.show() diff --git a/examples/guis/imgui_colorbar.py b/examples/guis/imgui_colorbar.py new file mode 100644 index 000000000..947bd5735 --- /dev/null +++ b/examples/guis/imgui_colorbar.py @@ -0,0 +1,51 @@ +""" +ImGUI Colorbar +============== + +Create an ImguiColorbar manually and add it to the right edge of each subplot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio +from fastplotlib.ui import ImguiColorbar + +# a grayscale image and an RGB image +camera = iio.imread("imageio:camera.png") +astronaut = iio.imread("imageio:astronaut.png") + +figure = fpl.Figure(shape=(2, 2), size=(900, 900), canvas_kwargs={"max_fps": 999, "vsync": False}) + +# top row: a plain colorbar for each image +# grayscale image displayed with a colormap +camera_image = figure[0, 0].add_image(camera, cmap="viridis", name="camera") +figure[0, 0].add_imgui_window(ImguiColorbar(images=camera_image), location="right", size=80) + +# RGB image, it has no colormap so its colorbar is drawn with "gray" +astronaut_image = figure[0, 1].add_image(astronaut, name="astronaut") +figure[0, 1].add_imgui_window(ImguiColorbar(images=astronaut_image), location="right", size=80) + +# bottom row: the same images, but with a precomputed 100-bin histogram on the colorbar +camera_image2 = figure[1, 0].add_image(camera, cmap="viridis", name="camera") +camera_histogram = np.histogram(camera, bins=100) +figure[1, 0].add_imgui_window( + ImguiColorbar(images=camera_image2, histogram=camera_histogram), location="right", size=100 +) + +astronaut_image2 = figure[1, 1].add_image(astronaut, name="astronaut") +astronaut_histogram = np.histogram(astronaut, bins=100) +figure[1, 1].add_imgui_window( + ImguiColorbar(images=astronaut_image2, histogram=astronaut_histogram), location="right", size=100 +) + +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/guis/imgui_decorator.py b/examples/guis/imgui_decorator.py new file mode 100644 index 000000000..e08f7a926 --- /dev/null +++ b/examples/guis/imgui_decorator.py @@ -0,0 +1,43 @@ +""" +ImGUI decorator +=============== + +You can quickly create imgui UIs using a decorator. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui + +np.random.seed(0) +xs = np.linspace(0, 2 * np.pi, 100) + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_line(np.column_stack([xs, np.sin(xs)]), thickness=3, name="sine") + + +# the decorated function draws the imgui elements +# it optionally takes the figure as its only argument +@figure.add_imgui_window(location="right", size=200, title="controls") +def gui(fig): + line = fig[0, 0]["sine"] + + changed, thickness = imgui.slider_float("thickness", v=line.thickness, v_min=2.0, v_max=50.0) + if changed: + line.thickness = thickness + + if imgui.button("randomize"): + line.data[:, 1] = np.random.rand(100) + + +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/guis/imgui_floating.py b/examples/guis/imgui_floating.py new file mode 100644 index 000000000..51beb012d --- /dev/null +++ b/examples/guis/imgui_floating.py @@ -0,0 +1,39 @@ +""" +ImGUI floating windows +====================== + +You can add floating and fixed-extent imgui windows that are overlaid on the Figure. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from imgui_bundle import imgui + +figure = fpl.Figure(size=(700, 560)) +figure[0, 0].add_image(np.random.rand(128, 128), name="image") + + +# a floating window is auto-sized by imgui and can be dragged by the user +@figure.add_imgui_window(location="floating", title="floating", window_flags=imgui.WindowFlags_.none) +def floating_gui(fig): + if imgui.button("randomize"): + fig[0, 0]["image"].data = np.random.rand(128, 128) + + +# a window fixed to a fractional extent (xmin, xmax, ymin, ymax) of the canvas +@figure.add_imgui_window(extent=(0.6, 0.98, 0.05, 0.25), title="fixed") +def fixed_gui(): + imgui.text("fixed to a\nfractional extent") + + +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/guis/imgui_menu_bar.py b/examples/guis/imgui_menu_bar.py new file mode 100644 index 000000000..e792f0206 --- /dev/null +++ b/examples/guis/imgui_menu_bar.py @@ -0,0 +1,138 @@ +""" +ImGUI menu bar +============== + +You can override ``ImguiWindow.draw()`` to create a window with a menu bar. You can override the `draw()` call when +you need full control of the imgui window. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import fastplotlib as fpl +from fastplotlib.ui import ImguiWindow +from imgui_bundle import imgui + +# the imageio standard images +IMAGES = [ + "camera.png", + "astronaut.png", + "checkerboard.png", + "chelsea.png", + "clock.png", + "coffee.png", + "coins.png", + "horse.png", + "hubble_deep_field.png", + "immunohistochemistry.png", + "moon.png", + "page.png", + "text.png", + "wikkie.png", + "bricks.jpg", + "wood.jpg", +] + +figure = fpl.Figure(size=(700, 560)) +image = figure[0, 0].add_image(iio.imread(f"imageio:{IMAGES[0]}"), name="image") + + +class ImagePicker(ImguiWindow): + """floating window that replaces the image in the subplot with the one that is picked""" + + def __init__(self): + super().__init__() + + self.visible = False + self.picked = IMAGES[0] + + def draw(self): + if not self.visible: + return + + # a height of zero makes imgui auto-size the window to fit the list + imgui.set_next_window_size((220, 0), imgui.Cond_.appearing) + expanded, self.visible = imgui.begin("Open image", True) + + if expanded: + for name in IMAGES: + if imgui.selectable(name, name == self.picked)[0]: + self.picked = name + image.data = iio.imread(f"imageio:{name}") + figure[0, 0].auto_scale() + self.visible = False + + imgui.end() + + +class MenuBar(ImguiWindow): + """menu bar at the top of the Figure, ``update()`` is unused since ``draw()`` is fully overridden""" + + def __init__(self, picker: ImagePicker): + super().__init__() + + self._picker = picker + self._show_version = False + + def draw(self): + imgui.set_next_window_size((self.width, self.height)) + imgui.set_next_window_pos((self.x, self.y)) + + imgui.begin( + f"menu-bar##{self._id_counter}", + p_open=None, + flags=imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_scrollbar + | imgui.WindowFlags_.no_bring_to_front_on_focus + | imgui.WindowFlags_.menu_bar, + ) + + if imgui.begin_menu_bar(): + if imgui.begin_menu("File"): + if imgui.menu_item("Open", "", False)[0]: + self._picker.visible = True + + imgui.end_menu() + + if imgui.begin_menu("Help"): + if imgui.menu_item("Version", "", False)[0]: + self._show_version = True + + imgui.end_menu() + + imgui.end_menu_bar() + + # the popup is opened here and not within the menu, imgui requires that open_popup() and + # begin_popup_modal() are called for the same window + if self._show_version: + self._show_version = False + imgui.open_popup("Version") + + # center the modal on the canvas + imgui.set_next_window_pos( + imgui.get_main_viewport().get_center(), imgui.Cond_.appearing, (0.5, 0.5) + ) + + # p_open draws a close button in the title bar, imgui closes the modal when it is clicked + if imgui.begin_popup_modal("Version", True, imgui.WindowFlags_.always_auto_resize)[0]: + imgui.text(f"fastplotlib version: {fpl.__version__}") + imgui.end_popup() + + imgui.end() + + +picker = ImagePicker() +figure.add_imgui_window(picker, location="floating") +figure.add_imgui_window(MenuBar(picker), location="top", size=30) + +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/guis/imgui_right_click.py b/examples/guis/imgui_right_click.py new file mode 100644 index 000000000..c83ea3759 --- /dev/null +++ b/examples/guis/imgui_right_click.py @@ -0,0 +1,91 @@ +""" +ImGUI right-click popups +======================== + +You can set an imgui popup that is opened by a right-click on a Figure, Subplot or Graphic. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import imageio.v3 as iio +from scipy.ndimage import gaussian_filter +import fastplotlib as fpl +from imgui_bundle import imgui + +data1 = iio.imread("imageio:camera.png").astype(np.float32) +data2 = iio.imread("imageio:moon.png").astype(np.float32) + +figure = fpl.Figure(shape=(1, 2), size=(900, 560), names=["images", "line"]) + +# the popup keeps its state in the graphic's metadata, so one function can be used for both images +state = {"noise": 0.0, "sigma": 1.0, "filter": False} + +img1 = figure[0, 0].add_image(data1, name="img1", metadata=state.copy()) +img2 = figure[0, 0].add_image(data2, name="img2", offset=(550, 0, 0), metadata=state.copy()) + +line = figure[0, 1].add_line(np.sin(np.linspace(0, 4 * np.pi, 100)), name="line") + +raw = {img1: data1, img2: data2} + + +# append elements to the standard right-click menu +@figure.append_imgui_right_click() +def more_items(fig): + imgui.separator() + if imgui.menu_item("Autoscale all subplots", "", False)[0]: + for subplot in fig: + subplot.auto_scale() + + +# a popup set on a subplot replaces the standard menu within that subplot +@figure[0, 1].set_imgui_right_click() +def line_popup(subplot): + imgui.text(f"subplot: {subplot.name}") + imgui.separator() + _, line.thickness = imgui.slider_float("thickness", line.thickness, 1.0, 20.0) + changed, color = imgui.color_edit3("color", tuple(float(c) for c in line.colors)[:3]) + if changed: + line.colors = (*color, 1.0) + + +# a popup can contain any imgui elements, it is not restricted to menu items +def image_processing(image): + ui = image.metadata + + imgui.text(image.name) + imgui.separator() + + changed_noise, ui["noise"] = imgui.slider_float("noise sigma", ui["noise"], 0.0, 100.0) + changed_filter, ui["filter"] = imgui.checkbox("gaussian filter", ui["filter"]) + + imgui.begin_disabled(not ui["filter"]) + changed_sigma, ui["sigma"] = imgui.slider_float("filter sigma", ui["sigma"], 0.1, 10.0) + imgui.end_disabled() + + if imgui.button("reset"): + ui.update(noise=0.0, sigma=1.0, filter=False) + changed_noise = True + + if changed_noise or changed_filter or changed_sigma: + data = raw[image] + np.random.normal(scale=ui["noise"], size=raw[image].shape) + + if ui["filter"]: + data = gaussian_filter(data, sigma=ui["sigma"]) + + image.data = data + + +# the same function on both images, each graphic gets its own popup and is passed to the function +img1.set_imgui_right_click(image_processing) +img2.set_imgui_right_click(image_processing) + +figure.show() +figure[0, 1].camera.maintain_aspect = False + +# 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/guis/imgui_top.py b/examples/guis/imgui_top.py index e1f865fe0..5a29534c8 100644 --- a/examples/guis/imgui_top.py +++ b/examples/guis/imgui_top.py @@ -11,8 +11,8 @@ import numpy as np import fastplotlib as fpl -# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +# subclass from ImguiWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui # make some initial data @@ -27,31 +27,29 @@ figure = fpl.Figure(size=(700, 560)) # make some scatter points at every 10th point -figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", color_mode="uniform") # place a line above the scatter -figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", color_mode="uniform") -class ImguiExample(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title, window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize) - +class ImguiExample(ImguiWindow): def update(self): imgui.text("This is a top window") # make GUI instance -gui = ImguiExample( - figure, # the figure this GUI instance should live inside - size=30, # width or height of the GUI window within the figure - location="top", # the edge to place this window at - title=" ", # window title +gui = ImguiExample() + +# add it to the top edge of the figure +figure.add_imgui_window( + gui, + location="top", + size=60, + title="top window", + window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize, ) -# add it to the figure -figure.add_gui(gui) - figure.show() # NOTE: fpl.loop.run() should not be used for interactive sessions diff --git a/examples/guis/sine_cosine_funcs.py b/examples/guis/sine_cosine_funcs.py index 935f9a5a1..be260d782 100644 --- a/examples/guis/sine_cosine_funcs.py +++ b/examples/guis/sine_cosine_funcs.py @@ -11,7 +11,7 @@ import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow from imgui_bundle import imgui @@ -129,9 +129,9 @@ def set_x_val(ev): sine_selector.selection = 50 -class GUIWindow(EdgeWindow): - def __init__(self, figure, size, location, title): - super().__init__(figure=figure, size=size, location=location, title=title) +class GUIWindow(ImguiWindow): + def __init__(self): + super().__init__() self._p = 1 self._q = 1 @@ -166,14 +166,9 @@ def update(self): self._set_data() -gui = GUIWindow( - figure=figure, - size=100, - location="right", - title="Freq. coeffs" -) +gui = GUIWindow() -figure.add_gui(gui) +figure.add_imgui_window(gui, location="right", size=150, title="Freq. coeffs") figure.show() diff --git a/examples/image/image_reshaping.py b/examples/image/image_reshaping.py new file mode 100644 index 000000000..23264bda1 --- /dev/null +++ b/examples/image/image_reshaping.py @@ -0,0 +1,50 @@ +""" +Image reshaping +=============== + +An example that shows replacement of the image data with new data of a different shape. Under the hood, this creates a +new buffer and a new array of Textures on the GPU that replace the older Textures. Creating a new buffer and textures +has a performance cost, so you should do this only if you need to or if the performance drawback is not a concern for +your use case. + +Note that the vmin-vmax is reset when you replace the buffers. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + + +import numpy as np +import fastplotlib as fpl + +# create some data, diagonal sinusoidal bands +xs = np.linspace(0, 2300, 2300, dtype=np.float16) +full_data = np.vstack([np.cos(np.sqrt(xs + (np.pi / 2) * i)) * i for i in range(2_300)]) + +figure = fpl.Figure() + +image = figure[0, 0].add_image(full_data) + +figure.show() + +i, j = 1, 1 + + +def update(): + global i, j + # set the new image data as a subset of the full data + row = np.abs(np.sin(i)) * 2300 + col = np.abs(np.cos(i)) * 2300 + image.data = full_data[: int(row), : int(col)] + + i += 0.01 + j += 0.01 + + +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/image_yuv.py b/examples/image/image_yuv.py new file mode 100644 index 000000000..dfb7cad47 --- /dev/null +++ b/examples/image/image_yuv.py @@ -0,0 +1,56 @@ +""" +YUV Image +========= + +Example that shows how to use YUV images. Most videos are stored in this colorspace. +Y stores luma at full resolution, UV stores chroma values. +In yuv420p UV channels are stored at half the resolution of Y. In yuv444p, UV channels are stored +at full resolution. + +YUV is also called YCbCr for digital images. + +For more info: https://en.wikipedia.org/wiki/Y%E2%80%B2UV + +You can see the slight differences between yuv420 and yuv444 if you zoom into parts of the image where colors change +rapidly over space, such as the astronaut's patch. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +from skimage.color import rgb2ycbcr +import imageio.v3 as iio + +# convert an rgb image to ycbcr for example purposes +img = iio.imread("imageio:astronaut.png") +img_yuv = rgb2ycbcr(img).astype(np.uint8) + +y = img_yuv[..., 0] +u = img_yuv[..., 1] +v = img_yuv[..., 2] + +figure = fpl.Figure( + shape=(1, 2), names=["yuv420p", "yuv444p"], controller_ids="sync", size=(700, 400) +) + +image1 = figure[0, 0].add_image_yuv( + data=(y, u[::2, ::2], v[::2, ::2]), colorspace="yuv420p" +) + +image2 = figure[0, 1].add_image_yuv(data=(y, u, v), colorspace="yuv444p") + +cursor = fpl.Cursor() + +for subplot in figure: + cursor.add_subplot(subplot) + +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_4d.py b/examples/image_volume/image_volume_4d.py index 34bf9b903..9782fabdc 100644 --- a/examples/image_volume/image_volume_4d.py +++ b/examples/image_volume/image_volume_4d.py @@ -11,6 +11,7 @@ import numpy as np from scipy.ndimage import gaussian_filter import fastplotlib as fpl +from fastplotlib.ui import ImguiColorbar def generate_data( @@ -67,12 +68,9 @@ def generate_data( 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) +# a colorbar with a histogram of the entire 4D dataset +colorbar = ImguiColorbar(images=volume, histogram=np.histogram(voldata, bins=100)) +figure[0, 0].add_imgui_window(colorbar, location="right", size=100) figure.show() diff --git a/examples/image_volume/image_volume_render_modes.py b/examples/image_volume/image_volume_render_modes.py index 36705d17d..943612887 100644 --- a/examples/image_volume/image_volume_render_modes.py +++ b/examples/image_volume/image_volume_render_modes.py @@ -10,7 +10,7 @@ import numpy as np import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiColorbar, ImguiWindow from fastplotlib.graphics.features import VOLUME_RENDER_MODES import imageio.v3 as iio from imgui_bundle import imgui @@ -25,58 +25,52 @@ 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) +# add a colorbar with a histogram of the volume data +colorbar = ImguiColorbar( + images=figure[0, 0]["vol-img"], histogram=np.histogram(voldata, bins=100) +) +figure[0, 0].add_imgui_window(colorbar, location="right", size=100) - # reference to the graphic for convenience - self.graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] +class GUI(ImguiWindow): def update(self): + graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] + 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 + 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, + if graphic.mode == "iso": + _, graphic.threshold = imgui.slider_float( + "threshold", v=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, + _, graphic.step_size = imgui.slider_float( + "step_size", v=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, + _, graphic.substep_size = imgui.slider_float( + "substep_size", v=graphic.substep_size, v_max=10.0, v_min=0.1, ) - col = imgui.ImVec4((*self.graphic.emissive.rgb, 1)) - _, self.graphic.emissive = imgui.color_picker3("emissive color", col=col) + col = imgui.ImVec4((*graphic.emissive.rgb, 1)) + _, graphic.emissive = imgui.color_picker3("emissive color", col=col) - if self.graphic.mode == "slice": + if 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) + _, a = imgui.slider_float("a", v=graphic.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=graphic.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=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) + largest_dim = max(graphic.data.value.shape) + _, d = imgui.slider_float("d", v=graphic.plane[3], v_min=0, v_max=largest_dim * 2) - self.graphic.plane = (a, b, c, d) + graphic.plane = (a, b, c, d) -gui = GUI(figure=figure) -figure.add_gui(gui) +gui = GUI() +figure.add_imgui_window(gui, location="right", size=300, title="Render options") figure.show() diff --git a/examples/image_volume/image_volume_share_buffer.py b/examples/image_volume/image_volume_share_buffer.py index cc9f07915..86bb372e8 100644 --- a/examples/image_volume/image_volume_share_buffer.py +++ b/examples/image_volume/image_volume_share_buffer.py @@ -11,7 +11,7 @@ from imgui_bundle import imgui import fastplotlib as fpl -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import ImguiWindow import imageio.v3 as iio from skimage.filters import gaussian @@ -37,9 +37,9 @@ ) -class GUI(EdgeWindow): - def __init__(self, figure, title="change data buffer", location="right", size=200): - super().__init__(figure, title=title, location=location, size=size) +class GUI(ImguiWindow): + def __init__(self): + super().__init__() self._sigma = 2 def update(self): @@ -62,8 +62,8 @@ def update(self): vol_slice.plane = (a, b, c, d) -gui = GUI(figure) -figure.add_gui(gui) +gui = GUI() +figure.add_imgui_window(gui, location="right", size=200, title="change data buffer") figure.show() diff --git a/examples/image_widget/README.rst b/examples/image_widget/README.rst deleted file mode 100644 index f445f7390..000000000 --- a/examples/image_widget/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -ImageWidget Examples -==================== diff --git a/examples/image_widget/image_widget.py b/examples/image_widget/image_widget.py deleted file mode 100644 index a3c332182..000000000 --- a/examples/image_widget/image_widget.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Image widget -============ - -Example showing the image widget in action. - -Every image in an `ImageWidget` is associated with an interactive Histogram LUT tool and colorbar. Right-click the -colorbar to pick colormaps. -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -import fastplotlib as fpl -import imageio.v3 as iio # not a fastplotlib dependency, only used for examples - -a = iio.imread("imageio:camera.png") -iw = fpl.ImageWidget(data=a, cmap="viridis", figure_kwargs={"size": (700, 560)}) -iw.show() - -# Access ImageGraphics managed by the image widget -iw.figure[0, 0]["image_widget_managed"].data[:50, :50] = 0 -iw.figure[0, 0]["image_widget_managed"].cmap = "gnuplot2" - -# another way to access the image widget managed ImageGraphics -iw.managed_graphics[0].data[450:, 450:] = 255 - -figure = iw.figure - -# 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_widget/image_widget_grid.py b/examples/image_widget/image_widget_grid.py deleted file mode 100644 index 41e964e95..000000000 --- a/examples/image_widget/image_widget_grid.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Image widget grid -================= - -Example showing how to view multiple images in an ImageWidget -""" - -import fastplotlib as fpl -import imageio.v3 as iio - -# test_example = true -# sphinx_gallery_pygfx_docs = 'screenshot' - -img1 = iio.imread("imageio:camera.png") -img2 = iio.imread("imageio:astronaut.png") -img3 = iio.imread("imageio:chelsea.png") -img4 = iio.imread("imageio:wikkie.png") - -iw = fpl.ImageWidget( - data=[img1, img2, img3, img4], - rgb=[False, True, True, True], # mix of grayscale and RGB images - names=["cameraman", "astronaut", "chelsea", "Almar's cat"], - # ImageWidget will sync controllers by default - # by setting `controller_ids=None` we can have independent controllers for each subplot - # this is useful when the images have different dimensions - figure_kwargs={"size": (700, 560), "controller_ids": None}, -) -iw.show() - -figure = iw.figure - -for subplot in figure: - # sometimes the toolbar adds clutter - subplot.toolbar = False - - -# 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_widget/image_widget_single_video.py b/examples/image_widget/image_widget_single_video.py deleted file mode 100644 index 86ca642fa..000000000 --- a/examples/image_widget/image_widget_single_video.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Image widget Video -================== - -Example showing how to scroll through one or more videos using the ImageWidget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' - -import fastplotlib as fpl -import imageio.v3 as iio -import numpy as np - - -movie = iio.imread("imageio:cockatoo.mp4") - -# Ignore and do not use the next 2 lines -# for the purposes of docs gallery generation we subsample and only use 15 frames -movie_sub = movie[:15, ::12, ::12].copy() -del movie - -iw = fpl.ImageWidget(movie_sub, rgb=True, figure_kwargs={"size": (700, 560)}) - -# ImageWidget supports setting window functions the `time` "t" or `volume` "z" dimension -# These can also be given as kwargs to `ImageWidget` during instantiation -# to set a window function, give a dict in the form of {dim: (func, window_size)} -iw.window_funcs = {"t": (np.mean, 13)} - -# change the window size -iw.window_funcs["t"].window_size = 33 - -# change the function -iw.window_funcs["t"].func = np.max - -# or reset it -iw.window_funcs = None - -iw.show() - -figure = iw.figure - -# 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_widget/image_widget_videos.py b/examples/image_widget/image_widget_videos.py deleted file mode 100644 index 399abbcff..000000000 --- a/examples/image_widget/image_widget_videos.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Image widget videos side by side -================================ - -Example showing how to scroll through one or more videos using the ImageWidget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'animate 6s 20fps' - -import fastplotlib as fpl -import imageio.v3 as iio -import numpy as np - - -# load the standard cockatoo video -cockatoo = iio.imread("imageio:cockatoo.mp4") - -# Ignore and do not use the next 2 lines -# for the purposes of docs gallery generation we subsample and only use 15 frames -cockatoo_sub = cockatoo[:15, ::12, ::12].copy() -del cockatoo - -# make a random grayscale video, shape is [t, rows, cols] -np.random.seed(0) -random_data = np.random.rand(*cockatoo_sub.shape[:-1]) - -iw = fpl.ImageWidget( - [random_data, cockatoo_sub], - rgb=[False, True], - figure_shape=(2, 1), # 2 rows, 1 column - figure_kwargs={"size": (700, 940)} -) - -iw.show() - -figure = iw.figure - -# 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_widget/image_widget_viewports_check.py b/examples/image_widget/image_widget_viewports_check.py deleted file mode 100644 index a4c0aea03..000000000 --- a/examples/image_widget/image_widget_viewports_check.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -ImageWidget test viewport rects -=============================== - -Test Figure to test that viewport rects are positioned correctly in an image widget -""" - -# test_example = true -# sphinx_gallery_pygfx_docs = 'hidden' - -import fastplotlib as fpl -import numpy as np - -np.random.seed(0) -a = np.random.rand(6, 15, 10, 10) - -iw = fpl.ImageWidget( - data=[img for img in a], - names=list(map(str, range(6))), - figure_kwargs={"size": (700, 560)}, -) - -for subplot in iw.figure: - subplot.docks["left"].size = 10 - subplot.docks["bottom"].size = 40 - -iw.show() - -figure = iw.figure - -# 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/inf_line.py b/examples/line/inf_line.py new file mode 100644 index 000000000..5d03eda3a --- /dev/null +++ b/examples/line/inf_line.py @@ -0,0 +1,46 @@ +""" +Infinite Lines +============== + +Draw infinite vertical and horizontal lines to mark positions on a plot. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 4 * np.pi, 100) +ys = np.sin(xs) +data = np.column_stack([xs, ys]) + +figure[0, 0].add_line(data, thickness=2, colors="w") + +# vertical lines at the zero-crossings, one color per line by passing a list of colors +zero_crossings = np.array([0, np.pi, 2 * np.pi, 3 * np.pi, 4 * np.pi]) +figure[0, 0].add_inf_line( + zero_crossings, axis="x", colors=["r", "g", "b", "c", "m"], thickness=2 +) + +# dashed horizontal lines at the sine bounds, provided as a 1D array of y-values +figure[0, 0].add_inf_line( + np.array([-1.0, 1.0]), + axis="y", + colors="gray", + thickness=2, + dash_pattern="--", +) + +figure[0, 0].axes.intersection = (0, 0, 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/line/inf_line_cmap.py b/examples/line/inf_line_cmap.py new file mode 100644 index 000000000..a2a067de6 --- /dev/null +++ b/examples/line/inf_line_cmap.py @@ -0,0 +1,27 @@ +""" +Infinite Lines Colormap +======================= + +Apply a colormap across a set of infinite lines, one color per line. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +# vertical lines colored by a colormap, one color per line +positions = np.arange(10) +figure[0, 0].add_inf_line(positions, axis="x", cmap="viridis", thickness=3) + +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/inf_line_cmap_transform.py b/examples/line/inf_line_cmap_transform.py new file mode 100644 index 000000000..d42833cbb --- /dev/null +++ b/examples/line/inf_line_cmap_transform.py @@ -0,0 +1,33 @@ +""" +Infinite Lines Colormap Transform +================================= + +Use a ``cmap_transform`` to color infinite lines by an associated value rather than by their sequential +order. Here each line at an x-position is colored according to the sine value at that x-axis position. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +# evenly spaced vertical lines +positions = np.linspace(0, 6 * np.pi, 32) + +# color each line by an associated value using the colormap transform +values = np.sin(positions) +figure[0, 0].add_inf_line( + positions, axis="x", cmap="plasma", cmap_transform=values, thickness=3 +) + +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/inf_line_pairs.py b/examples/line/inf_line_pairs.py new file mode 100644 index 000000000..040085220 --- /dev/null +++ b/examples/line/inf_line_pairs.py @@ -0,0 +1,34 @@ +""" +Infinite Lines from Point Pairs +=============================== + +Define infinite lines directly from pairs of points using ``axis=None``. Each two consecutive +points define one line. Here pairs of points sampled around the unit circle are used to produce +lines that are roughly tangent to the circle. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +# an even number of points sampled around a circle; each consecutive pair of points defines an infinite line +t = np.linspace(0, 2 * np.pi, 64, endpoint=False) +xs = np.sin(t) +ys = np.cos(t) +positions = np.column_stack([xs, ys, np.zeros_like(xs)]) + +figure[0, 0].add_inf_line(positions, axis=None, cmap="hsv", thickness=2) +figure[0, 0].axes.intersection = (0, 0, 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/line/line_cmap.py b/examples/line/line_cmap.py index 3d2b5e8c9..6dfc1fe23 100644 --- a/examples/line/line_cmap.py +++ b/examples/line/line_cmap.py @@ -27,7 +27,7 @@ data=sine_data, thickness=10, cmap="plasma", - cmap_transform=sine_data[:, 1] + cmap_transform=sine_data[:, 1], ) # qualitative colormaps, useful for cluster labels or other types of categorical labels @@ -36,7 +36,7 @@ data=cosine_data, thickness=10, cmap="tab10", - cmap_transform=labels + cmap_transform=labels, ) figure.show() diff --git a/examples/line/line_cmap_more.py b/examples/line/line_cmap_more.py index c7c0d80f4..c6e811fb2 100644 --- a/examples/line/line_cmap_more.py +++ b/examples/line/line_cmap_more.py @@ -31,16 +31,35 @@ # set colormap by mapping data using a transform # here we map the color using the y-values of the sine data # i.e., the color is a function of sine(x) -line2 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", cmap_transform=sine[:, 1], offset=(0, 4, 0)) +line2 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="jet", + cmap_transform=sine[:, 1], + offset=(0, 4, 0), +) # make a line and change the cmap afterward, here we are using the cosine instead fot the transform -line3 = figure[0, 0].add_line(sine, thickness=10, cmap="jet", cmap_transform=cosine[:, 1], offset=(0, 6, 0)) +line3 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="jet", + cmap_transform=cosine[:, 1], + offset=(0, 6, 0) +) + # change the cmap line3.cmap = "bwr" # use quantitative colormaps with categorical cmap_transforms labels = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20 -line4 = figure[0, 0].add_line(sine, thickness=10, cmap="tab10", cmap_transform=labels, offset=(0, 8, 0)) +line4 = figure[0, 0].add_line( + sine, + thickness=10, + cmap="tab10", + cmap_transform=labels, + offset=(0, 8, 0), +) # some text labels for i in range(5): diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py index b6865eadb..264f944f3 100644 --- a/examples/line/line_colorslice.py +++ b/examples/line/line_colorslice.py @@ -30,7 +30,8 @@ sine = figure[0, 0].add_line( data=sine_data, thickness=5, - colors="magenta" + colors="magenta", + color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later ) # you can also use colormaps for lines! @@ -56,6 +57,7 @@ data=zeros_data, thickness=8, colors="w", + color_mode="vertex", # initialize with same color across vertices, but we will change the per-vertex colors later offset=(0, 10, 0) ) diff --git a/examples/line/line_dash.py b/examples/line/line_dash.py new file mode 100644 index 000000000..d0c3d3912 --- /dev/null +++ b/examples/line/line_dash.py @@ -0,0 +1,35 @@ +""" +Line Dash Patterns +================== + +Draw lines with different dash patterns using matplotlib-style strings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 4 * np.pi, 100) + +# a matplotlib-style string, or a sequence of floats, sets the dash pattern +patterns = ["-", "--", "-.", ":"] + +for i, pattern in enumerate(patterns): + ys = np.sin(xs) + i * 3 + data = np.column_stack([xs, ys]) + figure[0, 0].add_line( + data, thickness=5, dash_pattern=pattern, name=pattern + ) + +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_collection/line_collection_slicing.py b/examples/line_collection/line_collection_slicing.py index f829a53c6..98ad97056 100644 --- a/examples/line_collection/line_collection_slicing.py +++ b/examples/line_collection/line_collection_slicing.py @@ -26,6 +26,7 @@ multi_data, thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], separation=4, + color_mode="vertex", # this will allow us to set per-vertex colors on each line metadatas=list(range(15)), # some metadata names=list("abcdefghijklmno"), # unique name for each line ) diff --git a/examples/machine_learning/kmeans.py b/examples/machine_learning/kmeans.py index f571882ce..4c49844f0 100644 --- a/examples/machine_learning/kmeans.py +++ b/examples/machine_learning/kmeans.py @@ -80,6 +80,7 @@ sizes=5, cmap="tab10", # use a qualitative cmap cmap_transform=kmeans.labels_, # color by the predicted cluster + uniform_size=False, ) # initial index diff --git a/examples/misc/buffer_replace_gc.py b/examples/misc/buffer_replace_gc.py new file mode 100644 index 000000000..2f6ec992b --- /dev/null +++ b/examples/misc/buffer_replace_gc.py @@ -0,0 +1,91 @@ +""" +Buffer replacement garbage collection test +========================================== + +This is an example that used for a manual test to ensure that GPU VRAM is free when buffers are replaced. + +Use while monitoring VRAM usage with nvidia-smi +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'code' + + +from typing import Literal +import numpy as np +import fastplotlib as fpl +from fastplotlib.ui import ImguiWindow +from imgui_bundle import imgui + + +def generate_dataset(size: int) -> dict[str, np.ndarray]: + return { + "data": np.random.rand(size, 3), + "colors": np.random.rand(size, 4), + # TODO: there's a wgpu bind group issue with edge_colors, will figure out later + # "edge_colors": np.random.rand(size, 4), + "markers": np.random.choice(list("osD+x^v<>*"), size=size), + "sizes": np.random.rand(size) * 5, + "point_rotations": np.random.rand(size) * 180, + } + + +datasets = { + "init": generate_dataset(50_000), + "small": generate_dataset(100), + "large": generate_dataset(5_000_000), +} + + +class UI(ImguiWindow): + def __init__(self, figure): + super().__init__() + init_data = datasets["init"] + figure["line"].add_line( + data=init_data["data"], colors=init_data["colors"], name="line" + ) + figure["scatter"].add_scatter( + **init_data, + uniform_size=False, + uniform_marker=False, + uniform_edge_color=False, + point_rotation_mode="vertex", + name="scatter", + ) + + def update(self): + for graphic in ["line", "scatter"]: + if graphic == "line": + features = ["data", "colors"] + + elif graphic == "scatter": + features = list(datasets["init"].keys()) + + for size in ["small", "large"]: + for fea in features: + if imgui.button(f"{size} - {graphic} - {fea}"): + self._replace(graphic, fea, size) + + def _replace( + self, + graphic: Literal["line", "scatter", "image"], + feature: Literal["data", "colors", "markers", "sizes", "point_rotations"], + size: Literal["small", "large"], + ): + new_value = datasets[size][feature] + + setattr(self._figure[graphic][graphic], feature, new_value) + + +figure = fpl.Figure(shape=(3, 1), size=(700, 1600), names=["line", "scatter", "image"]) +ui = UI(figure) +figure.add_imgui_window(ui, location="right", size=200, title="UI") + +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/misc/lorenz_animation.py b/examples/misc/lorenz_animation.py index 20aee5d83..52a77a243 100644 --- a/examples/misc/lorenz_animation.py +++ b/examples/misc/lorenz_animation.py @@ -60,7 +60,12 @@ def lorenz(xyz, *, s=10, r=28, b=2.667): scatter_markers = list() for graphic in lorenz_line: - marker = figure[0, 0].add_scatter(graphic.data.value[0], sizes=16, colors=graphic.colors[0]) + marker = figure[0, 0].add_scatter( + graphic.data.value[0], + sizes=16, + colors=graphic.colors, + edge_colors="w", + ) scatter_markers.append(marker) # initialize time diff --git a/examples/misc/reshape_lines_scatters.py b/examples/misc/reshape_lines_scatters.py new file mode 100644 index 000000000..db8adb29e --- /dev/null +++ b/examples/misc/reshape_lines_scatters.py @@ -0,0 +1,92 @@ +""" +Change number of points in lines and scatters +============================================= + +This example sets lines and scatters with new data of a different shape, i.e. new data with more or fewer datapoints. +Internally, this creates new buffers for the feature that is being set (data, colors, markers, etc.). Note that there +are performance drawbacks to doing this, so it is recommended to maintain the same number of datapoints in a graphic +when possible. You only want to change the number of datapoints when it's really necessary, and you don't want to do +it constantly (such as tens or hundreds of times per second). + +This example is also useful for manually checking that GPU buffers are freed when they're no longer in use. Run this +example while monitoring VRAM usage with `nvidia-smi` +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate' + + +import numpy as np +import fastplotlib as fpl + +# create some data to start with +xs = np.linspace(0, 10 * np.pi, 100) +ys = np.sin(xs) + +data = np.column_stack([xs, ys]) + +# create a figure, add a line, scatter and line_stack +figure = fpl.Figure(shape=(3, 1), size=(700, 700)) + +line = figure[0, 0].add_line(data) + +scatter = figure[1, 0].add_scatter( + np.random.rand(100, 3), + colors=np.random.rand(100, 4), + markers=np.random.choice(list("osD+x^v<>*"), size=100), + sizes=(np.random.rand(100) + 1) * 3, + edge_colors=np.random.rand(100, 4), + point_rotations=np.random.rand(100) * 180, + uniform_marker=False, + uniform_size=False, + uniform_edge_color=False, + point_rotation_mode="vertex", +) + +line_stack = figure[2, 0].add_line_stack(np.stack([data] * 10), cmap="viridis") + +text = figure[0, 0].add_text(f"n_points: {100}", offset=(0, 1.5, 0), anchor="middle-left") + +figure.show(maintain_aspect=False) + +i = 0 + + +def update(): + # set a new larger or smaller data array on every render + global i + + # create new data + freq = np.abs(np.sin(i)) * 10 + n_points = int((freq * 20_000) + 10) + + xs = np.linspace(0, 10 * np.pi, n_points) + ys = np.sin(xs * freq) + + new_data = np.column_stack([xs, ys]) + + # update line data + line.data = new_data + + # update scatter data, colors, markers, etc. + scatter.data = np.random.rand(n_points, 3) + scatter.colors = np.random.rand(n_points, 4) + scatter.markers = np.random.choice(list("osD+x^v<>*"), size=n_points) + scatter.edge_colors = np.random.rand(n_points, 4) + scatter.point_rotations = np.random.rand(n_points) * 180 + + # update line stack data + line_stack.data = np.stack([new_data] * 10) + + text.text = f"n_points: {n_points}" + + i += 0.01 + + +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/misc/scatter_animation.py b/examples/misc/scatter_animation.py index d37aea976..549059b65 100644 --- a/examples/misc/scatter_animation.py +++ b/examples/misc/scatter_animation.py @@ -37,7 +37,7 @@ figure = fpl.Figure(size=(700, 560)) subplot_scatter = figure[0, 0] # use an alpha value since this will be a lot of points -scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6) +scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, uniform_size=False, colors=colors, alpha=0.6) def update_points(subplot): diff --git a/examples/misc/scatter_sizes_animation.py b/examples/misc/scatter_sizes_animation.py index 53a616a68..2092787f3 100644 --- a/examples/misc/scatter_sizes_animation.py +++ b/examples/misc/scatter_sizes_animation.py @@ -20,7 +20,7 @@ figure = fpl.Figure(size=(700, 560)) -figure[0, 0].add_scatter(data, sizes=sizes, name="sine") +figure[0, 0].add_scatter(data, sizes=sizes, uniform_size=False, name="sine") i = 0 diff --git a/examples/ndwidget/README.rst b/examples/ndwidget/README.rst new file mode 100644 index 000000000..28ed4d752 --- /dev/null +++ b/examples/ndwidget/README.rst @@ -0,0 +1,2 @@ +NDWidget Examples +================= diff --git a/examples/ndwidget/ndimage.py b/examples/ndwidget/ndimage.py new file mode 100644 index 000000000..eafd3c3c3 --- /dev/null +++ b/examples/ndwidget/ndimage.py @@ -0,0 +1,54 @@ +""" +NDWidget image +============== + +NDWidget image example +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + + +data = np.random.rand(1000, 30, 64, 64) +data2 = np.random.rand(1000, 30, 128, 128) + +# must define a reference range for each dim +ref = { + "time": (0, 1000, 1), + "depth": (0, 30, 1), +} + + +ndw = fpl.NDWidget( + ref_ranges=ref, + size=(700, 560) +) +ndw2 = fpl.NDWidget( + ref_ranges=ref, + ref_index=ndw.indices, # can create another NDWidget that shared the reference index! So multiple windows are possible + size=(700, 560) +) + +ndi = ndw[0, 0].add_nd_image( + data, + ("time", "depth", "m", "n"), # specify all dim names + ("m", "n"), # specify spatial dims IN ORDER, rest are auto slider dims + name="4d-image", +) + +ndi2 = ndw2[0, 0].add_nd_image( + data2, + ("time", "depth", "m", "n"), # specify all dim names + ("m", "n"), # specify spatial dims IN ORDER, rest are auto slider dims + name="4d-image", +) + +# change spatial dims on the fly +# ndi.spatial_dims = ("depth", "m", "n") + +ndw.show() +ndw2.show() +fpl.loop.run() diff --git a/examples/ndwidget/timeseries.py b/examples/ndwidget/timeseries.py new file mode 100644 index 000000000..b2fd6ff6e --- /dev/null +++ b/examples/ndwidget/timeseries.py @@ -0,0 +1,63 @@ +""" +NDWidget Timeseries +=================== + +NDWidget timeseries example +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# generate some toy timeseries data +n_datapoints = 100_000 # number of datapoints per line +n_freqs = 20 # number of frequencies +n_ampls = 15 # number of amplitudes +n_lines = 8 + +xs = np.linspace(0, 1000 * np.pi, n_datapoints) + +data = np.zeros(shape=(n_freqs, n_ampls, n_lines, n_datapoints, 2), dtype=np.float32) + +for freq in range(data.shape[0]): + for ampl in range(data.shape[1]): + ys = np.sin(xs * (freq + 1)) * (ampl + 1) + np.random.normal( + 0, 0.1, size=n_datapoints + ) + line = np.column_stack([xs, ys]) + data[freq, ampl] = np.stack([line] * n_lines) + + +# must define a reference range, this would often be your time dimension and corresponds to your x-dimension +ref = { + "freq": (1, n_freqs + 1, 1), + "ampl": (1, n_ampls + 1, 1), + "angle": (0, xs[-1], 0.1), +} + +ndw = fpl.NDWidget(ref_ranges=ref, size=(700, 560)) + +nd_lines = ndw[0, 0].add_nd_timeseries( + data, + ("freq", "ampl", "n_lines", "angle", "d"), + ("n_lines", "angle", "d"), + slider_dim_transforms={ + "angle": xs, + "ampl": lambda x: int(x + 1), + "freq": lambda x: int(x + 1), + }, + cmap="jet", + x_range_mode="auto", + display_window=np.pi * 10, + name="nd-sine" +) + +nd_lines.cmap = "tab10" + +subplot = ndw.figure[0, 0] +subplot.controller.add_camera(subplot.camera, include_state={"x", "width"}) + +ndw.show(maintain_aspect=False) +fpl.loop.run() diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb index 7b7551588..61bcb6b06 100644 --- a/examples/notebooks/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -719,8 +719,8 @@ "# we will add all the lines to the same subplot\n", "subplot = fig_lines[0, 0]\n", "\n", - "# plot sine wave, use a single color\n", - "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\")\n", + "# plot sine wave, use a single color for now, but we will set per-vertex colors later\n", + "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\", color_mode=\"vertex\")\n", "\n", "# you can also use colormaps for lines!\n", "cosine = subplot.add_line(data=cosine_data, thickness=12, cmap=\"autumn\")\n", diff --git a/examples/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py index b9df16026..fc228e5bf 100644 --- a/examples/scatter/scatter_iris.py +++ b/examples/scatter/scatter_iris.py @@ -35,6 +35,7 @@ cmap="tab10", cmap_transform=clusters_labels, markers=markers, + uniform_marker=False, ) figure.show() diff --git a/examples/scatter/scatter_size.py b/examples/scatter/scatter_size.py index 30d3e6ea3..2b3899dbe 100644 --- a/examples/scatter/scatter_size.py +++ b/examples/scatter/scatter_size.py @@ -35,7 +35,7 @@ ) # add a set of scalar sizes non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5 -figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red") +figure["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, uniform_size=False, colors="red") for graph in figure: graph.auto_scale(maintain_aspect=True) diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py index abddffee0..45f0a177c 100644 --- a/examples/scatter/scatter_validate.py +++ b/examples/scatter/scatter_validate.py @@ -41,6 +41,7 @@ uniform_edge_color=False, edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"], markers=list("osD+x^v<>*"), + uniform_marker=False, edge_width=2.0, sizes=20, uniform_size=True, @@ -64,6 +65,7 @@ sine, markers="s", sizes=xs * 5, + uniform_size=False, offset=(0, 2, 0) ) diff --git a/examples/scatter/spinning_spiral.py b/examples/scatter/spinning_spiral.py index 89e74eaec..4f947970a 100644 --- a/examples/scatter/spinning_spiral.py +++ b/examples/scatter/spinning_spiral.py @@ -34,7 +34,14 @@ canvas_kwargs={"max_fps": 500, "vsync": False} ) -spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", edge_colors=None, alpha=0.5, sizes=sizes) +spiral = figure[0, 0].add_scatter( + data, + cmap="viridis_r", + edge_colors=None, + alpha=0.5, + sizes=sizes, + uniform_size=False, +) # 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/inf_line.png b/examples/screenshots/inf_line.png new file mode 100644 index 000000000..65e3cf42b --- /dev/null +++ b/examples/screenshots/inf_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b4d50c4d7b48e9efc41416f95c04c6c6d58f56e9281bcc59344c50cf8329ae3 +size 11196 diff --git a/examples/screenshots/inf_line_cmap.png b/examples/screenshots/inf_line_cmap.png new file mode 100644 index 000000000..97a52776e --- /dev/null +++ b/examples/screenshots/inf_line_cmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7df196e26f1fbca8d080bf2d7d212710dc50ee228a6a6578092e8b3db049eba +size 10406 diff --git a/examples/screenshots/inf_line_cmap_transform.png b/examples/screenshots/inf_line_cmap_transform.png new file mode 100644 index 000000000..518e03e88 --- /dev/null +++ b/examples/screenshots/inf_line_cmap_transform.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81ccedda34424e484d93dc518e30d4213f39540e18b7884f046eb6e422331952 +size 12268 diff --git a/examples/screenshots/inf_line_pairs.png b/examples/screenshots/inf_line_pairs.png new file mode 100644 index 000000000..3300ddfd6 --- /dev/null +++ b/examples/screenshots/inf_line_pairs.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1e4bc41a719d49215904316c536e71520035404c6e0d1e424f4c0317f194fe8 +size 37153 diff --git a/examples/screenshots/line_dash.png b/examples/screenshots/line_dash.png new file mode 100644 index 000000000..fe26f3819 --- /dev/null +++ b/examples/screenshots/line_dash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c0d25e65af29f7ffb29e9882907b385ecdbca7f897be7b2e3762d00310e640d +size 14045 diff --git a/examples/selection_tools/highlight_selector.py b/examples/selection_tools/highlight_selector.py new file mode 100644 index 000000000..e4c9dde91 --- /dev/null +++ b/examples/selection_tools/highlight_selector.py @@ -0,0 +1,89 @@ +""" +Highlight Selector +================== + +NDWidget with a time-varying 100x100 image (two circles driven by sine/cosine) +and a heatmap of all pixel timeseries. Clicking a row of the heatmap highlights +that row and the corresponding pixel on the image. +Shift-click appends; plain click replaces the selection. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from fastplotlib.graphics import ImageGraphic +from fastplotlib.graphics.selectors import ImageHighlightSelector +from fastplotlib.utils.functions import heatmap_to_positions + +# --- synthetic data --- +n_t = 100 +n_y, n_x = 100, 100 + +rng = np.random.default_rng(0) +vol = np.zeros((n_t, n_y, n_x), dtype=np.float32) + +yy, xx = np.ogrid[:n_y, :n_x] +mask1 = (yy - 30) ** 2 + (xx - 30) ** 2 < 15**2 +mask2 = (yy - 70) ** 2 + (xx - 70) ** 2 < 15**2 + +t = np.linspace(0, 2 * np.pi, n_t) +for i in range(n_t): + vol[i, mask1] = np.sin(t[i]) + rng.normal(0, 0.05, mask1.sum()) + vol[i, mask2] = np.cos(t[i]) + rng.normal(0, 0.05, mask2.sum()) + +# heatmap: (n_pixels, n_t), then convert to positions for add_nd_timeseries +heatmap = vol.reshape(n_t, n_y * n_x).T.astype(np.float32) # (n_pixels, n_t) +xvals = np.arange(n_t, dtype=np.float32) +heatmap_pos = heatmap_to_positions(heatmap, xvals) # (n_pixels, n_t, 2) + +# --- layout --- +ndw = fpl.NDWidget(ref_ranges={"t": (0, n_t, 1)}, shape=(1, 2), size=(1400, 560)) + +nd_img = ndw[0, 0].add_nd_image(vol, ("t", "y", "x"), ("y", "x"), name="image") + +nd_hm = ndw[0, 1].add_nd_timeseries( + heatmap_pos, + dims=("pixel", "t", "xy"), + spatial_dims=("pixel", "t", "xy"), + graphic_type=ImageGraphic, + x_range_mode="fixed", + display_window=None, + name="heatmap", +) + +# --- highlight selectors --- +img_sel = ImageHighlightSelector(color="w", alpha=0.4) +img_sel.add_graphic(nd_img.graphic) + +hm_sel = ImageHighlightSelector(color="w", alpha=0.4) +hm_sel.add_graphic(nd_hm.graphic) + + +@nd_hm.graphic.add_event_handler("double_click") +def on_heatmap_click(ev): + idx = ev.pick_info.get("index") + if idx is None: + return + # index = (col, row) = (timepoint, pixel_idx) + pixel_idx = idx[1] + row = pixel_idx // n_x + col = pixel_idx % n_x + + if "Shift" in ev.modifiers: + hm_sel.append("rows", pixel_idx) + img_sel.append("pixels", np.array([[row, col]])) + print(hm_sel.selection) + else: + hm_sel.selection = {"rows": [pixel_idx]} + img_sel.selection = {"pixels": [np.array([[row, col]])]} + + +ndw.show(maintain_aspect=False) + +# 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/selection_tools/visibility_selector.py b/examples/selection_tools/visibility_selector.py new file mode 100644 index 000000000..b81009389 --- /dev/null +++ b/examples/selection_tools/visibility_selector.py @@ -0,0 +1,185 @@ +""" +Visibility and Highlight Selector +================================= + +Example with an image that contains time-varying signals. An ``ImageHighlightSelector`` is created with pre-loaded +options for either contour outlines or filled masks that spatially denote a unique signal in the image. A +``VisiblitySelector`` is used on a LineCollection. When the image is clicked, the closest spatial signal is highlighted +and the corresponding line is made visible. Shift + click to multi-select signals. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +from functools import partial +import numpy as np +from scipy.ndimage import binary_erosion +import fastplotlib as fpl +import cmap as cmap_lib + +n_t = 500 +n_y, n_x = 128, 128 +n_circles = 32 +radius = 4 # diameter 5 + +rng = np.random.default_rng(0) + +# Random circle centers +centers = rng.integers(0, [n_y, n_x], size=(n_circles, 2)) + +yy, xx = np.ogrid[:n_y, :n_x] + +movies_sessions = list() +contours_sessions = list() +signals_sessions = list() +centers_per_session = list() +indices_per_session = list() + +# just generate multi-session toy data +for session_index in range(3): + masks = [] + contours = [] # perimeter pixel coordinates per circle + + for cy, cx in centers: + mask = (yy - cy) ** 2 + (xx - cx) ** 2 <= radius**2 + masks.append(mask) + # Perimeter = filled mask minus its erosion + perimeter = mask # & ~binary_erosion(mask) + contours.append(np.argwhere(perimeter)) # shape (K, 2), columns are [y, x] + + images = np.zeros((n_t, n_y, n_x), dtype=np.float32) + t = np.linspace(0, 10 * np.pi, n_t) + phases = 2 * np.pi * np.arange(n_circles) / n_circles + + signals = list() + for j, mask in enumerate(masks): + signal = np.sin(t + phases[j]).astype(np.float32) # (n_t,) + noise = rng.normal(0, 0.05, (n_t, mask.sum())).astype(np.float32) # (n_t, K) + signal = signal[:, None] + noise + images[:, mask] += signal + signals.append(signal.mean(axis=1)) + + signals = np.stack(signals) + + # just to create diff indices per session + local_indices = np.roll(np.arange(n_circles), shift=session_index) + + indices_per_session.append(local_indices) + + movies_sessions.append(images) + + # re-order stuff in local index order + centers_per_session.append(centers[local_indices]) + contours_sessions.append([contours[i] for i in local_indices]) + signals_sessions.append(signals[local_indices]) + + +# Just NDWidget & figure stuff +extents = { + "images-0": (0, 0.33, 0, 0.33), + "signals-0": (0.33, 1, 0, 0.33), + "images-1": (0, 0.33, 0.33, 0.67), + "signals-1": (0.33, 1, 0.33, 0.67), + "images-2": (0, 0.33, 0.67, 1), + "signals-2": (0.33, 1, 0.67, 1), +} + +ref_range = {"time": (0, n_t, 1)} +ndw = fpl.NDWidget( + ref_range, + extents=extents, + controller_ids=[ + ("images-0", "images-1", "images-2"), + ], + size=(1300, 1000) +) + +# create selection vector +sv = fpl.SelectionVector() + +# mapping to go from master index -> per session index for a given session +# this must be a vector -> vector mapping since multiple things can be selected +def master_to_local_index(session_id: int, selection_indices: list[int]) -> list[int]: + return [i + session_id for i in selection_indices] + + +# image click changes the selection, can change the selection vector in any other way too +def image_clicked(session, ev): + col, row = ev.pick_info["index"] + + local_index = np.argmin( + np.linalg.norm(centers_per_session[session] - np.array([row, col]), axis=1) + ) + + # inverse transform, local scalar index -> master index + master_index = local_index - session + + print(local_index, master_index) + + global sv + + if "Shift" in ev.modifiers: + sv.append(master_index) + else: + # just one item selected + sv.selection = [master_index] + + for subplot in ndw.figure: + if "signals" in subplot.name: + subplot.auto_scale() + + +# iterate through all the toy data, create NDGraphics and selectors +for session_index, (indices, movie, contours, signals) in enumerate( + zip(indices_per_session, movies_sessions, contours_sessions, signals_sessions) +): + # create NDImage, nothing special here + ndi = ndw[f"images-{session_index}"].add_nd_image( + movie, + dims=("time", "m", "n"), + spatial_dims=list("mn"), + ) + ndi.graphic.cmap = "gray" + # create ND Timeseries, again nothing special + ndt = ndw[f"signals-{session_index}"].add_nd_timeseries( + fpl.utils.heatmap_to_positions(signals, xvals=np.arange(0, n_t)), + dims=("l", "time", "d"), + spatial_dims=("l", "time", "d"), + x_range_mode="fixed", + display_window=None, + ) + + # Create selectors + # image highlight selector for this session + image_selector = fpl.ImageHighlightSelector( + lut="tab10", + selection_options={"pixels": contours}, # pre-loaded selection options + options_alpha=0.1, # unselected contours shown with low alpha + options_color="w", # unselected contours shown this color + lut_wrap="repeat", # cycles through tab10 colormap if you select > 10 items + alpha=0.7, # highlight alpha + ) + + # selector that toggles visibility of lines in the line stack + # use same lut as the image highlight + traces_visible_selector = fpl.VisibilitySelector( + ndt.graphic, lut="tab10", lut_wrap="repeat" + ) + + # target graphic, you can also add more target graphics later + # as long as they are in the same "selection space", ex: each movie for single-session + # each selector manages ONE buffer, so the same pixels will be highlighted on all graphics + # targetted by a selector. + image_selector.add_graphic(ndi.graphic) + # when image is double clicked, calls the handler + ndi.graphic.add_event_handler(partial(image_clicked, session_index), "double_click") + + # add selectors to SelectionVector + # with mapping that defines how to map from master index to local index for this session + mapping = partial(master_to_local_index, session_index) + sv.add_selector((image_selector, mapping)) + sv.add_selector((traces_visible_selector, mapping)) + +ndw.show() + +fpl.loop.run() diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 6dab91605..1e7b30854 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -1,13 +1,19 @@ -from pathlib import Path - from ._version import __version__, version_info # this must be the first import for auto-canvas detection from .utils import loop # noqa +from .utils import ( + config, + enums, + enumerate_adapters, + select_adapter, + print_wgpu_report, + protocols, +) from .graphics import * from .graphics.features import GraphicFeatureEvent from .graphics.selectors import * -from .graphics.utils import pause_events +from .graphics.utils import pause_events, get_nearest_graphics, get_nearest_graphics_indices from .legends import * from .tools import * @@ -19,8 +25,7 @@ else: from .layouts import Figure -from .widgets import ImageWidget -from .utils import config, enumerate_adapters, select_adapter, print_wgpu_report +from .widgets import NDWidget if len(enumerate_adapters()) < 1: diff --git a/fastplotlib/axes/__init__.py b/fastplotlib/axes/__init__.py new file mode 100644 index 000000000..bf9f72e04 --- /dev/null +++ b/fastplotlib/axes/__init__.py @@ -0,0 +1,8 @@ +from ._axes import Grid, Grids, Ruler, Axes + +__all__ = [ + "Grid", + "Grids", + "Ruler", + "Axes", +] diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/axes/_axes.py similarity index 73% rename from fastplotlib/graphics/_axes.py rename to fastplotlib/axes/_axes.py index 5b4c21682..dfd488f86 100644 --- a/fastplotlib/graphics/_axes.py +++ b/fastplotlib/axes/_axes.py @@ -1,3 +1,5 @@ +import math + import numpy as np import pygfx @@ -5,7 +7,6 @@ from ..utils.enums import RenderQueue - GRID_PLANES = ["xy", "xz", "yz"] CANONICAL_BAIS = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) @@ -143,6 +144,110 @@ def yz(self) -> Grid: return self._yz +class Ruler(pygfx.Ruler): + """pygfx.Ruler subclass that adds a rotated axis label.""" + + def __init__(self, *, color="#fff", alpha_mode=None, render_queue=None, **kwargs): + super().__init__( + color=color, alpha_mode=alpha_mode, render_queue=render_queue, **kwargs + ) + self._label = pygfx.Text( + screen_space=True, + anchor="middle-center", + font_size=16, + material=pygfx.TextMaterial( + color=color, + alpha_mode="auto", + render_queue=RenderQueue.overlay + 50, + aa=True, + ), + ) + self._label.visible = False + self.add(self._label) + + @property + def label(self) -> pygfx.Text: + """Axis label. Set text via ``label.set_text('label text')``""" + return self._label + + @property + def color(self): + return self._text.material.color + + @color.setter + def color(self, color): + self._text.material.color = color + self._line.material.color = color + self._points.material.edge_color = color + self._label.material.color = color + + def update(self, camera, canvas_size): + stats = super().update(camera, canvas_size) + self._update_label() + return stats + + def _update_label(self): + # update the label position + t1, t2 = self._visible_part_coords + if t1 == t2: + self._label.visible = False + return + self._label.visible = True + + mid_t = 0.5 * (t1 + t2) + mid_pos = self._start_pos * (1 - mid_t) + self._end_pos * mid_t + + world_vec = self._end_pos - self._start_pos + world_len = np.linalg.norm(world_vec) + screen_len = np.linalg.norm(self._screen_vec) + + if world_len > 0 and screen_len > 0: + world_dir = world_vec / world_len + # perpendicular in the xy plane: CCW = "left", CW = "right" + if self.tick_side == "left": + perp_world = np.array([-world_dir[1], world_dir[0], 0.0]) + else: + perp_world = np.array([world_dir[1], -world_dir[0], 0.0]) + + # same perpendicular in screen space, for projecting tick label rects + screen_dir = self._screen_vec / screen_len + if self.tick_side == "left": + px, py = -screen_dir[1], screen_dir[0] + else: + px, py = screen_dir[1], -screen_dir[0] + + # max extent of tick labels in the perpendicular direction. + # tick labels are unrotated screen-space text, so we project their + # axis-aligned _rect onto (px, py) directly. + visible_blocks = [ + b + for b in self.text._text_blocks + if b._rect.width > 0 or b._rect.height > 0 + ] + if visible_blocks: + tick_extent_px = max( + max(px, 0) * b._rect.right + + min(px, 0) * b._rect.left + + max(py, 0) * b._rect.top + + min(py, 0) * b._rect.bottom + for b in visible_blocks + ) + else: + tick_extent_px = 0.0 + + offset_px = max(tick_extent_px, 0.0) + self._label.font_size + mid_pos = mid_pos + (offset_px / (screen_len / world_len)) * perp_world + + self._label.local.position = mid_pos + + vec = self._visible_part_screen_vec + angle = math.atan2(vec[1], vec[0]) + # pylinalg uses [x, y, z, w] quaternion format + self._label.local.rotation = np.array( + [0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)] + ) + + class Axes: def __init__( self, @@ -191,15 +296,9 @@ def __init__( ) # create ruler for each dim - 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 - ) + self._x = Ruler(alpha_mode="solid", render_queue=RenderQueue.axes, **x_kwargs) + self._y = Ruler(alpha_mode="solid", render_queue=RenderQueue.axes, **y_kwargs) + self._z = 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: @@ -208,6 +307,7 @@ def __init__( ruler.text.material.depth_compare = "<=" ruler.text.material.alpha_mode = "auto" ruler.text.material.aa = True + ruler.label.material.depth_compare = "<=" self._offset = offset @@ -301,6 +401,8 @@ def __init__( self._basis = None self.basis = basis + self._last_state = self._get_view_state() + @property def world_object(self) -> pygfx.WorldObject: return self._world_object @@ -317,7 +419,7 @@ def basis(self, basis: np.ndarray): # apply quaternion to each of x, y, z rulers for dim, cbasis, new_basis in zip(["x", "y", "z"], CANONICAL_BAIS, basis): - ruler: pygfx.Ruler = getattr(self, dim) + ruler: Ruler = getattr(self, dim) ruler.local.rotation = quat_from_vecs(cbasis, new_basis) @property @@ -330,17 +432,17 @@ def offset(self, value: np.ndarray): self._offset = value @property - def x(self) -> pygfx.Ruler: + def x(self) -> Ruler: """x axis ruler""" return self._x @property - def y(self) -> pygfx.Ruler: + def y(self) -> Ruler: """y axis ruler""" return self._y @property - def z(self) -> pygfx.Ruler: + def z(self) -> Ruler: """z axis ruler""" return self._z @@ -362,6 +464,16 @@ def colors(self, colors: tuple[pygfx.Color | str]): for dim, color in zip(["x", "y", "z"], colors): getattr(self, dim).line.material.color = color + @property + def color(self) -> pygfx.Color: + """get or set a single color for all rulers""" + return self._x.color + + @color.setter + def color(self, color: pygfx.Color | str): + for ruler in (self._x, self._y, self._z): + ruler.color = color + @property def auto_grid(self) -> bool: """auto adjust the grid on each render cycle""" @@ -388,6 +500,7 @@ def intersection(self) -> tuple[float, float, float] | None: def intersection(self, intersection: tuple[float, float, float] | None): """ intersection point of [x, y, z] rulers. + Set (0, 0, 0) for origin Set to `None` to follow when panning through the scene with orthographic projection """ @@ -402,9 +515,16 @@ def intersection(self, intersection: tuple[float, float, float] | None): self._intersection = tuple(float(v) for v in intersection) + def _get_view_state(self) -> tuple[bytes, tuple[int, int], tuple[int, int], bytes]: + viewport = self._plot_area.viewport + cam_matrix = self._plot_area.camera.camera_matrix.tobytes() + scale = self._plot_area.camera.local.scale.tobytes() + + return (cam_matrix, viewport.rect, viewport.logical_size, scale) + def update_using_bbox(self, bbox): """ - Update the w.r.t. the given bbox + Update the axes w.r.t. the given bbox Parameters ---------- @@ -430,6 +550,33 @@ def update_using_bbox(self, bbox): self.update(bbox, intersection) + def _auto_intersection_pos(self, xpos, ypos, width, height): + # returns the intersection position for the axis so they are placed in the bottom left corner + margin = 4 + + y_blocks = [b for b in self.y.text._text_blocks if b._rect.width > 0] + y_extent = ( + max(abs(b._rect.left) for b in y_blocks) + if y_blocks + else 6 * self.y.text.font_size + ) + if self.y._label._text_blocks: + # label center is tick_extent + font_size from ruler; body adds font_size/2 more + y_extent += 1.5 * self.y._label.font_size + + x_blocks = [b for b in self.x.text._text_blocks if b._rect.height > 0] + x_extent = ( + max(abs(b._rect.bottom) for b in x_blocks) + if x_blocks + else 1.5 * self.x.text.font_size + ) + if self.x._label._text_blocks: + x_extent += 1.5 * self.x._label.font_size + + return self._plot_area.map_screen_to_world( + (xpos + y_extent + margin, ypos + height - x_extent - margin) + ) + def update_using_camera(self): """ Update the axes w.r.t the current camera state @@ -444,6 +591,10 @@ def update_using_camera(self): if not self.visible: return + state = self._get_view_state() + if state == self._last_state: + # no changes in the camera or viewport rect + return if self._plot_area.camera.fov == 0: xpos, ypos, width, height = self._plot_area.viewport.rect @@ -453,27 +604,6 @@ def update_using_camera(self): xmin, xmax = xpos, xpos + width ymin, ymax = ypos + height, ypos - # apply quaternion to account for rotation of axes - # xmin, _, _ = vec_transform_quat( - # [xmin, ypos + height / 2, 0], - # self.x.local.rotation - # ) - # - # xmax, _, _ = vec_transform_quat( - # [xmax, ypos + height / 2, 0], - # self.x.local.rotation, - # ) - # - # _, ymin, _ = vec_transform_quat( - # [xpos + width / 2, ymin, 0], - # self.y.local.rotation - # ) - # - # _, ymax, _ = vec_transform_quat( - # [xpos + width / 2, ymax, 0], - # self.y.local.rotation - # ) - min_vals = self._plot_area.map_screen_to_world((xmin, ymin)) max_vals = self._plot_area.map_screen_to_world((xmax, ymax)) @@ -498,12 +628,7 @@ def update_using_camera(self): if self.intersection is None: if self._plot_area.camera.fov == 0: - # place the ruler close to the left and bottom edges of the viewport - # TODO: determine this for perspective projections - xscreen_10, yscreen_10 = xpos + (width * 0.1), ypos + (height * 0.9) - intersection = self._plot_area.map_screen_to_world( - (xscreen_10, yscreen_10) - ) + intersection = self._auto_intersection_pos(xpos, ypos, width, height) else: # force origin since None is not supported for Persepctive projections self._intersection = (0, 0, 0) @@ -515,6 +640,8 @@ def update_using_camera(self): self.update(bbox, intersection) + self._last_state = state + def update(self, bbox, intersection): """ Update the axes using the given bbox and ruler intersection point diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 3d01e4a35..3a0c56077 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,19 +1,22 @@ from ._base import Graphic from .line import LineGraphic +from .inf_line import InfLineGraphic from .scatter import ScatterGraphic -from .image import ImageGraphic +from .image import ImageGraphic, ImageYUVGraphic from .image_volume import ImageVolumeGraphic from ._vectors import VectorsGraphic from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack - +from .scatter_collection import ScatterCollection, ScatterStack __all__ = [ "Graphic", "LineGraphic", + "InfLineGraphic", "ScatterGraphic", "ImageGraphic", + "ImageYUVGraphic", "ImageVolumeGraphic", "VectorsGraphic", "MeshGraphic", @@ -22,4 +25,6 @@ "TextGraphic", "LineCollection", "LineStack", + "ScatterCollection", + "ScatterStack", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 5279cf306..24a59a7e4 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -18,7 +18,6 @@ import pygfx from .features import ( - BufferManager, Deleted, Name, Offset, @@ -28,7 +27,7 @@ AlphaMode, Visible, ) -from ._axes import Axes +from ..axes import Axes HexStr: TypeAlias = str WorldObjectID: TypeAlias = int @@ -67,7 +66,6 @@ class Graphic: _fpl_support_tooltip: bool = True def __init_subclass__(cls, **kwargs): - # set of all features cls._features = { **cls._features, @@ -178,10 +176,11 @@ def __init__( self._alpha_mode = AlphaMode(alpha_mode) self._visible = Visible(visible) self._block_events = False + self._block_handlers = list() self._axes: Axes = None - self._right_click_menu = None + self._imgui_right_click = None # store ids of all the WorldObjects that this Graphic manages/uses self._world_object_ids = list() @@ -274,6 +273,11 @@ def block_events(self) -> bool: def block_events(self, value: bool): self._block_events = value + @property + def block_handlers(self) -> list: + """Used to block event handlers for a graphic and prevent recursion.""" + return self._block_handlers + @property def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" @@ -285,15 +289,8 @@ def _set_world_object(self, wo: pygfx.WorldObject): # add to world object -> graphic mapping if isinstance(wo, pygfx.Group): - for child in wo.children: - if isinstance( - child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) - ): - # unique 32 bit integer id for each world object - global_id = child.id - WORLD_OBJECT_TO_GRAPHIC[global_id] = self - # store id to pop from dict when graphic is deleted - self._world_object_ids.append(global_id) + # for Graphics which use a pygfx.Group, ImageGraphic and graphic collections + self._add_group_graphic_map(wo) else: global_id = wo.id WORLD_OBJECT_TO_GRAPHIC[global_id] = self @@ -322,6 +319,27 @@ def _set_world_object(self, wo: pygfx.WorldObject): if not all(wo.world.scale == self.scale): self.scale = self.scale + def _add_group_graphic_map(self, wo: pygfx.Group): + # add the children of the group to the WorldObject -> Graphic map + # used by images since they create new WorldObject ImageTiles when a different buffer size is required + # also used by GraphicCollections inititally, but not used for reseting like images + for child in wo.children: + if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): + # unique 32 bit integer id for each world object + global_id = child.id + WORLD_OBJECT_TO_GRAPHIC[global_id] = self + # store id to pop from dict when graphic is deleted + self._world_object_ids.append(global_id) + + def _remove_group_graphic_map(self, wo: pygfx.Group): + # remove the children of the group to the WorldObject -> Graphic map + for child in wo.children: + if isinstance(child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line)): + # unique 32 bit integer id for each world object + global_id = child.id + WORLD_OBJECT_TO_GRAPHIC.pop(global_id) + self._world_object_ids.remove(global_id) + @property def tooltip_format(self) -> Callable[[dict], str] | None: """ @@ -444,6 +462,9 @@ def _handle_event(self, callback, event: pygfx.Event): if self.block_events: return + if callback in self._block_handlers: + return + if event.type in self._features: # for feature events event._target = self.world_object @@ -501,6 +522,23 @@ def my_handler(event): feature = getattr(self, f"_{t}") feature.remove_event_handler(wrapper) + def _parse_positions(self, position: tuple | np.ndarray) -> np.ndarray: + """ + Converts position data (in the form of tuple or np.ndarray) into a (num_points, 3)-shaped np.ndarray for processing + """ + position = np.asarray(position) + + if position.ndim not in (1,2): + raise ValueError(f"position must be of shape (num_points, 3) or (3,)") + + if position.ndim == 1: + position = position[None, :] + + if position.shape[-1] != 3: + raise ValueError(f"position must be of shape (num_points, 3) or (3,)") + + return position + def map_model_to_world( self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray ) -> np.ndarray: @@ -509,27 +547,18 @@ def map_model_to_world( Parameters ---------- - position: (float, float, float) or (float, float) - (x, y, z) or (x, y) position. If z is not provided then the graphic's offset z is used. + position: tuple of (x, y, z) or np.ndarray of shape (num_points, 3) + The xyz positions we wish to map to world space Returns ------- np.ndarray - (x, y, z) position in world space - + either shape (3,) or (num_points, 3), specifying position in world space """ - - if len(position) == 2: - # use z of the graphic - position = [*position, self.offset[-1]] - - if len(position) != 3: - raise ValueError( - f"position must be tuple or array indicating (x, y, z) position in *model space*" - ) + position = self._parse_positions(position) # apply world transform to project from model space to world space - return la.vec_transform(position, self.world_object.world.matrix) + return la.vec_transform(position, self.world_object.world.matrix).squeeze() def map_world_to_model( self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray @@ -539,26 +568,20 @@ def map_world_to_model( Parameters ---------- - position: (float, float, float) or (float, float) - (x, y, z) or (x, y) position. If z is not provided then 0 is used. + position: tuple of (x, y, z) or np.ndarray of shape (num_points, 3) + The xyz positions we wish to map to model space Returns ------- np.ndarray - (x, y, z) position in world space + either shape (3,) or (num_points, 3), specifying position in model space """ + position = self._parse_positions(position) - if len(position) == 2: - # use z of the graphic - position = [*position, self.offset[-1]] - - if len(position) != 3: - raise ValueError( - f"position must be tuple or array indicating (x, y, z) position in *model space*" - ) - - return la.vec_transform(position, self.world_object.world.inverse_matrix) + return la.vec_transform( + position, self.world_object.world.inverse_matrix + ).squeeze() def format_pick_info(self, ev: pygfx.PointerEvent) -> str: """ @@ -669,21 +692,111 @@ def add_axes(self): self._axes.update_using_bbox(self.world_object.get_world_bounding_box()) @property - def right_click_menu(self): - return self._right_click_menu + def imgui_right_click(self): + """ + The imgui popup that is opened by a right-click on this graphic. + + Returns + ------- + ImguiPopup | None + + """ + return self._imgui_right_click + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup that is opened by a right-click on this graphic, replaces the popup of the subplot or + Figure for this graphic. Can also be used as a decorator, see the + ``ImguiFigure.set_imgui_right_click`` examples. + + Parameters + ---------- + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup - @right_click_menu.setter - def right_click_menu(self, menu): + """ if not IMGUI: raise ImportError( - "imgui is required to set right-click menus:\npip install imgui_bundle" + "imgui is required to set right-click popups:\npip install imgui_bundle" + ) + + from ..layouts._subplot import Subplot + from ..ui._base import ImguiPopup, _wrap_update_call + + if not isinstance(self._plot_area, Subplot): + raise TypeError( + "graphic must be added to a subplot before setting an imgui right-click popup on it" + ) + + figure = self._plot_area.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError( + "imgui right-click popups can only be set on a graphic in an ImguiFigure" + ) + + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the right-click popup of this graphic. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + from ..ui._base import _wrap_update_call + + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this graphic to append to, set one using " + "`graphic.set_imgui_right_click()`" ) - self._right_click_menu = menu - menu.owner = self + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui - def _fpl_request_right_click_menu(self): - pass + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_right_click(self): + """ + Remove and return the right-click popup of this graphic + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later + + """ + popup = self._imgui_right_click + self._imgui_right_click = None - def _fpl_close_right_click_menu(self): - pass + return popup diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index af7d7badb..426079730 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,4 +1,6 @@ -from typing import Any, Sequence +from numbers import Real +from typing import Any, Sequence, Literal +from warnings import warn import numpy as np @@ -16,14 +18,25 @@ class PositionsGraphic(Graphic): """Base class for LineGraphic and ScatterGraphic""" + # the feature used to manage a per-vertex color buffer, subclasses may override + _VertexColorsCls = VertexColors + @property def data(self) -> VertexPositions: - """Get or set the graphic's data""" + """ + Get or set the graphic's data. + + Note that if the number of datapoints does not match the number of + current datapoints a new buffer is automatically allocated. This can + have performance drawbacks when you have a very large number of datapoints. + This is usually fine as long as you don't need to do it hundreds of times + per second. + """ return self._data @data.setter def data(self, value): - self._data[:] = value + self._data.set_value(self, value) @property def colors(self) -> VertexColors | pygfx.Color: @@ -36,11 +49,59 @@ def colors(self) -> VertexColors | pygfx.Color: @colors.setter def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): + self._colors.set_value(self, value) + + @property + def color_mode(self) -> Literal["uniform", "vertex"]: + """ + Get or set the color mode. Note that after setting the color_mode, you will have to set the `colors` + as well for switching between 'uniform' and 'vertex' modes. + """ + return self.world_object.material.color_mode + + @color_mode.setter + def color_mode(self, mode: Literal["uniform", "vertex"]): + valid = ("uniform", "vertex") + if mode not in valid: + raise ValueError(f"`color_mode` must be one of : {valid}") + if mode == "vertex" and isinstance(self._colors, UniformColor): + # uniform -> vertex + # need to make a new vertex buffer and get rid of uniform buffer + new_colors = self._create_colors_buffer(self._colors.value, "vertex") + # we can't clear world_object.material.color so just set the colors buffer on the geometry + # this doesn't really matter anyways since the lingering uniform color takes up just a few bytes + self.world_object.geometry.colors = new_colors._fpl_buffer + + elif mode == "uniform" and isinstance(self._colors, VertexColors): + # vertex -> uniform + # use first vertex color and spit out a warning + warn( + "changing `color_mode` from vertex -> uniform, will use first vertex color " + "for the uniform and discard the remaining color values" + ) + new_colors = self._create_colors_buffer(self._colors.value[0], "uniform") + self.world_object.geometry.colors = None + self.world_object.material.color = new_colors.value + + # clear out cmap + self._cmap.clear_event_handlers() + self._cmap = None + + else: + # no change, return + return + + # restore event handlers onto the new colors feature + new_colors._event_handlers[:] = self._colors._event_handlers + self._colors.clear_event_handlers() + # this should trigger gc + self._colors = new_colors + + # this is created so that cmap can be set later if isinstance(self._colors, VertexColors): - self._colors[:] = value + self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - elif isinstance(self._colors, UniformColor): - self._colors.set_value(self, value) + self.world_object.material.color_mode = mode @property def cmap(self) -> VertexCmap: @@ -53,8 +114,8 @@ def cmap(self) -> VertexCmap: @cmap.setter def cmap(self, name: str): - if self._cmap is None: - raise BufferError("Cannot use cmap with uniform_colors=True") + if self.color_mode == "uniform": + raise ValueError("cannot use `cmap` with `color_mode` = 'uniform'") self._cmap[:] = name @@ -71,14 +132,72 @@ def size_space(self): def size_space(self, value: str): self._size_space.set_value(self, value) + def _create_colors_buffer(self, colors, color_mode) -> UniformColor | VertexColors: + # creates either a UniformColor or VertexColors based on the given `colors` and `color_mode` + # if `color_mode` = "auto", returns {UniformColor | VertexColor} based on what the `colors` arg represents + # if `color_mode` = "uniform", it verifies that the user `colors` input represents just 1 color + # if `color_mode` = "vertex", always returns VertexColors regardless of whether `colors` represents >= 1 colors + + if isinstance(colors, VertexColors): + if color_mode == "uniform": + raise ValueError( + "if a `VertexColors` instance is provided for `colors`, " + "`color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) + # share buffer with existing colors instance + new_colors = colors + # blank colormap instance + self._cmap = VertexCmap(new_colors, cmap_name=None, transform=None) + + else: + # determine if a single or multiple colors were passed and decide color mode + if isinstance(colors, (pygfx.Color, str)) or ( + len(colors) in [3, 4] and all(isinstance(v, Real) for v in colors) + ): + # one color specified as a str or pygfx.Color, or one color specified with RGB(A) values + if color_mode in ("auto", "uniform"): + new_colors = UniformColor(colors) + else: + new_colors = self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) + + elif all(isinstance(c, (str, pygfx.Color)) for c in colors): + # sequence of colors + if color_mode == "uniform": + raise ValueError( + "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " + "`color_mode` = 'auto' or 'vertex' for multiple colors." + ) + new_colors = self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) + + elif len(colors) > 4: + # sequence of multiple colors, must again ensure color_mode is not uniform + if color_mode == "uniform": + raise ValueError( + "You passed `color_mode` = 'uniform', but specified a sequence of multiple colors. Use " + "`color_mode` = 'auto' or 'vertex' for multiple colors." + ) + new_colors = self._VertexColorsCls( + colors, n_colors=self._data.value.shape[0] + ) + else: + raise ValueError( + "`colors` must be a str, pygfx.Color, array, list or tuple indicating an RGB(A) color, or a " + "sequence of str, pygfx.Color, or array of shape [n_datapoints, 3 | 4]" + ) + + return new_colors + def __init__( self, data: Any, colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", - uniform_color: bool = False, cmap: str | VertexCmap = None, cmap_transform: np.ndarray = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", *args, **kwargs, @@ -86,25 +205,36 @@ def __init__( if isinstance(data, VertexPositions): self._data = data else: - self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + self._data = VertexPositions(data) if cmap_transform is not None and cmap is None: raise ValueError("must pass `cmap` if passing `cmap_transform`") + valid = ("auto", "uniform", "vertex") + + # default _cmap is None + self._cmap = None + + if color_mode not in valid: + raise ValueError(f"`color_mode` must be one of {valid}") + if cmap is not None: # if a cmap is specified it overrides colors argument - if uniform_color: - raise TypeError("Cannot use cmap if uniform_color=True") + if color_mode == "uniform": + raise ValueError( + "if a `cmap` is provided, `color_mode` must be 'vertex' or 'auto', not 'uniform'" + ) if isinstance(cmap, str): # make colors from cmap if isinstance(colors, VertexColors): # share buffer with existing colors instance for the cmap self._colors = colors - self._colors._shared += 1 else: # create vertex colors buffer - self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) + self._colors = self._VertexColorsCls( + "w", n_colors=self._data.value.shape[0] + ) # make cmap using vertex colors buffer self._cmap = VertexCmap( self._colors, @@ -115,34 +245,18 @@ def __init__( # use existing cmap instance self._cmap = cmap self._colors = cmap._vertex_colors + else: raise TypeError( "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" ) else: # no cmap given - if isinstance(colors, VertexColors): - # share buffer with existing colors instance - self._colors = colors - self._colors._shared += 1 - # blank colormap instance + self._colors = self._create_colors_buffer(colors, color_mode) + + # this is created so that cmap can be set later + if isinstance(self._colors, VertexColors): self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None) - else: - if uniform_color: - if not isinstance(colors, str): # not a single color - if not len(colors) in [3, 4]: # not an RGB(A) array - raise TypeError( - "must pass a single color if using `uniform_colors=True`" - ) - self._colors = UniformColor(colors) - self._cmap = None - else: - self._colors = VertexColors( - colors, n_colors=self._data.value.shape[0] - ) - self._cmap = VertexCmap( - self._colors, cmap_name=None, transform=None - ) self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 7f7410cf7..cc1840a56 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -4,6 +4,8 @@ SizeSpace, VertexPositions, VertexCmap, + InfLineAxisData, + InfLineColors, ) from ._mesh import ( MeshIndices, @@ -14,7 +16,7 @@ surface_data_to_mesh, triangulate_polygon, ) -from ._line import Thickness +from ._line import Thickness, DashPattern, parse_dash_pattern from ._scatter import ( VertexMarkers, UniformMarker, @@ -27,7 +29,10 @@ ) from ._image import ( TextureArray, + TextureYUV, + TupleYUV, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -80,10 +85,13 @@ "SizeSpace", "VertexPositions", "VertexCmap", + "InfLineAxisData", + "InfLineColors", "MeshIndices", "MeshCmap", "SurfaceData", "Thickness", + "DashPattern", "VertexMarkers", "UniformMarker", "UniformEdgeColor", @@ -93,7 +101,10 @@ "VertexPointSizes", "UniformSize", "TextureArray", + "TextureYUV", + "TupleYUV", "ImageCmap", + "ImageGamma", "ImageVmin", "ImageVmax", "ImageInterpolation", diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 779310476..68fe54c33 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,5 +1,6 @@ +import weakref from warnings import warn -from typing import Literal +from typing import Callable import numpy as np from numpy.typing import NDArray @@ -78,7 +79,7 @@ def block_events(self, val: bool): """ self._block_events = val - def add_event_handler(self, handler: callable): + def add_event_handler(self, handler: Callable): """ Add an event handler. All added event handlers are called when this feature changes. @@ -89,7 +90,7 @@ def add_event_handler(self, handler: callable): Parameters ---------- - handler: callable + handler: Callable a function to call when this feature changes """ @@ -102,7 +103,7 @@ def add_event_handler(self, handler: callable): self._event_handlers.append(handler) - def remove_event_handler(self, handler: callable): + def remove_event_handler(self, handler: Callable): """ Remove a registered event ``handler``. @@ -137,32 +138,28 @@ class BufferManager(GraphicFeature): def __init__( self, - data: NDArray | pygfx.Buffer, - buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", - isolated_buffer: bool = True, + data: NDArray | pygfx.Buffer | None, **kwargs, ): 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) - bdata[:] = data[:] - else: - # user's input array is used as the buffer - bdata = data - - if isinstance(data, pygfx.Resource): - # already a buffer, probably used for - # managing another BufferManager, example: VertexCmap manages VertexColors - self._buffer = data - elif buffer_type == "buffer": - self._buffer = pygfx.Buffer(bdata) + + # if data is None, then the BufferManager just provides a view into an existing buffer + # example: VertexCmap is basically a view into VertexColors + if data is not None: + if isinstance(data, pygfx.Resource): + # already a buffer, probably used for + # managing another BufferManager, example: VertexCmap manages VertexColors + self._fpl_buffer = data + else: + # create a buffer + bdata = np.empty(data.shape, dtype=data.dtype) + bdata[:] = data[:] + + self._fpl_buffer = pygfx.Buffer(bdata) else: - raise ValueError( - "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" - ) + self._fpl_buffer = None - self._event_handlers: list[callable] = list() + self._event_handlers: list[Callable] = list() @property def value(self) -> np.ndarray: @@ -174,9 +171,10 @@ def set_value(self, graphic, value): self[:] = value @property - def buffer(self) -> pygfx.Buffer | pygfx.Texture: - """managed buffer""" - return self._buffer + def buffer(self) -> pygfx.Buffer: + """managed buffer, returns a weakref proxy""" + # the user should never create their own references to the buffer + return weakref.proxy(self._fpl_buffer) @property def __array_interface__(self): @@ -320,7 +318,7 @@ def __repr__(self): def block_reentrance(set_value): # decorator to block re-entrant set_value methods # useful when creating complex, circular, bidirectional event graphs - def set_value_wrapper(self: GraphicFeature, graphic_or_key, value): + def set_value_wrapper(self: GraphicFeature, graphic_or_key, value, **kwargs): """ wraps GraphicFeature.set_value @@ -336,7 +334,7 @@ def set_value_wrapper(self: GraphicFeature, graphic_or_key, value): try: # block re-execution of set_value until it has *fully* finished executing self._reentrant_block = True - set_value(self, graphic_or_key, value) + set_value(self, graphic_or_key, value, **kwargs) except Exception as exc: # raise original exception raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant! diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 648f79bc8..1d9092de5 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -1,16 +1,21 @@ from itertools import product - from math import ceil +from typing import Literal, TypeAlias +from warnings import warn +import cmap as cmap_lib import numpy as np +from numpy.typing import NDArray +import wgpu import pygfx + from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance -from ...utils import ( - make_colors, - get_cmap_texture, -) +from .utils import get_element_format_from_numpy_array +from ...utils import get_cmap_texture, ColorspacesRGB, ColorspacesYUV, ColorRange + +TupleYUV: TypeAlias = tuple[NDArray[np.uint8], NDArray[np.uint8], NDArray[np.uint8]] class TextureArray(GraphicFeature): @@ -33,36 +38,62 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True, property_name: str = "data"): + def __init__( + self, + data, + property_name: str = "data", + cpu_buffer: bool = True, + usage: wgpu.TextureUsage = 0, + colorspace: ColorspacesRGB = ColorspacesRGB.srgb, + ): super().__init__(property_name=property_name) - data = self._fix_data(data) + self._colorspace = ColorspacesRGB(colorspace) + self._cpu_buffer = cpu_buffer + data = self._check_data(data, colorspace, cpu_buffer) + + self._shape = data.shape shared = pygfx.renderers.wgpu.get_shared() self._texture_limit_2d = shared.device.limits["max-texture-dimension-2d"] - if isolated_buffer: - # useful if data is read-only, example: memmaps - self._value = np.zeros(data.shape, dtype=data.dtype) + if cpu_buffer: + # create a local buffer + self._value = np.empty(data.shape, dtype=data.dtype) self.value[:] = data[:] + usage = usage else: - # user's input array is used as the buffer - self._value = data + self._value = None + usage = wgpu.TextureUsage.COPY_DST | usage + # auto-determine format, adapted from pygfx.Texture + element_format = get_element_format_from_numpy_array(data) + if element_format is None: + raise ValueError( + f"Unsupported dtype/format for texture data: {data.dtype}" + ) + + if data.ndim == 3: + nchannels = data.shape[-1] + else: + nchannels = 1 + format_ = (f"{nchannels}x" + element_format).lstrip("1x") + + self._shape = data.shape # data start indices for each Texture self._row_indices = np.arange( 0, - ceil(self.value.shape[0] / self._texture_limit_2d) * self._texture_limit_2d, + ceil(self.shape[0] / self._texture_limit_2d) * self._texture_limit_2d, self._texture_limit_2d, ) self._col_indices = np.arange( 0, - ceil(self.value.shape[1] / self._texture_limit_2d) * self._texture_limit_2d, + ceil(self.shape[1] / self._texture_limit_2d) * self._texture_limit_2d, self._texture_limit_2d, ) # buffer will be an array of textures - self._buffer: np.ndarray[pygfx.Texture] = np.empty( + self._buffer: NDArray[pygfx.Texture] = np.empty( shape=(self.row_indices.size, self.col_indices.size), dtype=object ) @@ -70,20 +101,75 @@ def __init__(self, data, isolated_buffer: bool = True, property_name: str = "dat # iterate through each chunk of passed `data` # create a pygfx.Texture from this chunk - for _, buffer_index, data_slice in self: - texture = pygfx.Texture(self.value[data_slice], dim=2) + for _, buffer_index, slicer in self: + if cpu_buffer: + # texture gets the data directly + texture = pygfx.Texture( + self.value[slicer], + dim=2, + colorspace=colorspace, + usage=usage + ) + else: + # we only supply the size + w, h = data[slicer].shape[1], data[slicer].shape[0] + + texture = pygfx.Texture( + size=(w, h, 1), + dim=2, + colorspace=colorspace, + format=format_, + usage=usage, + ) + + # send the initial data + texture.send_data((0, 0, 0), data[slicer]) self.buffer[buffer_index] = texture @property - def value(self) -> np.ndarray: + def colorspace( + self, + ) -> ColorspacesRGB: + """Colorspace, read only""" + return self._colorspace + + @property + def cpu_buffer(self) -> bool: + """whether or not a cpu buffer exists for this TextureArray""" + return self._cpu_buffer + + @property + def shape(self) -> tuple[int, int] | tuple[int, int, int]: + """ + the shape of the represented data, [n_rows, n_cols] or [n_rows, n_cols, 3 | 4] + """ + return self._shape + + @property + def value(self) -> np.ndarray | None: + """array buffer if Texture has a cpu buffer, otherwise None""" return self._value - def set_value(self, graphic, value): - self[:] = value + def set_value(self, graphic, value: np.ndarray): + if not self.cpu_buffer: + if isinstance(value, np.ndarray): + # if cpu_buffer is False, we directly send data to the GPU + if value.shape != self.shape: + raise ValueError( + f"new data shape must be the same as the original data array if `cpu_buffer=False`" + f"original data shape was: {self.shape}, data passed is of shape: {value.shape}" + ) + for texture, buffer_index, slicer in self: + chunk = value[slicer] + texture.send_data((0, 0, 0), chunk) + + else: + # set the cpu buffer, it will be marked for upload + self[:] = value @property - def buffer(self) -> np.ndarray[pygfx.Texture]: + def buffer(self) -> NDArray[pygfx.Texture]: return self._buffer @property @@ -102,15 +188,30 @@ def col_indices(self) -> np.ndarray: """ return self._col_indices - def _fix_data(self, data): + def _check_data(self, data, colorspace, cpu_buffer): + # make sure data ndim is valid for the given colorspace + if data.ndim not in (2, 3): raise ValueError( - "image data must be 2D with or without an RGB(A) dimension, i.e. " + "the image data must be 2D with or without an RGB(A) dimension, i.e. " "it must be of shape [rows, cols], [rows, cols, 3] or [rows, cols, 4]" ) - # let's just cast to float32 always - return data.astype(np.float32) + if data.ndim == 3 and not cpu_buffer: + # wgpu only supports rgba, it does not support rgb + if data.shape[-1] != 4: + raise ValueError( + "if the colorspace is 'srgb', 'tex-srgb', or 'physical' and `cpu_buffer=False`" + "the image data MUST be RGBA, with shape [rows, cols, 4]. WGPU does not support " + "rgb textures. You must either supply full a RGBA array with `cpu_buffer=False` or " + "use `cpu_buffer=True` which supports RGB arrays." + ) + + if data.itemsize == 8: + warn(f"casting {data.dtype} array to float32") + return data.astype(np.float32) + + return data def __iter__(self): self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) @@ -133,22 +234,33 @@ def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]] chunk_index = (chunk_row, chunk_col) # stop indices of big data array for this chunk - row_stop = min(self.value.shape[0], data_row_start + self._texture_limit_2d) - col_stop = min(self.value.shape[1], data_col_start + self._texture_limit_2d) + row_stop = min(self.shape[0], data_row_start + self._texture_limit_2d) + col_stop = min(self.shape[1], data_col_start + self._texture_limit_2d) # row and column slices that slice the data for this chunk from the big data array - data_slice = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) + slicer = (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 + return texture, chunk_index, slicer def __getitem__(self, item): + if not self.cpu_buffer: + return None + return self.value[item] @block_reentrance def __setitem__(self, key, value): + if not self.cpu_buffer: + raise BufferError( + f"setting slices or specific elements of texture data is only supported when `cpu_buffer=True`." + f"'unbuffered' textures only support setting the full data entirely, " + f"i.e. you must do: graphic.data = new_arr, you cannot do: graphic.data[indices] = new_arr, unless " + f"`cpu_buffer=True`" + ) + self.value[key] = value for texture in self.buffer.ravel(): @@ -163,6 +275,152 @@ def __len__(self): return self.buffer.size +class TextureYUV(GraphicFeature): + """ + Manages a YUV texture, no chunking, no local buffer + """ + + 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: TupleYUV, + property_name: str = "data", + colorspace: ColorspacesYUV = ColorspacesYUV.yuv420p, + colorrrange: ColorRange = ColorRange.limited, + ): + super().__init__(property_name=property_name) + + self._colorspace = ColorspacesYUV(colorspace) + self._colorrange = ColorRange(colorrrange) + + self._check_data(data) + + self._data = data + + shared = pygfx.renderers.wgpu.get_shared() + limit = shared.device.limits["max-texture-dimension-2d"] + if data[0].shape[0] > limit or data[0].shape[1] > limit: + raise ValueError( + f"YUV colorspaces Images currently don't support dimensions that exceed the device's " + f"max-texture-dimension-2d. For now you must manually tile individual Images to use a YUV colorspace." + ) + + self._allocate_texture(data) + self._send_data(data) + + @property + def cpu_buffer(self) -> Literal[False]: + return False + + @property + def texture(self) -> pygfx.Texture: + return self._texture + + @property + def colorspace(self) -> ColorspacesYUV: + return self._colorspace + + @property + def colorrange(self) -> ColorRange: + return self._colorrange + + def _allocate_texture(self, data: TupleYUV): + """Create a new pygfx.Texture""" + + self._h, self._w = data[0].shape + if self.colorspace == ColorspacesYUV.yuv420p: + depth = 2 + else: + depth = 3 + + self._texture = pygfx.Texture( + size=(self._w, self._h, depth), + dim=2, + colorspace=self.colorspace.value, + colorrange=self.colorrange.value, + format="r8unorm", + usage=wgpu.TextureUsage.COPY_DST, + ) + + def _send_data(self, data): + """send the data to the GPU""" + y, u, v = data + + self._texture.send_data((0, 0, 0), y) + + if self.colorspace == ColorspacesYUV.yuv420p: + self._texture.send_data((0, 0, 1), u) + self._texture.send_data((self._w // 2, 0, 1), v) + else: + self._texture.send_data((0, 0, 1), u) + self._texture.send_data((0, 0, 2), v) + + @property + def value(self) -> None: + """this is bufferless""" + return None + + def set_value(self, graphic, value: TupleYUV): + self._check_data(value) + + y, u, v = value + + if y.shape[0] != self._h or y.shape[1] != self._w: + self._allocate_texture(value) + graphic.geometry.grid = self._texture + + self._send_data(value) + + def _check_data(self, data: TupleYUV): + err = f"must provide a tuple/list of np.ndarray of type np.uint8 representing YUV components." + + if not isinstance(data, (tuple, list)): + raise TypeError(err + f"\nYou provided: {data}") + + if not len(data) == 3: + raise TypeError(err + f"\nYou provided data of len: {len(data)}") + + if not all([isinstance(a, np.ndarray) for a in data]): + raise TypeError(err + f"\nYou provided types: {[type(d) for d in data]}") + + types = [a.dtype for a in data] + if not all([t == np.uint8 for t in types]): + raise TypeError(err + f"\nYou provided data of types: {types}") + + if self.colorspace == ColorspacesYUV.yuv420p: + err += ( + f"For {self.colorspace} UV channels must be 4x smaller than Y. " + f"You provided shapes: {tuple(d.shape for d in data)}" + ) + shapes = tuple(np.asarray(d.shape) for d in data) + expected_uv_shape = shapes[0] // 2 + if (shapes[1] != expected_uv_shape).all() or ( + shapes[2] != expected_uv_shape + ).all(): + raise ValueError(err) + + else: + err += ( + f"For {self.colorspace} UV channels must be the same size as Y" + f"You provided shapes: {tuple(d.shape for d in data)}" + ) + + if data[0].shape != data[1].shape or data[0].shape != data[2].shape: + raise ValueError(err) + + class ImageVmin(GraphicFeature): """lower contrast limit""" @@ -221,6 +479,34 @@ def set_value(self, graphic, value: float): self._call_event_handlers(event) +class ImageGamma(GraphicFeature): + """gamma correction applied to the image""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new gamma value", + }, + ] + + def __init__(self, value: float, property_name: str = "gamma"): + 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): + graphic._material.gamma = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + class ImageCmap(GraphicFeature): """colormap for texture""" @@ -243,8 +529,8 @@ def value(self) -> str: @block_reentrance def set_value(self, graphic, value: str): - new_colors = make_colors(256, value) - graphic._material.map.texture.data[:] = new_colors + colormap = pygfx.cm.create_colormap(cmap_lib.Colormap(value).lut()) + graphic._material.map = colormap graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) self._value = value diff --git a/fastplotlib/graphics/features/_line.py b/fastplotlib/graphics/features/_line.py index 792cb7832..a29e0ec97 100644 --- a/fastplotlib/graphics/features/_line.py +++ b/fastplotlib/graphics/features/_line.py @@ -5,6 +5,38 @@ ) +# matplotlib-style dash pattern presets, expressed in units relative to the line thickness +DASH_PATTERNS: dict[str, tuple] = { + "-": (), + "solid": (), + "--": (5, 5), + "dashed": (5, 5), + "-.": (5, 2, 1, 2), + "dashdot": (5, 2, 1, 2), + ":": (0, 2), + "dotted": (0, 2), +} + + +def parse_dash_pattern(value: str | tuple | list) -> tuple: + """ + Parse a ``dash_pattern`` into a pygfx dash tuple. + + ``value`` can be a matplotlib-style string, one of + ``"-", "--", "-.", ":"`` or ``"solid", "dashed", "dashdot", "dotted"``, or a + sequence of floats describing the length of strokes and gaps. + """ + if isinstance(value, str): + if value not in DASH_PATTERNS: + raise ValueError( + f"`dash_pattern` string must be one of {sorted(DASH_PATTERNS.keys())}, " + f"you have passed: {value!r}" + ) + return DASH_PATTERNS[value] + + return tuple(value) + + class Thickness(GraphicFeature): event_info_spec = [ {"dict key": "value", "type": "float", "description": "new thickness value"}, @@ -26,3 +58,31 @@ def set_value(self, graphic, value: float): event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) + + +class DashPattern(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | tuple", + "description": "new dash pattern", + }, + ] + + def __init__(self, value: str | tuple | list = (), property_name: str = "dash_pattern"): + # parse to validate, but store the user's original value so it stays readable + parse_dash_pattern(value) + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> str | tuple: + return self._value + + @block_reentrance + def set_value(self, graphic, value: str | tuple | list): + graphic.world_object.material.dash_pattern = parse_dash_pattern(value) + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py index 7355acb4e..776d77ce4 100644 --- a/fastplotlib/graphics/features/_mesh.py +++ b/fastplotlib/graphics/features/_mesh.py @@ -51,18 +51,14 @@ class MeshIndices(VertexPositions): }, ] - def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "indices" - ): + def __init__(self, data: Any, property_name: str = "indices"): """ Manages the vertex indices 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, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim != 2 or data.shape[1] not in (3, 4): diff --git a/fastplotlib/graphics/features/_positions.py b/fastplotlib/graphics/features/_positions.py index 295d22417..2ede10b8b 100644 --- a/fastplotlib/graphics/features/_positions.py +++ b/fastplotlib/graphics/features/_positions.py @@ -13,7 +13,7 @@ to_gpu_supported_dtype, block_reentrance, ) -from .utils import parse_colors +from .utils import parse_colors, is_single_color class VertexColors(BufferManager): @@ -39,7 +39,6 @@ def __init__( self, colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], n_colors: int, - isolated_buffer: bool = True, property_name: str = "colors", ): """ @@ -57,9 +56,44 @@ def __init__( """ data = parse_colors(colors, n_colors) - super().__init__( - data=data, isolated_buffer=isolated_buffer, property_name=property_name - ) + super().__init__(data=data, property_name=property_name) + + def set_value( + self, + graphic, + value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], + ): + """set the entire array, create new buffer if necessary""" + # a sequence of colors whose length differs from the current buffer requires a new buffer + if ( + isinstance(value, (np.ndarray, list, tuple)) + and not is_single_color(value) + and self.buffer.data.shape[0] != len(value) + ): + # parse the new colors + new_colors = parse_colors(value, len(value)) + + # create the new buffer, old buffer should get dereferenced + # make sure new buffer is isolated (i.e. allocate a buffer, then set the values) + buff = np.empty(new_colors.shape, dtype=np.float32) + buff[:] = new_colors + self._fpl_buffer = pygfx.Buffer(buff) + graphic.world_object.geometry.colors = self._fpl_buffer + + if len(self._event_handlers) < 1: + return + + event_info = { + "key": slice(None), + "value": new_colors, + "user_value": value, + } + + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) + return + + self[:] = value @block_reentrance def __setitem__( @@ -231,18 +265,14 @@ class VertexPositions(BufferManager): }, ] - def __init__( - self, data: Any, isolated_buffer: bool = True, property_name: str = "data" - ): + def __init__(self, data: Any, 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, property_name=property_name - ) + super().__init__(data, property_name=property_name) def _fix_data(self, data): if data.ndim == 1: @@ -261,13 +291,42 @@ def _fix_data(self, data): return to_gpu_supported_dtype(data) + def set_value(self, graphic, value): + """Sets the entire array, creates new buffer if necessary""" + if isinstance(value, np.ndarray): + if self.buffer.data.shape[0] != value.shape[0]: + # number of items doesn't match, create a new buffer + + # if data is not 3D + if value.ndim == 1: + # _fix_data creates a new array so we don't need to re-allocate with np.zeros + bdata = self._fix_data(value) + + elif value.shape[1] == 2: + # _fix_data creates a new array so we don't need to re-allocate with np.zeros + bdata = self._fix_data(value) + + elif value.shape[1] == 3: + # need to allocate a buffer to use here + bdata = np.empty(value.shape, dtype=np.float32) + bdata[:] = value[:] + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(bdata) + graphic.world_object.geometry.positions = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], value: np.ndarray | float | list[float], ): - # directly use the key to slice the buffer + # directly use the key to slice the buffer and set the values self.buffer.data[key] = value # _update_range handles parsing the key to @@ -306,7 +365,7 @@ def __init__( provides a way to set colormaps with arbitrary transforms """ - super().__init__(data=vertex_colors.buffer, property_name=property_name) + super().__init__(data=None, property_name=property_name) self._vertex_colors = vertex_colors self._cmap_name = cmap_name @@ -331,6 +390,16 @@ def __init__( # set vertex colors from cmap self._vertex_colors[:] = colors + @property + def buffer(self) -> pygfx.Buffer: + return self._vertex_colors.buffer + + @property + def value(self) -> np.ndarray: + # mirror the managed colors feature, whose length is the number of color entries + # (this is per-line, not per-vertex, for an InfLineColors) + return self._vertex_colors.value + @block_reentrance def __setitem__(self, key: slice, cmap_name): if not isinstance(key, slice): @@ -402,3 +471,209 @@ def __len__(self): def __repr__(self): return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}" + + +class InfLineAxisData(VertexPositions): + """ + Manages the positions buffer for :class:`InfLineGraphic`. + + Each infinite line is stored as a two-point segment, so the buffer has two vertices per + line. When ``axis`` is one of ``"x", "y", "z"`` the data is a 1D array of positions along + that axis and one infinite line is drawn at each position. When ``axis`` is ``None`` the + data is used directly as the segment endpoints (2 points per line). + + Indexing and ``value`` operate per-line: ``value`` is a 1D array of ``n_lines`` axis + positions, or an ``[n_lines, 2, 3]`` array of segment endpoints when ``axis`` is ``None``. + """ + + _AXIS_INDICES = {"x": 0, "y": 1, "z": 2} + + def __init__(self, data: Any, axis: str | None = None, property_name: str = "data"): + if axis is not None and axis not in self._AXIS_INDICES: + raise ValueError( + f"`axis` must be one of 'x', 'y', 'z', or None, you have passed: {axis!r}" + ) + self._axis = axis + super().__init__(data, property_name=property_name) + + @property + def axis(self) -> str | None: + return self._axis + + def _fix_data(self, data): + data = np.asarray(data) + + if self._axis is None: + # data is used directly as the segment endpoints, 2 points per line; + # accept the grouped [n_lines, 2, 3] form as well as a flat [n_points, 3] buffer + if data.ndim == 3: + data = data.reshape(-1, data.shape[-1]) + data = super()._fix_data(data) + if data.shape[0] % 2 != 0: + raise ValueError( + "when `axis` is None, `data` is used directly as the infinite line segment " + "endpoints and must contain an even number of points (2 per line)" + ) + return data + + # axis is 'x', 'y', or 'z': `data` is a 1D array of positions along that axis + if data.ndim != 1: + raise ValueError( + f"when `axis` is '{self._axis}', `data` must be a 1D array of positions along that " + f"axis, you have passed an array with {data.ndim} dimensions" + ) + + axis_index = self._AXIS_INDICES[self._axis] + # the two points of a line share the axis position; they differ along another axis + # so the segment has a direction along which it is extended to infinity + run_index = 1 if axis_index == 0 else 0 + + buffer = np.zeros((2 * data.size, 3), dtype=np.float32) + buffer[:, axis_index] = np.repeat(data, 2) + buffer[1::2, run_index] = 1.0 + + return buffer + + def __len__(self) -> int: + return len(self.buffer.data) // 2 + + @property + def value(self) -> np.ndarray: + if self._axis is None: + # one [2, 3] pair of endpoints per line + return self.buffer.data.reshape(len(self), 2, 3) + # both endpoints of a line share the axis position, return one value per line + return self.buffer.data[::2, self._AXIS_INDICES[self._axis]] + + def __getitem__(self, key): + return self.value[key] + + def set_value(self, graphic, value): + """set the line positions, allocating a new buffer if the number of lines changed""" + value = np.asarray(value) + + if self._axis is None: + fixed = self._fix_data(value) + if fixed.shape[0] != len(self.buffer.data): + # number of lines changed, allocate a new buffer + self._fpl_buffer = pygfx.Buffer(fixed) + graphic.world_object.geometry.positions = self._fpl_buffer + # emit the [n_lines, 2, 3] form to match `value` and the in-place path + self._emit_event( + self._property_name, slice(None), fixed.reshape(-1, 2, 3) + ) + return + self[:] = fixed.reshape(len(self), 2, 3) + return + + if value.ndim != 1: + raise ValueError( + f"when `axis` is '{self._axis}', data must be set with a 1D array of axis positions" + ) + if value.size != len(self): + # number of lines changed, allocate a new buffer + self._fpl_buffer = pygfx.Buffer(self._fix_data(value)) + graphic.world_object.geometry.positions = self._fpl_buffer + self._emit_event(self._property_name, slice(None), value) + return + + self[:] = value + + @block_reentrance + def __setitem__(self, key, value): + # for axis=None, `value` is [n_lines, 2, 3] so the line index is the first + # element of a multi-dimensional endpoint/coordinate key + line_key = key[0] if (self._axis is None and isinstance(key, tuple)) else key + line_indices = np.atleast_1d(np.arange(len(self))[line_key]) + if line_indices.size == 0: + return + + if self._axis is None: + self.buffer.data.reshape(len(self), 2, 3)[key] = value + else: + axis_index = self._AXIS_INDICES[self._axis] + # write the axis position to both endpoints of each line + self.buffer.data[2 * line_indices, axis_index] = value + self.buffer.data[2 * line_indices + 1, axis_index] = value + + offset = 2 * int(line_indices.min()) + size = 2 * (int(line_indices.max()) - int(line_indices.min()) + 1) + self.buffer.update_range(offset=offset, size=size) + + self._emit_event(self._property_name, key, value) + + +class InfLineColors(VertexColors): + """ + Manages per-line colors for :class:`InfLineGraphic`. + + One color is stored per infinite line; internally each color is written to both + endpoints of the line's segment so that the segment renders as a single solid color. + """ + + def __init__(self, colors, n_colors: int, property_name: str = "colors"): + # n_colors is the number of infinite lines; each line spans two vertices + data = np.repeat(parse_colors(colors, n_colors), 2, axis=0) + # bypass VertexColors.__init__, which would parse the (already parsed) colors again + BufferManager.__init__(self, data=data, property_name=property_name) + + @property + def value(self) -> np.ndarray: + # both vertices of a line share its color, return one color per line + return self.buffer.data[::2] + + def __getitem__(self, key): + return self.value[key] + + def __len__(self) -> int: + return len(self.buffer.data) // 2 + + def set_value(self, graphic, value): + """set the per-line colors, allocating a new buffer if the number of lines changed""" + if not is_single_color(value) and len(value) != len(self): + data = np.repeat(parse_colors(value, len(value)), 2, axis=0) + buff = np.empty(data.shape, dtype=np.float32) + buff[:] = data + self._fpl_buffer = pygfx.Buffer(buff) + graphic.world_object.geometry.colors = self._fpl_buffer + + if len(self._event_handlers) < 1: + return + + event_info = {"key": slice(None), "value": data, "user_value": value} + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) + return + + self[:] = value + + @block_reentrance + def __setitem__(self, key, value): + # the line index is the first element of a multi-dimensional (per-channel) key + line_key = key[0] if isinstance(key, tuple) else key + line_indices = np.atleast_1d(np.arange(len(self))[line_key]) + if line_indices.size == 0: + return + + if isinstance(key, tuple): + # channel-level write, e.g. colors[i, :3]; set the value directly, no color parsing + colors = value + rest = key[1:] + self.buffer.data[(2 * line_indices, *rest)] = value + self.buffer.data[(2 * line_indices + 1, *rest)] = value + else: + # one color per selected line, written to both of the line's vertices + colors = parse_colors(value, line_indices.size) + self.buffer.data[2 * line_indices] = colors + self.buffer.data[2 * line_indices + 1] = colors + + offset = 2 * int(line_indices.min()) + size = 2 * (int(line_indices.max()) - int(line_indices.min()) + 1) + self.buffer.update_range(offset=offset, size=size) + + if len(self._event_handlers) < 1: + return + + event_info = {"key": key, "value": colors, "user_value": value} + event = GraphicFeatureEvent(self._property_name, info=event_info) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py index 16671ef89..e41115ae3 100644 --- a/fastplotlib/graphics/features/_scatter.py +++ b/fastplotlib/graphics/features/_scatter.py @@ -100,6 +100,37 @@ def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]): return marker_int_searchsorted_vals[indices] +def parse_markers(markers: str | Sequence[str] | np.ndarray, n_datapoints: int): + # 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))) + + markers_readable_array = np.empty(n_datapoints, dtype=f" np.ndarray[str]: @@ -200,6 +200,25 @@ def _set_markers_arrays(self, key, value, n_markers): "new markers value must be a str, Sequence or np.ndarray of new marker values" ) + def set_value(self, graphic, value): + """set all the markers, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != len(value): + # need to create a new buffer + markers_int_array, self._markers_readable_array = parse_markers( + value, len(value) + ) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(markers_int_array) + graphic.world_object.geometry.markers = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + + return + + self[:] = value + @block_reentrance def __setitem__( self, @@ -414,18 +433,15 @@ 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 - ) + sizes = self._fix_rotations(rotations, n_datapoints) + super().__init__(data=sizes, property_name=property_name) - def _fix_sizes( + def _fix_rotations( self, sizes: int | float | np.ndarray | Sequence[int | float], n_datapoints: int, @@ -454,6 +470,22 @@ def _fix_sizes( return sizes + def set_value(self, graphic, value): + """set all rotations, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != value.shape[0]: + # need to create a new buffer + value = self._fix_rotations(value, len(value)) + data = np.empty(shape=(len(value),), dtype=np.float32) + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(data) + graphic.world_object.geometry.rotations = self._fpl_buffer + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, @@ -488,16 +520,13 @@ 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 - ) + super().__init__(data=sizes, property_name=property_name) def _fix_sizes( self, @@ -533,6 +562,24 @@ def _fix_sizes( return sizes + def set_value(self, graphic, value): + """set all sizes, create new buffer if necessary""" + if isinstance(value, (np.ndarray, list, tuple)): + if self.buffer.data.shape[0] != len(value): + # create new buffer + value = self._fix_sizes(value, len(value)) + data = np.empty(shape=(len(value),), dtype=np.float32) + data[:] = value + + # create the new buffer, old buffer should get dereferenced + self._fpl_buffer = pygfx.Buffer(data) + graphic.world_object.geometry.sizes = self._fpl_buffer + + self._emit_event(self._property_name, key=slice(None), value=value) + return + + self[:] = value + @block_reentrance def __setitem__( self, diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 9b30dd70c..1f049f0cb 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -118,7 +118,7 @@ def axis(self) -> str: return self._axis @block_reentrance - def set_value(self, selector, value: Sequence[float]): + def set_value(self, selector, value: Sequence[float], *, change: str = "full"): """ Set start, stop range of selector @@ -182,7 +182,9 @@ def set_value(self, selector, value: Sequence[float]): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent(self._property_name, {"value": self.value}) + event = GraphicFeatureEvent( + self._property_name, {"value": self.value, "change": change} + ) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py index 9c86d25fc..82767ca21 100644 --- a/fastplotlib/graphics/features/_vectors.py +++ b/fastplotlib/graphics/features/_vectors.py @@ -22,7 +22,6 @@ class VectorPositions(GraphicFeature): def __init__( self, positions: np.ndarray, - isolated_buffer: bool = True, property_name: str = "positions", ): """ @@ -83,11 +82,8 @@ def set_value(self, graphic, value: np.ndarray): 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] - ) + # Only need to update the translation vector + graphic.world_object.instance_buffer.data["matrix"][:, 3, 0:3] = self._positions[:] graphic.world_object.instance_buffer.update_full() @@ -111,7 +107,6 @@ class VectorDirections(GraphicFeature): 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""" @@ -173,15 +168,162 @@ def set_value(self, graphic, value: np.ndarray): # 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 + rotation = quat_from_vecs(self.init_direction, self._directions[:]) + # get the new transform + transform = mat_compose(graphic.positions[:], rotation, magnitudes[:]) + # set the buffer + graphic.world_object.instance_buffer.data["matrix"][:] = transform.transpose(0, 2, 1) graphic.world_object.instance_buffer.update_full() event = GraphicFeatureEvent(type="directions", info={"value": value}) self._call_event_handlers(event) + + + +def quat_from_vecs(source, target, out=None, dtype=None) -> np.ndarray: + source = np.asarray(source, dtype=float) + if source.ndim == 1: + source = source[None, :] + target = np.asarray(target, dtype=float) + if target.ndim == 1: + target = target[None, :] + + num_vecs = target.shape[0] + result_shape = (num_vecs, 4) + if out is None: + out = np.empty(result_shape, dtype=dtype) + + axis = np.cross(source, target) # (num_pts, 3) + axis_norm = np.linalg.norm(axis, axis=-1) # (num_pts,) + angle = np.arctan2(axis_norm, (target @ source.T).squeeze(1)) # (num_pts,) + + # Handle degenerate case: source and target are parallel (axis is zero vector). + # Pick any axis orthogonal to source as a replacement. + use_fallback = axis_norm == 0 + if np.any(use_fallback): + t = np.broadcast_to(source, (num_vecs, 3))[use_fallback] + + # Better case split: + y_zero = t[:, 1] == 0 + z_zero = t[:, 2] == 0 + neither_zero = ~y_zero & ~z_zero + + fb = np.empty((y_zero.shape[0], 3), dtype=float) + fb[y_zero] = (0., 1., 0.) + fb[~y_zero & z_zero] = (0., 0., 1.) + fb[neither_zero, 0] = 0. + fb[neither_zero, 1] = -t[neither_zero, 2] + fb[neither_zero, 2] = t[neither_zero, 1] + + axis[use_fallback] = fb + + return quat_from_axis_angle(axis, angle, out=out) + + +def quat_from_axis_angle(axis, angle, out=None, dtype=None) -> np.ndarray: + """Quaternion from axis-angle pair. + + Create a quaternion representing the rotation of an given angle + about a given unit vector + + Parameters + ---------- + axis : ndarray, [num_vectors, 3] or [3] + Unit vector + angle : number or np.ndarray of shape [num_pts,] + The angle (in radians) to rotate about axis + out : ndarray, optional + A location into which the result is stored. If provided, it + must have a shape that the inputs broadcast to. If not provided or + None, a freshly-allocated array is returned. A tuple must have + length equal to the number of outputs. + dtype : data-type, optional + Overrides the data type of the result. + + Returns + ------- + ndarray, [num_pts, 4] or [4] + Quaternion. + """ + + axis = np.asarray(axis, dtype=float) + angle = np.asarray(angle, dtype=float) + + if out is None: + out_shape = np.broadcast_shapes(axis.shape[:-1], angle.shape) + out = np.empty((*out_shape, 4), dtype=dtype) + + # result should be independent of the length of the given axis + lengths_shape = (*axis.shape[:-1], 1) + axis = axis / np.linalg.norm(axis, axis=-1).reshape(lengths_shape) + + out[..., :3] = axis * np.sin(angle / 2).reshape(lengths_shape) + out[..., 3] = np.cos(angle / 2) + + return out.squeeze(0) if out.shape[0] == 1 else out + + +def mat_compose(translation, rotation, scaling, /, *, out=None, dtype=None) -> np.ndarray: + """ + Compose transformation matrices given translation vectors, quaternions, + and scaling vectors. + + Parameters + ---------- + translation : ndarray, [3] or [num_vectors, 3] + rotation : ndarray, [4] or [num_vectors, 4] + scaling : ndarray, [3] or [num_vectors, 3] + + Returns + ------- + ndarray, [num_vectors, 4, 4] or [4, 4] + """ + rotation = np.asarray(rotation, dtype=float) + translation = np.asarray(translation, dtype=float) + scaling = np.asarray(scaling, dtype=float) + + if rotation.ndim == 1: + rotation = rotation[None, :] + if translation.ndim == 1: + translation = translation[None, :] + if scaling.ndim == 0: + scaling = np.full((1, 3), scaling) + elif scaling.ndim == 1 and scaling.shape[0] == 3: + scaling = scaling[None, :] + elif scaling.ndim == 1: + scaling = scaling[:, None] * np.ones(3) + + num_vectors = max(rotation.shape[0], translation.shape[0], scaling.shape[0]) + + if out is None: + out = np.zeros((num_vectors, 4, 4), dtype=dtype) + else: + out[..., :, :] = 0 + + x, y, z, w = rotation[:, 0], rotation[:, 1], rotation[:, 2], rotation[:, 3] + + x2, y2, z2 = x + x, y + y, z + z + xx, xy, xz = x * x2, x * y2, x * z2 + yy, yz, zz = y * y2, y * z2, z * z2 + wx, wy, wz = w * x2, w * y2, w * z2 + + sx, sy, sz = scaling[:, 0], scaling[:, 1], scaling[:, 2] + + + out[:, 0, 0] = (1 - (yy + zz)) * sx + out[:, 1, 0] = (xy + wz) * sx + out[:, 2, 0] = (xz - wy) * sx + + out[:, 0, 1] = (xy - wz) * sy + out[:, 1, 1] = (1 - (xx + zz)) * sy + out[:, 2, 1] = (yz + wx) * sy + + out[:, 0, 2] = (xz + wy) * sz + out[:, 1, 2] = (yz - wx) * sz + out[:, 2, 2] = (1 - (xx + yy)) * sz + + out[:, 0:3, 3] = translation + out[:, 3, 3] = 1 + + return out.squeeze(0) if out.shape[0] == 1 else out \ No newline at end of file diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py index ec4c4052a..532065fb7 100644 --- a/fastplotlib/graphics/features/_volume.py +++ b/fastplotlib/graphics/features/_volume.py @@ -34,7 +34,7 @@ class TextureArrayVolume(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True): + def __init__(self, data): super().__init__(property_name="data") data = self._fix_data(data) @@ -43,13 +43,9 @@ def __init__(self, data, isolated_buffer: bool = True): 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 + # create a new buffer that will be used for the texture data + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] # data start indices for each Texture self._row_indices = np.arange( diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index aa4022052..59c62f354 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -5,6 +5,22 @@ from ...utils import make_pygfx_colors +def is_single_color(value) -> bool: + """ + Whether ``value`` represents a single RGB(A) color rather than a sequence of colors. + + A single color is a str, ``pygfx.Color``, or an RGB(A) array/list/tuple of 3-4 numbers. + """ + if isinstance(value, np.ndarray): + return value.shape in ((3,), (4,)) and value.dtype.kind in "fiu" + + if isinstance(value, (list, tuple)): + return len(value) in (3, 4) and all(isinstance(v, (float, int)) for v in value) + + # str, pygfx.Color, or any other scalar color specifier + return True + + def parse_colors( colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None ): @@ -77,3 +93,26 @@ def parse_colors( data = make_pygfx_colors(colors, n_colors) return to_gpu_supported_dtype(data) + + +def get_element_format_from_numpy_array(array): + """Get the per-element format specifier from a numpy array. + Returns None if the format appears to be a structured array. + Raises an error if GPU-incompatible dtypes are used (64 bit). + """ + + # Uniform buffers are scalars with a structured dtype. + # But can also create storage buffers with complex formats. + if array.dtype.kind not in "iuf": + return None + + # GPUs generally don't support 64-bit buffers or textures. + # Note: the Python docs say that l and L are 32 bit, but converting + # a int64 numpy array to a memoryview gives a format of 'l' instead + # of 'q' on some systems/configs? So we need to check the itemsize. + if array.itemsize == 8: + raise ValueError( + f"A dtype of {array.dtype.name} is not supported for buffers, use a 32-bit variant instead." + ) + + return array.dtype.str.lstrip("<>=|") \ No newline at end of file diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 44bffcedc..908f92347 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,9 +1,12 @@ import math from typing import * +import numpy as np import pygfx +from pygfx import Texture -from ..utils import quick_min_max +from .shaders import HighlightableImageMaterial +from ..utils import quick_min_max, ColorspacesRGB, ColorspacesYUV, ColorRange from ._base import Graphic from .selectors import ( LinearSelector, @@ -13,7 +16,10 @@ ) from .features import ( TextureArray, + TextureYUV, + TupleYUV, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -44,11 +50,23 @@ def __init__( chunk_index: tuple[int, int], **kwargs, ): + self._vis_scale = None # (axis_index, scale) set by ImageVisibilitySelector super().__init__(geometry, material, **kwargs) self._data_slice = data_slice self._chunk_index = chunk_index + def get_bounding_box(self): + aabb = super().get_bounding_box() + if aabb is None or self._vis_scale is None: + return aabb + ax_i, scale = self._vis_scale + if scale == 0.0: + return None + aabb = aabb.copy() + aabb[1, ax_i] = aabb[0, ax_i] + (aabb[1, ax_i] - aabb[0, ax_i]) * scale + return aabb + def _wgpu_get_pick_info(self, pick_value): pick_info = super()._wgpu_get_pick_info(pick_value) @@ -84,161 +102,11 @@ def chunk_index(self) -> tuple[int, int]: return self._chunk_index -class ImageGraphic(Graphic): - _features = { - "data": TextureArray, - "cmap": ImageCmap, - "vmin": ImageVmin, - "vmax": ImageVmax, - "interpolation": ImageInterpolation, - "cmap_interpolation": ImageCmapInterpolation, - } - - def __init__( - self, - data: Any, - vmin: float = None, - vmax: float = None, - cmap: str = "plasma", - interpolation: str = "nearest", - cmap_interpolation: str = "linear", - isolated_buffer: bool = True, - **kwargs, - ): - """ - Create an Image Graphic - - Parameters - ---------- - data: array-like - 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: float, optional - minimum value for color scaling, estimated 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 - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ - - interpolation: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" - - cmap_interpolation: str, optional, default "linear" - colormap interpolation method, one of "nearest" or "linear" - - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. - - kwargs: - additional keyword arguments passed to :class:`.Graphic` - - """ - - super().__init__(**kwargs) - - world_object = pygfx.Group() - - 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(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) - - # set map to None for RGB images - if self._data.value.ndim > 2: - self._cmap = None - _map = None - else: - # use TextureMap for grayscale images - self._cmap = ImageCmap(cmap) - self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - - _map = pygfx.TextureMap( - self._cmap.texture, - filter=self._cmap_interpolation.value, - wrap="clamp-to-edge", - ) - - # one common material is used for every Texture chunk - self._material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), - map=_map, - interpolation=self._interpolation.value, - pick_write=True, - ) - - # iterate through each texture chunk and create - # an _ImageTile, offset the tile using the data indices - for texture, chunk_index, data_slice in self._data: - # create an ImageTile using the texture for this chunk - img = _ImageTile( - geometry=pygfx.Geometry(grid=texture), - material=self._material, - data_slice=data_slice, # used to parse pick_info - chunk_index=chunk_index, - ) - - # row and column start index for this chunk - data_row_start = data_slice[0].start - data_col_start = data_slice[1].start - - # offset tile position using the indices from the big data array - # that correspond to this chunk - img.world.x = data_col_start - img.world.y = data_row_start - - world_object.add(img) - - self._set_world_object(world_object) - +class ImageBase(Graphic): @property - def data(self) -> TextureArray: - """Get or set the image data""" - return self._data - - @data.setter - def data(self, data): - self._data[:] = data - - @property - def cmap(self) -> str | None: - """ - 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._cmap is not None: - return self._cmap.value - - return None - - @cmap.setter - def cmap(self, name: str): - if self.data.value.ndim > 2: - raise AttributeError("RGB(A) images do not have a colormap property") - self._cmap.set_value(self, name) + def cpu_buffer(self) -> bool: + """whether or not a cpu buffer is used for the image data. If ``False``, then the data only exist on the GPU""" + return self.data.cpu_buffer @property def vmin(self) -> float: @@ -258,6 +126,15 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) + @property + def gamma(self) -> float: + """gamma correction applied to the image""" + return self._gamma.value + + @gamma.setter + def gamma(self, value: float): + self._gamma.set_value(self, value) + @property def interpolation(self) -> str: """Data interpolation method""" @@ -267,24 +144,6 @@ def interpolation(self) -> str: def interpolation(self, value: str): self._interpolation.set_value(self, value) - @property - def cmap_interpolation(self) -> str: - """cmap interpolation method""" - return self._cmap_interpolation.value - - @cmap_interpolation.setter - def cmap_interpolation(self, value: str): - self._cmap_interpolation.set_value(self, value) - - def reset_vmin_vmax(self): - """ - Reset the vmin, vmax by estimating it from the data by subsampling. - """ - - vmin, vmax = quick_min_max(self._data.value) - self.vmin = vmin - self.vmax = vmax - def add_linear_selector( self, selection: int = None, axis: str = "x", **kwargs ) -> LinearSelector: @@ -488,6 +347,24 @@ def add_polygon_selector( return selector def format_pick_info(self, pick_info: dict) -> str: + if not self.cpu_buffer: + if self.colorspace not in ColorspacesYUV and len(self.data.shape) == 2: + # inverse map from rgb pixel value to grayscale value using the colormap + # we can only perform a guess + lut = self._material.map.texture.data + rgb = pick_info["rgba"][:3] + closest = np.argmin(np.linalg.norm(lut[:, :3] - rgb, axis=1)) + scalar = closest / (lut.shape[0] - 1) + val = self.vmin + scalar * (self.vmax - self.vmin) + return f"{val:.4g}\n!!estimate!!, cpu_buffer=False" + else: + # direct rgba vals + rgba_val = pick_info["rgba"] + info = "\n".join( + f"{channel}: {val: .4g}" for channel, val in zip("rgba", rgba_val) + ) + return info + col, row = pick_info["index"] if self.data.value.ndim == 2: val = self.data[row, col] @@ -499,3 +376,432 @@ def format_pick_info(self, pick_info: dict) -> str: ) return info + + +class ImageGraphic(ImageBase): + _features = { + "data": TextureArray, + "cmap": ImageCmap, + "gamma": ImageGamma, + "vmin": ImageVmin, + "vmax": ImageVmax, + "interpolation": ImageInterpolation, + "cmap_interpolation": ImageCmapInterpolation, + } + + def __init__( + self, + data: Any, + vmin: float = None, + vmax: float = None, + cmap: str = "plasma", + gamma: float = 1.0, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + colorspace: ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + **kwargs, + ): + """ + Create an ImageGraphic + + Parameters + ---------- + data: array-like + 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: float, optional + minimum value for color scaling, estimated 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 + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + """ + + super().__init__(**kwargs) + + group = pygfx.Group() + + 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, colorspace=colorspace, cpu_buffer=cpu_buffer + ) + + if (vmin is None) or (vmax is None): + if self.data.value is None: + raise ValueError( + "must provide vmin, vmax if sharing a buffer that does not exist locally" + ) + + _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._gamma = ImageGamma(gamma) + + self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) + + # set map to None for RGB images + if len(self.data.shape) == 3: + self._cmap = None + _map = None + + else: + # use TextureMap for grayscale images + self._cmap = ImageCmap(cmap) + + _map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + + # one common material is used for every Texture chunk + self._material = HighlightableImageMaterial( + clim=(vmin, vmax), + map=_map, + interpolation=self._interpolation.value, + pick_write=True, + ) + self._material.gamma = gamma + + # create the _ImageTile world objects, add to group + for tile in self._create_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_tiles(self) -> list[_ImageTile]: + tiles = list() + # iterate through each texture chunk and create + # an _ImageTile, offset the tile using the data indices + for texture, chunk_index, data_slice in self._data: + # create an ImageTile using the texture for this chunk + img = _ImageTile( + geometry=pygfx.Geometry(grid=texture), + material=self._material, + data_slice=data_slice, # used to parse pick_info + chunk_index=chunk_index, + ) + + # row and column start index for this chunk + data_row_start = data_slice[0].start + data_col_start = data_slice[1].start + + # offset tile position using the indices from the big data array + # that correspond to this chunk + img.world.x = data_col_start + img.world.y = data_row_start + + tiles.append(img) + + return tiles + + @property + def data(self) -> TextureArray: + """ + Get or set the image data. + + Note that if the shape of the new data array does not equal the shape of + current data array, a new set of GPU Textures are automatically created. + This can have performance drawbacks when you have a ver large images. + This is usually fine as long as you don't need to do it hundreds of times + per second. + """ + return self._data + + @data.setter + def data(self, data): + if isinstance(data, np.ndarray): + # check if a new buffer is required + if self._data.value.shape != data.shape: + # create new TextureArray + self._data = TextureArray(data) + + # cmap based on if rgb or grayscale + if self._data.value.ndim > 2: + self._cmap = None + + # must be None if RGB(A) + self._material.map = None + else: + if self.cmap is None: # have switched from RGBA -> grayscale image + # create default cmap + self._cmap = ImageCmap("plasma") + self._material.map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + + # remove tiles from the WorldObject -> Graphic map + self._remove_group_graphic_map(self.world_object) + + # clear image tiles + self.world_object.clear() + + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) + + # add new tiles to WorldObject -> Graphic map + self._add_group_graphic_map(self.world_object) + + return + + self._data[:] = data + + + @property + def colorspace(self) -> ColorspacesRGB: + """The image's colorspace""" + return self.data.colorspace + + @property + def cmap(self) -> str | None: + """ + 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._cmap is not None: + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + if self.data.value.ndim > 2: + raise AttributeError("RGB(A) images do not have a colormap property") + self._cmap.set_value(self, name) + + @property + def cmap_interpolation(self) -> str: + """cmap interpolation method, 'linear' or 'nearest'. Used only for grayscale images""" + return self._cmap_interpolation.value + + @cmap_interpolation.setter + def cmap_interpolation(self, value: str): + self._cmap_interpolation.set_value(self, value) + + def reset_vmin_vmax(self): + """ + Reset the vmin, vmax by estimating it from the data by subsampling. + """ + if self.data.value is None: + raise NotImplemented("Cannot reset vmin, vmax if `cpu_buffer=False`") + + vmin, vmax = quick_min_max(self._data.value) + self.vmin = vmin + self.vmax = vmax + + +class ImageYUVGraphic(ImageBase): + _features = { + "data": TextureYUV, + "gamma": ImageGamma, + "vmin": ImageVmin, + "vmax": ImageVmax, + "interpolation": ImageInterpolation, + } + + def __init__( + self, + data: TupleYUV | TextureYUV, + vmin: float = 0, + vmax: float = 255, + gamma: float = 1.0, + interpolation: str = "nearest", + colorspace: ColorspacesYUV = "yuv420p", + colorrange: ColorRange = "limited", + **kwargs, + ): + """ + Create an ImageYUVGraphic. Similar to ImageGraphic but handles data that is in yuv42p or yuv444p colorspace. + + Note that the buffers for YUV Images only exist on the GPU. When setting the image data, the new values are + directly sent to the GPU. + + ``reset_vmin_vmax()`` just sets (vmin, vmax) to (0, 255) + + Parameters + ---------- + data: TupleYUV + tuple of arrays that represent YUV channels. If the colorspace is yuv420p, the U and V array dims + must be 4 times smaller than the Y array dims. + + vmin: float, optional, default 0 + minimum value for color scaling + + vmax: float, optional, default 255 + maximum value for color scaling + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + colorspace: "yuv42p" | "yuv444p" + colorspace in which to interpret the provided data. + + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. + + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. + + colorrange: Literal["full", "limited"] = "limited", + Relevant for yuv colorspaces. Most videos use "limited". + + * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + * "full": The luma plane and chroma plane use the full range of the storage format. + + See the following links from the FFMPEG documentation for more details: + https://trac.ffmpeg.org/wiki/colorspace + https://ffmpeg.org/doxygen/7.0/pixfmt_8h_source.html#l00609 + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + """ + super().__init__(**kwargs) + + if isinstance(data, TextureYUV): + # share buffer + self._data = data + else: + self._data = TextureYUV(data, colorspace=colorspace) + + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) + + self._interpolation = ImageInterpolation(interpolation) + + self._material = HighlightableImageMaterial( + clim=(vmin, vmax), interpolation=self.interpolation, pick_write=True + ) + self._material.gamma = gamma + + wo = pygfx.Image( + geometry=pygfx.Geometry(grid=self.data._texture), + material=self._material, + ) + + self._set_world_object(wo) + + @property + def data(self) -> TextureYUV: + """ + YUV Texture data, note that no local buffer exists for YUV images, you can only set values but not get them + """ + return self._data + + @data.setter + def data(self, data): + self.data.set_value(self, data) + + @property + def colorspace(self) -> ColorspacesYUV: + """image's colorspace""" + return self.data.colorspace + + @property + def colorrange(self) -> ColorRange: + """the color range, see docstring for details""" + return self.data.colorrange + + @property + def cmap(self): + raise NotImplemented("YUV images don't have a cmap") + + @property + def cmap_interpolation(self): + raise NotRequired("YUV images don't have a cmap") + + def reset_vmin_vmax(self): + """reset vmin, vmax to (0, 255)""" + self.vmin, self.vmax = 0, 255 diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db8f29eaa..2154acdb8 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -8,6 +8,7 @@ from .features import ( TextureArrayVolume, ImageCmap, + ImageGamma, ImageVmin, ImageVmax, ImageInterpolation, @@ -85,6 +86,7 @@ class ImageVolumeGraphic(Graphic): _features = { "data": TextureArrayVolume, "cmap": ImageCmap, + "gamma": ImageGamma, "vmin": ImageVmin, "vmax": ImageVmax, "interpolation": ImageInterpolation, @@ -105,6 +107,7 @@ def __init__( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "linear", cmap_interpolation: str = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), @@ -113,7 +116,6 @@ def __init__( substep_size: float = 0.1, emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, - isolated_buffer: bool = True, **kwargs, ): """ @@ -137,6 +139,9 @@ def __init__( cmap: str, default "plasma" colormap for grayscale volumes + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, default "linear" interpolation method for sampling pixels @@ -170,11 +175,6 @@ def __init__( 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` @@ -188,7 +188,7 @@ def __init__( super().__init__(**kwargs) - world_object = pygfx.Group() + group = pygfx.Group() if isinstance(data, TextureArrayVolume): # share existing buffer @@ -196,7 +196,7 @@ def __init__( 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) + self._data = TextureArrayVolume(data) if (vmin is None) or (vmax is None): _vmin, _vmax = quick_min_max(self.data.value) @@ -208,20 +208,27 @@ def __init__( # other graphic features self._vmin = ImageVmin(vmin) self._vmax = ImageVmax(vmax) + self._gamma = ImageGamma(gamma) self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_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", ) + if self._data.value.ndim not in (3, 4): + raise ValueError( + f"ImageVolumeGraphic `data` must have 3 dimensions for grayscale images, " + f"or 4 dimensions for RGB(A) images.\n" + f"You have passed a a data array with: {self._data.value.ndim} dimensions, " + f"and of shape: {self._data.value.shape}" + ) + self._plane = VolumeSlicePlane(plane) self._threshold = VolumeIsoThreshold(threshold) self._step_size = VolumeIsoStepSize(step_size) @@ -234,9 +241,19 @@ def __init__( VolumeMaterialCls = VOLUME_RENDER_MODES[mode] self._material = VolumeMaterialCls(**material_kwargs) + self._material.gamma = gamma self._mode = VolumeRenderMode(mode) + # create tiles + for tile in self._create_tiles(): + group.add(tile) + + self._set_world_object(group) + + def _create_tiles(self) -> list[_VolumeTile]: + tiles = list() + # 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: @@ -259,9 +276,9 @@ def __init__( vol.world.x = data_col_start vol.world.y = data_row_start - world_object.add(vol) + tiles.append(vol) - self._set_world_object(world_object) + return tiles @property def data(self) -> TextureArrayVolume: @@ -270,6 +287,21 @@ def data(self) -> TextureArrayVolume: @data.setter def data(self, data): + if isinstance(data, np.ndarray): + # check if a new buffer is required + if self._data.value.shape != data.shape: + # create new TextureArray + self._data = TextureArrayVolume(data) + + # clear image tiles + self.world_object.clear() + + # create new tiles + for tile in self._create_tiles(): + self.world_object.add(tile) + + return + self._data[:] = data @property @@ -283,7 +315,7 @@ def mode(self, mode: str): @property def cmap(self) -> str: - """Get or set colormap name""" + """Get or set colormap name, only used for grayscale images""" return self._cmap.value @cmap.setter @@ -308,6 +340,15 @@ def vmax(self) -> float: def vmax(self, value: float): self._vmax.set_value(self, value) + @property + def gamma(self) -> float: + """gamma correction applied to the image""" + return self._gamma.value + + @gamma.setter + def gamma(self, value: float): + self._gamma.set_value(self, value) + @property def interpolation(self) -> str: """Get or set the image data interpolation method""" diff --git a/fastplotlib/graphics/inf_line.py b/fastplotlib/graphics/inf_line.py new file mode 100644 index 000000000..6d92d4b3b --- /dev/null +++ b/fastplotlib/graphics/inf_line.py @@ -0,0 +1,182 @@ +from typing import * + +import numpy as np + +import pygfx + +from .line import LineGraphic +from .features import ( + InfLineAxisData, + InfLineColors, + UniformColor, + VertexCmap, + Thickness, + SizeSpace, + DashPattern, +) + + +class InfLineGraphic(LineGraphic): + _features = { + "data": InfLineAxisData, + "colors": (InfLineColors, UniformColor), + "cmap": (VertexCmap, None), # none if UniformColor + "thickness": Thickness, + "size_space": SizeSpace, + "dash_pattern": DashPattern, + } + + # one color per line, each broadcast to the two vertices of the line's segment + _VertexColorsCls = InfLineColors + + def __init__( + self, + data: Any, + axis: Literal["x", "y", "z"] | None = None, + thickness: float = 2.0, + colors: str | np.ndarray | Sequence = "w", + cmap: str = None, + cmap_transform: np.ndarray | Sequence = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", + start_is_infinite: bool = True, + end_is_infinite: bool = True, + dash_pattern: str | tuple | list = (), + size_space: str = "screen", + **kwargs, + ): + """ + Create a collection of infinite lines. + + Parameters + ---------- + data: array-like + The line positions. If ``axis`` is "x", "y", or "z", a 1D array of positions along + that axis; one infinite line is drawn at each position. If ``axis`` is None, ``data`` + is used directly as the segment endpoints, of shape [n_points, 2 | 3], where every two + consecutive points define one line. + + axis: "x", "y", "z", or None, default None + The axis along which the line positions are given. If None, ``data`` is interpreted + directly as the segment endpoints. + + thickness: float, optional, default 2.0 + thickness of the lines + + colors: str, array, or iterable, default "w" + specify colors as a single human-readable string, a single RGBA array, or a Sequence + (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one + color per line. + + cmap: str, optional + Apply a colormap to the lines instead of assigning colors manually, one color per line. + This overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all lines. + "vertex" allows an independent color per line. + For most cases you can keep it as "auto" and the `color_mode` is determined automatically + based on the argument passed to `colors`. + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + start_is_infinite: bool, default True + whether the start of each line is extended to infinity + + end_is_infinite: bool, default True + whether the end of each line is extended to infinity + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + **kwargs + passed to :class:`.Graphic` + + """ + + self._start_is_infinite = bool(start_is_infinite) + self._end_is_infinite = bool(end_is_infinite) + + data = InfLineAxisData(data, axis=axis) + + super().__init__( + data=data, + thickness=thickness, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + color_mode=color_mode, + size_space=size_space, + dash_pattern=dash_pattern, + thin=False, + **kwargs, + ) + + def _make_material(self) -> pygfx.LineInfiniteSegmentMaterial: + return pygfx.LineInfiniteSegmentMaterial( + start_is_infinite=self._start_is_infinite, + end_is_infinite=self._end_is_infinite, + **self._material_kwargs(), + ) + + @property + def axis(self) -> str | None: + """the axis the lines are defined along ("x", "y", "z"), or None if set from endpoints""" + return self._data.axis + + @property + def start_is_infinite(self) -> bool: + """Get or set whether the start of each line is extended to infinity""" + return self._start_is_infinite + + @start_is_infinite.setter + def start_is_infinite(self, value: bool): + self._start_is_infinite = bool(value) + self.world_object.material.start_is_infinite = self._start_is_infinite + + @property + def end_is_infinite(self) -> bool: + """Get or set whether the end of each line is extended to infinity""" + return self._end_is_infinite + + @end_is_infinite.setter + def end_is_infinite(self, value: bool): + self._end_is_infinite = bool(value) + self.world_object.material.end_is_infinite = self._end_is_infinite + + @property + def thin(self) -> bool: + """infinite lines do not support the thin line material""" + return False + + @thin.setter + def thin(self, value: bool): + if value: + raise NotImplementedError( + "`InfLineGraphic` does not support the thin line material" + ) + + def _selectors_not_supported(self, *args, **kwargs): + raise NotImplementedError("selectors are not supported on `InfLineGraphic`") + + add_linear_selector = _selectors_not_supported + add_linear_region_selector = _selectors_not_supported + add_rectangle_selector = _selectors_not_supported + add_polygon_selector = _selectors_not_supported + + def format_pick_info(self, pick_info: dict) -> str: + # two vertices per line + index = pick_info["vertex_index"] // 2 + + if self.axis is not None: + return f"{self.axis}: {self.data.value[index]:.4g}" + + # for axis=None, show the first endpoint of the picked line + point = self.data.value[index][0] + return "\n".join(f"{dim}: {val:.4g}" for dim, val in zip("xyz", point)) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index a4f42704f..0b325df71 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -1,4 +1,5 @@ from typing import * +from warnings import warn import numpy as np @@ -13,11 +14,14 @@ ) from .features import ( Thickness, + DashPattern, + parse_dash_pattern, VertexPositions, VertexColors, UniformColor, VertexCmap, SizeSpace, + UniformRotations, ) from ..utils import quick_min_max @@ -29,6 +33,7 @@ class LineGraphic(PositionsGraphic): "cmap": (VertexCmap, None), # none if UniformColor "thickness": Thickness, "size_space": SizeSpace, + "dash_pattern": DashPattern, } def __init__( @@ -36,11 +41,12 @@ def __init__( data: Any, thickness: float = 2.0, colors: str | np.ndarray | Sequence = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray | Sequence = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", + dash_pattern: str | tuple | list = (), + thin: bool = False, **kwargs, ): """ @@ -61,21 +67,34 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default ``False`` - if True, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color - 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 ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap size_space: str, default "screen" coordinate space in which the thickness is expressed ("screen", "world", "model") + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + **kwargs passed to :class:`.Graphic` @@ -84,52 +103,61 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, + color_mode=color_mode, size_space=size_space, **kwargs, ) self._thickness = Thickness(thickness) + self._dash_pattern = DashPattern(dash_pattern) + self._thin = bool(thin) - 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 + if self._thin and parse_dash_pattern(dash_pattern): + warn( + "`dash_pattern` is ignored when `thin=True`; the thin line material does not " + "support dashing" ) - world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) + world_object = pygfx.Line( + geometry=self._create_geometry(), + material=self._make_material(), + ) self._set_world_object(world_object) + def _material_kwargs(self) -> dict: + # pygfx line material kwargs assembled from the current feature state + kwargs = dict( + thickness=self.thickness, + thickness_space=self.size_space, + dash_pattern=parse_dash_pattern(self._dash_pattern.value), + aa=self.alpha_mode in ("blend", "weighted_blend"), + pick_write=True, + depth_compare="<=", + ) + + if isinstance(self._colors, UniformColor): + kwargs["color_mode"] = "uniform" + kwargs["color"] = self.colors + else: + kwargs["color_mode"] = "vertex" + + return kwargs + + def _make_material(self) -> pygfx.LineMaterial: + # create the pygfx material, subclasses override to use a different line material + material_cls = pygfx.LineThinMaterial if self._thin else pygfx.LineMaterial + return material_cls(**self._material_kwargs()) + + def _create_geometry(self) -> pygfx.Geometry: + if isinstance(self._colors, UniformColor): + return pygfx.Geometry(positions=self._data._fpl_buffer) + return pygfx.Geometry( + positions=self._data._fpl_buffer, colors=self._colors._fpl_buffer + ) + @property def thickness(self) -> float: """Get or set the line thickness""" @@ -139,6 +167,56 @@ def thickness(self) -> float: def thickness(self, value: float): self._thickness.set_value(self, value) + @property + def dash_pattern(self) -> str | tuple | list: + """ + Get or set the dash pattern. + + May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` or + ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + """ + return self._dash_pattern.value + + @dash_pattern.setter + def dash_pattern(self, value: str | tuple | list): + if self._thin and parse_dash_pattern(value): + warn( + "`dash_pattern` is ignored when `thin=True`; the thin line material does not " + "support dashing" + ) + self._dash_pattern.set_value(self, value) + + @property + def thin(self) -> bool: + """ + Get or set whether the line uses the more performant thin line material, which is + always one physical pixel wide. Thickness, dashing, and anti-aliasing are ignored + when True. + """ + return self._thin + + @thin.setter + def thin(self, value: bool): + value = bool(value) + if value == self._thin: + return + + if value and parse_dash_pattern(self._dash_pattern.value): + warn( + "`dash_pattern` is ignored when `thin=True`; the thin line material does not " + "support dashing" + ) + + self._thin = value + + # thin vs. non-thin is a different pygfx material, so rebuild and swap it in place, + # keeping the same geometry + material = self._make_material() + material.opacity = self.alpha + material.alpha_mode = self.alpha_mode + self.world_object.material = material + def add_linear_selector( self, selection: float = None, axis: str = "x", **kwargs ) -> LinearSelector: diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index d08231f7d..3656b5d39 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -1,3 +1,5 @@ +from itertools import repeat +from numbers import Number from typing import * import numpy as np @@ -105,8 +107,11 @@ def thickness(self) -> np.ndarray: return np.asarray([g.thickness for g in self]) @thickness.setter - def thickness(self, values: np.ndarray | list[float]): - if not len(values) == len(self): + def thickness(self, values: float | Sequence[float]): + if isinstance(values, Number): + values = repeat(values, len(self)) + + elif not len(values) == len(self): raise IndexError for g, v in zip(self, values): @@ -128,14 +133,13 @@ def __init__( data: np.ndarray | List[np.ndarray], thickness: float | Sequence[float] = 2.0, colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", - uniform_colors: bool = False, cmap: Sequence[str] | str = None, cmap_transform: np.ndarray | List = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, **kwargs, ): @@ -170,6 +174,9 @@ def __init__( cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + name: str, optional name of the line collection as a whole @@ -320,11 +327,10 @@ def __init__( data=d, thickness=_s, colors=_c, - uniform_color=uniform_colors, cmap=_cmap, + color_mode=color_mode, name=_name, metadata=_m, - isolated_buffer=isolated_buffer, **kwargs_lines, ) @@ -560,7 +566,6 @@ def __init__( names: list[str] = None, metadata: Any = None, metadatas: Sequence[Any] | np.ndarray = None, - isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, @@ -634,7 +639,6 @@ def __init__( names=names, metadata=metadata, metadatas=metadatas, - isolated_buffer=isolated_buffer, kwargs_lines=kwargs_lines, **kwargs, ) @@ -651,4 +655,5 @@ def __init__( axis_zero + line.data.value[:, axes[separation_axis]].max() + separation ) + self.separation_axis = separation_axis self.separation = separation diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 0e1ac42a3..efe03c57b 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -38,7 +38,6 @@ def __init__( mapcoords: Any = None, cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, **kwargs, ): """ @@ -77,12 +76,6 @@ def __init__( Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. An image can also be used, this is basically a 2D colormap. - 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. In almost all cases this should be ``True``. - **kwargs passed to :class:`.Graphic` @@ -93,16 +86,12 @@ def __init__( if isinstance(positions, VertexPositions): self._positions = positions else: - self._positions = VertexPositions( - positions, isolated_buffer=isolated_buffer, property_name="positions" - ) + self._positions = VertexPositions(positions, property_name="positions") if isinstance(positions, MeshIndices): self._indices = indices else: - self._indices = MeshIndices( - indices, isolated_buffer=isolated_buffer, property_name="indices" - ) + self._indices = MeshIndices(indices, property_name="indices") self._cmap = MeshCmap(cmap) @@ -139,7 +128,7 @@ def __init__( ) geometry = pygfx.Geometry( - positions=self._positions.buffer, indices=self._indices._buffer + positions=self._positions.buffer, indices=self._indices._fpl_buffer ) valid_modes = ["basic", "phong", "slice"] diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index a2e696a82..b9cacf908 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -40,12 +40,12 @@ def __init__( self, data: Any, colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: str | np.ndarray | Sequence[str] = "o", - uniform_marker: bool = False, + uniform_marker: bool = True, custom_sdf: str = None, edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", uniform_edge_color: bool = True, @@ -53,10 +53,9 @@ def __init__( 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, + sizes: float | np.ndarray | Sequence[float] = 5, + uniform_size: bool = True, size_space: str = "screen", - isolated_buffer: bool = True, **kwargs, ): """ @@ -72,18 +71,23 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default False - 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. - 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 - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -103,9 +107,10 @@ def __init__( * 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. + uniform_marker: bool, default ``True`` + If ``True``, 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. If ``False``, you can + set per-vertex markers. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. @@ -125,8 +130,9 @@ def __init__( 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. + uniform_edge_color: bool, default ``True`` + Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge + colors edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". @@ -147,17 +153,13 @@ def __init__( sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points - uniform_size: bool, default False - if True, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. + uniform_size: bool, default ``False`` + if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to + save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. size_space: str, default "screen" 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` @@ -166,17 +168,16 @@ def __init__( super().__init__( data=data, colors=colors, - uniform_color=uniform_color, cmap=cmap, cmap_transform=cmap_transform, - isolated_buffer=isolated_buffer, + color_mode=color_mode, size_space=size_space, **kwargs, ) n_datapoints = self.data.value.shape[0] - geo_kwargs = {"positions": self._data.buffer} + geo_kwargs = {"positions": self._data._fpl_buffer} aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") @@ -214,7 +215,7 @@ def __init__( self._markers = VertexMarkers(markers, n_datapoints) - geo_kwargs["markers"] = self._markers.buffer + geo_kwargs["markers"] = self._markers._fpl_buffer if edge_colors is None: # interpret as no edge color @@ -237,7 +238,7 @@ def __init__( edge_colors, n_datapoints, property_name="edge_colors" ) material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["edge_colors"] = self._edge_colors.buffer + geo_kwargs["edge_colors"] = self._edge_colors._fpl_buffer self._edge_width = EdgeWidth(edge_width) material_kwargs["edge_width"] = self._edge_width.value @@ -274,12 +275,12 @@ def __init__( self._size_space = SizeSpace(size_space) - if uniform_color: + if isinstance(self._colors, UniformColor): material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: material_kwargs["color_mode"] = pygfx.ColorMode.vertex - geo_kwargs["colors"] = self.colors.buffer + geo_kwargs["colors"] = self.colors._fpl_buffer if uniform_size: material_kwargs["size_mode"] = pygfx.SizeMode.uniform @@ -288,14 +289,14 @@ def __init__( else: material_kwargs["size_mode"] = pygfx.SizeMode.vertex self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) - geo_kwargs["sizes"] = self.sizes.buffer + geo_kwargs["sizes"] = self.sizes._fpl_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 + geo_kwargs["rotations"] = self._point_rotations._fpl_buffer case pygfx.enums.RotationMode.uniform: self._point_rotations = UniformRotations(point_rotations) @@ -338,10 +339,8 @@ def markers(self, value: str | np.ndarray[str] | Sequence[str]): 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) + + self._markers.set_value(self, value) @property def edge_colors(self) -> str | pygfx.Color | VertexColors | None: @@ -359,12 +358,7 @@ def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]) 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) + self._edge_colors.set_value(self, value) @property def edge_width(self) -> float | None: @@ -406,11 +400,7 @@ def point_rotations(self, value: float | np.ndarray[float]): 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) + self._point_rotations.set_value(self, value) @property def image(self) -> TextureArray | None: @@ -437,8 +427,4 @@ def sizes(self) -> VertexPointSizes | float: @sizes.setter def sizes(self, value): - if isinstance(self._sizes, VertexPointSizes): - self._sizes[:] = value - - elif isinstance(self._sizes, UniformSize): - self._sizes.set_value(self, value) + self._sizes.set_value(self, value) diff --git a/fastplotlib/graphics/scatter_collection.py b/fastplotlib/graphics/scatter_collection.py new file mode 100644 index 000000000..b2d150d23 --- /dev/null +++ b/fastplotlib/graphics/scatter_collection.py @@ -0,0 +1,677 @@ +from itertools import repeat +from numbers import Number +from typing import * + +import numpy as np + +import pygfx + +from ..utils import parse_cmap_values +from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature +from .scatter import ScatterGraphic +from .selectors import ( + LinearRegionSelector, + LinearSelector, + RectangleSelector, + PolygonSelector, +) + + +class _ScatterCollectionProperties: + """Mix-in class for ScatterCollection properties""" + + @property + def colors(self) -> CollectionFeature: + """get or set colors of scatters in the collection""" + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all scatter to one str color + for g in self: + g.colors = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each scatter + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + """get or set data of scatters in the collection""" + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + for g, v in zip(self, values): + g.data = v + + @property + def cmap(self) -> CollectionFeature: + """ + Get or set a cmap along the scatter collection. + + Optionally set using a tuple ("cmap", ) to set the transform. + Example: + + scatter_collection.cmap = ("jet", sine_transform_vals, 0.7) + + """ + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, args): + if isinstance(args, str): + name = args + transform = None + elif len(args) == 1: + name = args[0] + transform = None + elif len(args) == 2: + name, transform = args + else: + raise ValueError( + "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)" + ) + + self.colors = parse_cmap_values( + n_colors=len(self), cmap_name=name, transform=transform + ) + + @property + def markers(self) -> CollectionFeature: + """get or set markers of scatters in the collection""" + return CollectionFeature(self.graphics, "markers") + + @markers.setter + def markers(self, values: str | Sequence[str]): + if isinstance(values, str): + values = repeat(values, len(self)) + + elif len(values) != len(self): + raise IndexError("len(markers) must be the same as the number of ScatterGraphics in the collection") + + for g, v in zip(self, values): + g.markers = v + + @property + def sizes(self) -> CollectionFeature: + """get or set sizes of scatter points in the collection""" + return CollectionFeature(self.graphics, "sizes") + + @sizes.setter + def sizes(self, values): + if isinstance(values, Number): + values = repeat(values, len(self)) + + elif len(values) != len(self): + raise IndexError("len(sizes) must be the same as the number of ScatterGraphics in the collection") + + for g, v in zip(self, values): + g.sizes = v + + +class ScatterCollectionIndexer(CollectionIndexer, _ScatterCollectionProperties): + """Indexer for scatter collections""" + pass + + +class ScatterCollection(GraphicCollection, _ScatterCollectionProperties): + _child_type = ScatterGraphic + _indexer = ScatterCollectionIndexer + + def __init__( + self, + data: np.ndarray | List[np.ndarray], + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + cmap: Sequence[str] | str = None, + cmap_transform: np.ndarray | List = None, + sizes: float | Sequence[float] = 5.0, + uniform_size: bool = True, + markers: np.ndarray | Sequence[str] = None, + uniform_marker: bool = True, + edge_width: float = 1.0, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + **kwargs, + ): + """ + Create a collection of :class:`.ScatterGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + """ + + super().__init__(name=name, metadata=metadata, **kwargs) + + if names is not None: + if len(names) != len(data): + raise ValueError( + 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{len(metadatas)} != {len(data)}" + ) + + self._cmap_transform = cmap_transform + self._cmap_str = cmap + + # cmap takes priority over colors + if cmap is not None: + # cmap across lines + if isinstance(cmap, str): + colors = parse_cmap_values( + n_colors=len(data), cmap_name=cmap, transform=cmap_transform + ) + single_color = False + cmap = None + + elif isinstance(cmap, (tuple, list)): + if len(cmap) != len(data): + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) + single_color = False + else: + raise ValueError( + "cmap argument must be a single cmap or a list of cmaps " + "with the same length as the data" + ) + else: + if isinstance(colors, np.ndarray): + # single color for all lines in the collection as RGBA + if colors.shape in [(3,), (4,)]: + single_color = True + + # colors specified for each line as array of shape [n_lines, RGBA] + elif colors.shape == (len(data), 4): + single_color = False + + else: + raise ValueError( + f"numpy array colors argument must be of shape (4,) or (n_lines, 4)." + f"You have pass the following shape: {colors.shape}" + ) + + elif isinstance(colors, str): + if colors == "random": + colors = np.random.rand(len(data), 3) + single_color = False + else: + # parse string color + single_color = True + colors = pygfx.Color(colors) + + elif isinstance(colors, (tuple, list)): + if len(colors) == 4: + # single color specified as (R, G, B, A) tuple or list + if all([isinstance(c, (float, int)) for c in colors]): + single_color = True + + elif len(colors) == len(data): + # colors passed as list/tuple of colors, such as list of string + single_color = False + + else: + raise ValueError( + "tuple or list colors argument must be a single color represented as [R, G, B, A], " + "or must be a tuple/list of colors represented by a string with the same length as the data" + ) + + self._set_world_object(pygfx.Group()) + + for i, d in enumerate(data): + if cmap is None: + _cmap = None + + if single_color: + _c = colors + else: + _c = colors[i] + else: + _cmap = cmap[i] + _c = None + + if metadatas is not None: + _m = metadatas[i] + else: + _m = None + + if names is not None: + _name = names[i] + else: + _name = None + + if markers is not None: + if isinstance(markers, (tuple, list, np.ndarray)): + markers_ = markers[i] + else: + markers_ = markers + else: + markers_ = "o" + + if sizes is not None: + if isinstance(sizes, (tuple, list, np.ndarray)): + sizes_ = sizes[i] + else: + sizes_ = sizes + else: + sizes_ = 5 + + lg = ScatterGraphic( + data=d, + colors=_c, + sizes=sizes_, + markers=markers_, + cmap=_cmap, + name=_name, + metadata=_m, + uniform_marker=uniform_marker, + uniform_size=uniform_size, + edge_width=edge_width, + **kwargs, + ) + + self.add_graphic(lg) + + def __getitem__(self, item) -> ScatterCollectionIndexer: + return super().__getitem__(item) + + def add_linear_selector( + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs + ) -> LinearSelector: + """ + Adds a linear selector. + + Parameters + ---------- + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init[0] + + selector = LinearSelector( + selection=selection, + limits=limits, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + return selector + + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init + + # create selector + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + # 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] = None, + **kwargs, + ) -> RectangleSelector: + """ + Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + selection: (float, float, float, float), optional + initial (xmin, xmax, ymin, ymax) of the selection + """ + bbox = self.world_object.get_world_bounding_box() + + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25px = (xmax - xmin) / 4 + + ydata = np.array(self.data[:, 1]) + ymin = np.floor(ydata.min()).astype(int) + + ymax = np.ptp(bbox[:, 1]) + + if selection is None: + selection = (xmin, value_25px, ymin, ymax) + + limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5) + + selector = RectangleSelector( + selection=selection, + limits=limits, + parent=self, + **kwargs, + ) + + self._plot_area.add_graphic(selector, center=False) + + 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() + + if axis == "x": + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25p = (xmax - xmin) / 4 + + bounds = (xmin, value_25p) + limits = (xmin, xmax) + # size from orthogonal axis + size = np.ptp(bbox[:, 1]) * 1.5 + # center on orthogonal axis + center = bbox[:, 1].mean() + + elif axis == "y": + ydata = np.array(self.data[:, 1]) + xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) + value_25p = (xmax - xmin) / 4 + + bounds = (xmin, value_25p) + limits = (xmin, xmax) + + size = np.ptp(bbox[:, 0]) * 1.5 + # center on orthogonal axis + center = bbox[:, 0].mean() + + return bounds, limits, size, center + + +axes = {"x": 0, "y": 1, "z": 2} + + +class ScatterStack(ScatterCollection): + def __init__( + self, + data: np.ndarray | List[np.ndarray], + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + cmap: Sequence[str] | str = None, + cmap_transform: np.ndarray | List = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + separation: float = 0.0, + separation_axis: str = "y", + **kwargs, + ): + """ + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + separation: float, default 0.0 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + """ + super().__init__( + data=data, + colors=colors, + cmap=cmap, + cmap_transform=cmap_transform, + name=name, + names=names, + metadata=metadata, + metadatas=metadatas, + **kwargs, + ) + + self._separation_axis = separation_axis + self._separation = separation + + self.separation = separation + + @property + def separation_axis(self) -> str: + """axis along which the graphics are separated: ``'x'`` or ``'y'``""" + return self._separation_axis + + @property + def separation(self) -> float: + """distance between each line in the stack, in world space""" + return self._separation + + @separation.setter + def separation(self, value: float): + separation = float(value) + + axis_zero = 0 + for i, line in enumerate(self.graphics): + if self._separation_axis == "x": + line.offset = (axis_zero, *line.offset[1:]) + + elif self._separation_axis == "y": + line.offset = (line.offset[0], axis_zero, line.offset[2]) + + axis_zero = ( + axis_zero + line.data.value[:, axes[self._separation_axis]].max() + separation + ) + + self._separation = value diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 9133192e9..8b2c109fe 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,7 +1,38 @@ +from ._protocols import SelectorProtocol, MultiSelectorProtocol from ._linear import LinearSelector from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector from ._rectangle import RectangleSelector +from ._highlight_selector import ( + HighlightSelector, + PositionsHighlightSelector, + CollectionHighlightSelector, + ImageHighlightSelector, +) +from ._visibility_selector import VisibilitySelector, ImageVisibilitySelector +from ._selector_collection import ( + SelectorCollection, + LinearSelectors, + LinearRegionSelectors, + RectangleSelectors, + PolygonSelectors, +) +from ._selection_vector import SelectionVector - -__all__ = ["LinearSelector", "LinearRegionSelector", "RectangleSelector"] +__all__ = [ + "LinearSelector", + "LinearRegionSelector", + "RectangleSelector", + "HighlightSelector", + "PositionsHighlightSelector", + "CollectionHighlightSelector", + "ImageHighlightSelector", + "VisibilitySelector", + "ImageVisibilitySelector", + "SelectorCollection", + "LinearSelectors", + "LinearRegionSelectors", + "RectangleSelectors", + "PolygonSelectors", + "SelectionVector", +] diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index 28c6534a7..b73e36a5f 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -7,6 +7,7 @@ from pygfx import WorldObject, Line, Mesh, Points +from ._protocols import SelectorProtocol from .._base import Graphic @@ -39,7 +40,7 @@ class MoveInfo: # Selector base class -class BaseSelector(Graphic): +class BaseSelector(Graphic, SelectorProtocol): _fpl_support_tooltip = False @property diff --git a/fastplotlib/graphics/selectors/_highlight_selector.py b/fastplotlib/graphics/selectors/_highlight_selector.py new file mode 100644 index 000000000..dc757c07a --- /dev/null +++ b/fastplotlib/graphics/selectors/_highlight_selector.py @@ -0,0 +1,988 @@ +from __future__ import annotations + +from typing import Iterable +from numbers import Integral +from typing import Callable, Literal +from warnings import warn + +import cmap as cmap_lib +import numpy as np +import pygfx +import wgpu + +from .._collection_base import GraphicCollection +from ..shaders._highlight_materials import ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, + HighlightableImageMaterial, +) + +_POSITIONS_MATERIAL_TYPES = ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, +) + +cmap_lib.Colormap("tab10").lut() + + +def _build_lut( + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + n: int = 1, + lut_wrap: Literal["fixed", "repeat"] = "fixed", +) -> np.ndarray: + """ + Return an (n, 4) float32 RGBA array for n selected items. + """ + + if n == 0: + return np.zeros((1, 4), dtype=np.float32) + + if lut is not None: + if isinstance(lut, str): + lut = cmap_lib.Colormap(lut).lut(n) + + lut = np.asarray(lut, dtype=np.float32) + + if lut.ndim != 2 or lut.shape[1] != 4: + raise ValueError("`lut` must have shape (n, 4) for n selected items") + + if lut_wrap == "repeat": + return lut[np.arange(n) % len(lut)] + + if lut.shape[0] < n: + raise ValueError( + f"`lut` has only {lut.shape[0]} entries but {n} are selected" + ) + + return lut[:n] + + return np.repeat([pygfx.Color(color)], n, axis=0) + + +class HighlightSelector: + """ + Base class managing highlight state on one or more graphics. + + Highlights selected vertices or image regions by blending a color into the + rendered output. Does not create extra world objects, so ``pick_info`` + is unaffected. + + Use the subclasses: + + * :class:`PositionsHighlightSelector`: highlight individual vertices on a + LineGraphic or ScatterGraphic + * :class:`CollectionHighlightSelector`: highlight whole lines/scatters in + a collection + * :class:`ImageHighlightSelector`: highlight pixel regions of an ImageGraphic + """ + + def __init__( + self, + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + lut_wrap: Literal["fixed", "repeat"] = "fixed", + alpha: float = 0.7, + ): + if lut_wrap not in ("fixed", "repeat"): + raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}") + + self._color = color + self._lut = lut + self._alpha = float(alpha) + self._lut_wrap = lut_wrap + self._graphics = list() + self._event_handlers: list[Callable] = list() + + @property + def selection(self): + raise NotImplementedError + + @selection.setter + def selection(self, value): + raise NotImplementedError + + def append(self, item) -> None: + raise NotImplementedError + + def remove(self, item) -> None: + raise NotImplementedError + + def clear(self) -> None: + raise NotImplementedError + + @property + def color(self) -> str | np.ndarray: + """ + Get or set color applied to all selected items, used if ``lut`` is ``None``. + + Accepts any value that ``pygfx.Color`` understands (color name string, + RGBA tuple, hex string, etc.). + """ + return self._color + + @color.setter + def color(self, value): + self._color = value + self._update_all_graphics() + + @property + def lut(self) -> str | np.ndarray | None: + """ + Get or set per-item color lookup table, shape ``(n, 4)`` float32 RGBA, or a str + that defines a colormap. + + When set, ``lut[i]`` is the highlight color for the i-th selected item. + Must have at least as many rows as the number of selected items. + Set to ``None`` to fall back to ``color``. + """ + return self._lut + + @lut.setter + def lut(self, value: np.ndarray | None): + self._lut = value + self._update_all_graphics() + + @property + def lut_wrap(self) -> str: + """ + Get or set LUT wrap mode. + - "fixed": no wrapping, fixed to size of the given LUT + - "repeat": cycles through the colormap when n_selections > lut_size""" + return self._lut_wrap + + @property + def alpha(self) -> float: + """Get or set alpha value, 0 - 1.0""" + return self._alpha + + @alpha.setter + def alpha(self, value: float): + self._alpha = float(value) + self._update_all_graphics() + + @property + def graphics(self) -> list: + """Get graphics the highlight selector is operating on.""" + return list(self._graphics) + + def add_graphic(self, graphic) -> None: + """Add ``graphic`` and apply the current highlight selection to it.""" + if graphic in self._graphics: + warn(f"{graphic!r} is already attached to this selector.") + return + + self._check_graphic(graphic) + self._graphics.append(graphic) + self._update_highlight_buffers(graphic) + + def remove_graphic(self, graphic) -> None: + """remove ``graphic`` and clear its highlight buffer.""" + if graphic not in self._graphics: + raise KeyError(f"{graphic!r} is not attached to this selector.") + self._graphics.remove(graphic) + self._clear_highlight_buffers(graphic) + + def _check_graphic(self, graphic) -> None: + raise NotImplementedError + + def _update_highlight_buffers(self, graphic) -> None: + raise NotImplementedError + + def _clear_highlight_buffers(self, graphic) -> None: + raise NotImplementedError + + def add_event_handler(self, handler: Callable) -> None: + """Add a callback that is called when the selection changes.""" + if not callable(handler): + raise TypeError("event handler must be callable") + + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + """Remove an event handler.""" + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + def _update_all_graphics(self) -> None: + for g in self._graphics: + self._update_highlight_buffers(g) + + @staticmethod + def _write_ids(material, ids: np.ndarray) -> None: + # replace buffer if size changed (GPU binding must point to new object) + if material._highlight_ids_buffer.data.shape[0] != ids.shape[0]: + material._highlight_ids_buffer = pygfx.Buffer(ids.copy()) + else: + material._highlight_ids_buffer.data[:] = ids + material._highlight_ids_buffer.update_range() + + @staticmethod + def _write_lut(material, lut: np.ndarray) -> None: + # replace buffer if size changed (GPU binding must point to new object) + if material._highlight_lut_buffer.data.shape[0] != lut.shape[0]: + material._highlight_lut_buffer = pygfx.Buffer(lut.copy()) + else: + material._highlight_lut_buffer.data[:] = lut + material._highlight_lut_buffer.update_range() + + def __len__(self) -> int: + raise NotImplementedError + + def __contains__(self, item) -> bool: + raise NotImplementedError + + def __iter__(self): + raise NotImplementedError + + def __repr__(self) -> str: + return f"{self.__class__.__name__}\n" f"selection: {self.selection}" + + +class PositionsHighlightSelector(HighlightSelector): + """ + Highlights individual data points on a LineGraphic or ScatterGraphic. + + Parameters + ---------- + color : str or array-like, default "cyan" + Color applied to all selected vertices when no ``lut`` is set. + + lut : np.ndarray of shape (n, 4), optional + Per-vertex RGBA colors; ``lut[i]`` applies to the i-th selected vertex. + + lut_wrap: "fixed" or "repeat" + - "fixed": no wrapping, fixed to size of the given LUT + - "repeat": cycles through the colormap when n_selections > lut_size + + alpha : float, default 1.0 + Highlight blend strength in [0, 1]. + + """ + + def __init__( + self, + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + lut_wrap: Literal["fixed", "repeat"] = "fixed", + alpha: float = 1.0, + ): + super().__init__(color=color, lut=lut, lut_wrap=lut_wrap, alpha=alpha) + self._selection: list[int] = list() + + @property + def selection(self) -> tuple[int, ...]: + """ + Get or set selected vertex indices. + """ + return tuple(self._selection) + + @selection.setter + def selection(self, value) -> None: + if value is None or len(value) == 0: + self._selection = list() + else: + if isinstance(value, Integral): + value = [value] + + if not all([isinstance(i, Integral) for i in value]): + raise TypeError(f"selection must be an iterable of \ngot: {value}") + + # convert to list + self._selection = list(map(int, value)) + + self._update_all_graphics() + self._emit({"value": tuple(self._selection)}) + + # TODO: need to review the rest of these method + def append(self, item) -> None: + """ + Append one or more vertex indices to the selection. + + Indices already in the selection are silently skipped. + """ + new = [int(i) for i in np.asarray(item).ravel()] + novel = [i for i in new if i not in self._selection] + if novel: + self._selection.extend(novel) + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def remove(self, item) -> None: + """Remove one or more vertex indices from the selection.""" + to_remove = set(int(i) for i in np.asarray(item).ravel()) + self._selection = [i for i in self._selection if i not in to_remove] + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Remove all highlights.""" + self._selection = [] + self._update_all_graphics() + self._emit({"value": []}) + + def _check_graphic(self, graphic) -> None: + mat = graphic.world_object.material + if not isinstance(mat, _POSITIONS_MATERIAL_TYPES): + raise TypeError( + f"PositionsHighlightSelector requires a graphic using one of " + f"{[t.__name__ for t in _POSITIONS_MATERIAL_TYPES]}, " + f"got {type(mat).__name__}." + ) + + def _update_highlight_buffers(self, graphic) -> None: + mat = graphic.world_object.material + mat.uniform_buffer.data["highlight_alpha"] = self._alpha + mat.uniform_buffer.update_range() + + n_vertices = graphic.data.value.shape[0] + ids = np.zeros(n_vertices, dtype=np.uint32) + for rank, idx in enumerate(self._selection): + if 0 <= idx < n_vertices: + ids[idx] = rank + 1 + + self._write_ids(mat, ids) + self._write_lut( + mat, + _build_lut(self._color, self._lut, len(self._selection), self._lut_wrap), + ) + + def _clear_highlight_buffers(self, graphic) -> None: + mat = graphic.world_object.material + n_vertices = graphic.data.value.shape[0] + self._write_ids(mat, np.zeros(n_vertices, dtype=np.uint32)) + self._write_lut(mat, np.zeros((1, 4), dtype=np.float32)) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return int(item) in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + return ( + f"PositionsHighlightSelector(" + f"selection={self._selection}, " + f"n_graphics={len(self._graphics)})" + ) + + +# TODO: review +class CollectionHighlightSelector(HighlightSelector): + """ + Highlights entire graphics within a LineCollection or ScatterCollection. + + Each selected collection item is highlighted with a single color across + all of its vertices. + + Parameters + ---------- + color : str or array-like, default "cyan" + Color applied to all selected items when no ``lut`` is set. + lut : np.ndarray of shape (k, 4), optional + Per-item RGBA colors; ``lut[i]`` applies to the i-th selected item. + Must have at least as many rows as the number of selected items. + alpha : float, default 1.0 + Highlight blend strength in [0, 1]. + """ + + def __init__( + self, + color: str | np.ndarray = "cyan", + lut: np.ndarray | None = None, + alpha: float = 1.0, + ): + super().__init__(color=color, lut=lut, alpha=alpha) + self._selection: list[int] = [] + + @property + def selection(self) -> list[int]: + """ + Selected collection indices. + + Assign a list or array of integer indices to set the selection. + Empty selection is represented as ``[]``. + """ + return list(self._selection) + + @selection.setter + def selection(self, value) -> None: + if value is None or len(value) == 0: + self._selection = [] + else: + self._selection = [int(i) for i in np.asarray(value).ravel()] + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def append(self, item) -> None: + """ + Append one or more collection indices to the selection. + + Indices already in the selection are silently skipped. + """ + new = [int(i) for i in np.asarray(item).ravel()] + novel = [i for i in new if i not in self._selection] + if novel: + self._selection.extend(novel) + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def remove(self, item) -> None: + """Remove one or more collection indices from the selection.""" + to_remove = set(int(i) for i in np.asarray(item).ravel()) + self._selection = [i for i in self._selection if i not in to_remove] + self._update_all_graphics() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Remove all highlights.""" + self._selection = [] + self._update_all_graphics() + self._emit({"value": []}) + + def _check_graphic(self, graphic) -> None: + if not isinstance(graphic, GraphicCollection): + raise TypeError( + f"CollectionHighlightSelector requires a GraphicCollection, " + f"got {type(graphic).__name__}." + ) + + def _update_highlight_buffers(self, graphic) -> None: + n_items = len(graphic) + sel = self._selection + lut = _build_lut(self._color, self._lut, len(sel), self._lut_wrap) + rank_map = {idx: rank + 1 for rank, idx in enumerate(sel) if 0 <= idx < n_items} + for i, sub_graphic in enumerate(graphic): + sub_mat = sub_graphic.world_object.material + if not isinstance(sub_mat, _POSITIONS_MATERIAL_TYPES): + continue + sub_mat.uniform_buffer.data["highlight_alpha"] = self._alpha + sub_mat.uniform_buffer.update_range() + n_vertices = sub_graphic.data.value.shape[0] + id_val = np.uint32(rank_map.get(i, 0)) + self._write_ids(sub_mat, np.full(n_vertices, id_val, dtype=np.uint32)) + self._write_lut(sub_mat, lut) + + def _clear_highlight_buffers(self, graphic) -> None: + for sub_graphic in graphic: + sub_mat = sub_graphic.world_object.material + if not isinstance(sub_mat, _POSITIONS_MATERIAL_TYPES): + continue + n_vertices = sub_graphic.data.value.shape[0] + self._write_ids(sub_mat, np.zeros(n_vertices, dtype=np.uint32)) + self._write_lut(sub_mat, np.zeros((1, 4), dtype=np.float32)) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return int(item) in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + return ( + f"CollectionHighlightSelector(" + f"selection={self._selection}, " + f"n_graphics={len(self._graphics)})" + ) + + +class ImageHighlightSelector(HighlightSelector): + """ + Highlights pixel regions of an ImageGraphic. + + Can be used in two modes: + + **Free-selection mode**, if ``selection_options`` is ``None``: + + ``selection`` is a dict with keys: + + - "rows": list of row specs (int, list[int], or slice); selects those rows across all cols. + - "cols": list of col specs (int, list[int], or slice); selects those cols across all rows. + - "pixels": list of ``(n, 2)`` arrays of ``[[row, col], ...]`` coordinates. + + When both "rows" and "cols" are given they must have the same length each pair defines a rectangle. + + **Options mode**, if ``selection_options`` is set: + + All options are shown with ``options_color`` & ``options_alpha``. + ``selection`` is an ``int`` or ``list[int]`` indexing into the options, selected items are shown + with the highlight ``color`` or ``lut`` & ``alpha``. + Only the LUT is rewritten on selection change, not the mask. + + Parameters + ---------- + color : str or array-like, default "red" + Highlight color for selected items. + + lut : np.ndarray of shape (n, 4), optional + RGBA colors for each selected item + + alpha : float, default 1.0 + alpha blending value + + options_color : str or array-like, default "w" + Color shown for unselected option items. + + options_alpha : float, default 0.1 + alpha blend value for unselected items + + selection_options : dict or None, optional + Pool of selectable options (same dict format as ``selection`` in free-selection mode). + + """ + + _VALID_KEYS = frozenset(("rows", "cols", "pixels")) + + def __init__( + self, + color: str | np.ndarray = "red", + lut: str | np.ndarray | None = None, + alpha: float = 0.7, + lut_wrap: str = "fixed", + options_color: str | np.ndarray = "w", + options_alpha: float = 0.1, + selection_options: dict | None = None, + ): + super().__init__(color=color, lut=lut, alpha=alpha, lut_wrap=lut_wrap) + + self._selection: dict[str, list] = dict() + self._selected_indices: list[int | None] = list() + self._options_color = options_color + self._options_alpha = float(options_alpha) + + # 65535 is the highest number that uint16 can represent. + # We make a LUT of this (65535 - 1) since the highlight mask Texture is uint16 + # and 0 is uesd to indicate the placeholder locations for "selection_options" + self._lut_buffer = pygfx.Buffer(np.zeros((65534, 4), dtype=np.float32)) + self._mask_texture: pygfx.Texture | None = None + + # validate and store selection_options without triggering _update_all_graphics + # no graphics are targeted yet + if selection_options is not None: + for k in selection_options: + if k not in self._VALID_KEYS: + raise ValueError( + f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" + ) + self._selection_options: dict[str, list] | None = { + k: list(v) for k, v in selection_options.items() + } + else: + self._selection_options = None + + def _len_dict(self, sel: dict) -> int: + if "rows" in sel: + # covers the case for a selection of rows, as well as row & col pairs + return len(sel["rows"]) + + if "cols" in sel: + return len(sel["cols"]) + + if "pixels" in sel: + return len(sel["pixels"]) + + return 0 + + @staticmethod + def _rgba(color, alpha: float) -> np.ndarray: + c = np.array(pygfx.Color(color), dtype=np.float32) + c[3] = float(alpha) + return c + + @property + def selection_options(self) -> dict[str, tuple] | None: + """ + Get or set a pool of selectable items (same dict format as ``selection`` in free mode). + When set, all options highlighted using ``options_color`` and ``options_alpha``. + ``selection`` indexes into this pool. + Setting to ``None`` reverts to free-selection mode and clears the selection. + """ + if self._selection_options is None: + return None + + # return a new dict with a tuple of the selections so the user can't modify the objects + return {k: tuple(v) for k, v in self._selection_options.items()} + + @selection_options.setter + def selection_options(self, value: dict | None) -> None: + if value is None: + self._selection_options = None + else: + for k in value: + if k not in self._VALID_KEYS: + raise ValueError( + f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" + ) + self._selection_options = {k: list(v) for k, v in value.items()} + + self._selected_indices = list() + self._selection = dict() + self._update_all_graphics() + self._emit({"value": self.selection}) + + @property + def options_color(self) -> str | np.ndarray: + """Get or set color for unselected option items (options mode only).""" + return self._options_color + + @options_color.setter + def options_color(self, value: str | np.ndarray) -> None: + self._options_color = value + + if self._selection_options is not None: + self._update_all_graphics() + + @property + def options_alpha(self) -> float: + """Get or set alpha blend value of unselected option items (options mode only).""" + return self._options_alpha + + @options_alpha.setter + def options_alpha(self, value: float) -> None: + self._options_alpha = float(value) + + if self._selection_options is not None: + self._update_all_graphics() + + @property + def selection(self) -> tuple[int | None, ...] | dict[str, tuple]: + """ + In options mode: tuple of selection option indices. + In free mode: dict of selection items. + """ + if self._selection_options is not None: + return tuple(self._selected_indices) + + # return a new dict with a tuple of the selections so the user can't modify the objects + return {k: tuple(v) for k, v in self._selection.items()} + + @selection.setter + def selection(self, value: Iterable[int | None] | dict[Literal["rows", "cols", "pixels"], list] | None) -> None: + if self._selection_options is not None: + if value is None: + self._selected_indices = list() + + elif isinstance(value, int): + self._selected_indices = [value] + + else: + self._selected_indices = [int(i) if i is not None else None for i in value] + + else: + if not value: + self._selection = {} + + else: + for k in value: + if k not in self._VALID_KEYS: + raise ValueError( + f"Unknown key {k!r}. Must be one of {self._VALID_KEYS}" + ) + + self._selection = {k: list(v) for k, v in value.items()} + + self._update_all_graphics() + self._emit({"value": self.selection}) + + def append(self, dict_or_index: dict | int) -> None: + """ + append to the current selection + """ + if self._selection_options is not None: + # options mode + index = dict_or_index + if not isinstance(index, Integral) and index is not None: + raise TypeError( + f"must provide integer index to append to selection " + f"in 'options' mode, you passed: {dict_or_index!r}" + ) + if index not in self._selected_indices or index is None: + self._selected_indices.append(index) + self._update_all_graphics() + self._emit({"value": self.selection}) + else: + d = dict_or_index + # check that dict is valid + keys = list(d.keys()) + err = f"must provide a dict of only rows, cols, rows & cols, or pixels, you passed a dict with keys: {keys}" + + if any([k not in self._VALID_KEYS for k in keys]): + raise KeyError(err) + + if "pixels" in keys and len(keys) > 1: + raise KeyError(err) + + if "rows" in keys and "cols" in keys: + if len(d["rows"]) != len(d["cols"]): + raise ValueError( + f"if appending pairs of rows & cols, they must be of the same length" + ) + rows, cols = d["rows"], d["cols"] + if not all( + [ + isinstance(r, slice) and isinstance(c, slice) + for r, c in zip(rows, cols) + ] + ): + raise ValueError( + f"if appending pairs of rows & cols, each row and column pair must be a slice, you passed: {d}" + ) + for k in keys: + self._selection.setdefault(k, list()).append(d[k]) + + self._update_all_graphics() + self._emit({"value": self.selection}) + + def remove(self, dict_or_index: dict | int) -> None: + """ + In options mode: ``remove(index)``: remove an option index from the selection. + In free mode: ``remove(key, list_index=-1)``: remove one item from the selection dict. + """ + if self._selection_options is not None: + # options mode + index = dict_or_index + if not isinstance(index, Integral): + raise TypeError( + f"must provide integer index to append to selection " + f"in 'options' mode, you passed: {dict_or_index!r}" + ) + if index in self._selected_indices: + self._selected_indices.remove(index) + self._update_all_graphics() + self._emit({"value": self.selection}) + else: + d = dict_or_index + keys = list(d.keys()) + if any([k not in self._selection for k in keys]): + raise KeyError( + f"You provided keys that are not in the selection.\nkeys: {keys}\nselection: {self._selection}" + ) + + for k in keys: + for item in d[k]: + self._selection[k].remove(item) + if len(self._selection[k]) < 1: + del self._selection[k] + + self._update_all_graphics() + self._emit({"value": self.selection}) + + def clear(self) -> None: + """Clear the selection (options mode: deselects all, free mode: clears all regions).""" + if self._selection_options is not None: + # options mode + self._selected_indices = list() + else: + self._selection = dict() + self._update_all_graphics() + self._emit({"value": self.selection}) + + def _check_graphic(self, graphic) -> None: + mat = getattr(graphic, "_material", None) + if not isinstance(mat, HighlightableImageMaterial): + raise TypeError( + f"ImageHighlightSelector requires HighlightableImageMaterial, " + f"got {type(mat).__name__}." + ) + + def _create_mask_texture(self, mask: np.ndarray) -> pygfx.Texture: + rows, cols = mask.shape + texture = pygfx.Texture( + size=(cols, rows, 1), # initialize with size, no local cpu buffer + dim=2, + format="r16uint", + usage=wgpu.TextureUsage.COPY_DST, + ) + # send initialized data directly to GPU + texture.send_data((0, 0, 0), mask) + return texture + + def _create_mask(self, n_rows: int, n_cols: int) -> np.ndarray: + """create uint16 mask array for the current selection""" + mask = np.zeros((n_rows, n_cols), dtype=np.uint16) + sel = ( + self._selection_options + if self._selection_options is not None + else self._selection + ) + if "rows" in sel and "cols" in sel: + if len(sel["rows"]) != len(sel["cols"]): + raise ValueError( + f"'rows' and 'cols' must have the same length when both given " + f"({len(sel['rows'])} vs {len(sel['cols'])})" + ) + # start=1 since 0 indicates unselected placeholder value + for i, (rs, cs) in enumerate(zip(sel["rows"], sel["cols"]), start=1): + if rs is None or cs in None: + continue + mask[rs, cs] = i + elif "rows" in sel: + for i, rs in enumerate(sel["rows"], start=1): + if rs is None: + continue + mask[rs, :] = i + elif "cols" in sel: + for i, cs in enumerate(sel["cols"], start=1): + if cs in None: + continue + mask[:, cs] = i + elif "pixels" in sel: + for i, px in enumerate(sel["pixels"], start=1): + if px is None: + continue + arr = np.asarray(px) + mask[arr[:, 0], arr[:, 1]] = i + return mask + + def _fill_lut(self) -> None: + """Write current highlight colors into the LUT buffer.""" + lut_buffer = self._lut_buffer.data + lut_buffer[:] = 0.0 + + if self._selection_options is not None: + n_placeholder = self._len_dict(self._selection_options) + # reset all the options to the unselected placeholder color + lut_buffer[:n_placeholder] = self._rgba( + self._options_color, self._options_alpha + ) + n_sel = len(self._selected_indices) + if n_sel > 0: + current_lut = _build_lut( + color=self._color, lut=self._lut, n=n_sel, lut_wrap=self._lut_wrap + ) + current_lut[:, -1] *= self._alpha + for i, sel in enumerate(self._selected_indices): + if sel is None: + continue + lut_buffer[sel] = current_lut[i] + else: + n = self._len_dict(self._selection) + if n > 0: + current_lut = _build_lut( + color=self._color, lut=self._lut, n=n, lut_wrap=self._lut_wrap + ) + current_lut[:, 3] *= self._alpha + lut_buffer[:n] = current_lut + + self._lut_buffer.update_full() + + def _update_highlight_buffers(self, graphic) -> None: + # Called once per graphic on add_graphic. Set selector buffers onto + # the material. Subsequent graphics just get references to the same objects. + material = graphic._material + material._highlight_lut_buffer = self._lut_buffer + + if self._mask_texture is None: + n_rows, n_cols = graphic.data.value.shape[:2] + self._mask_texture = self._create_mask_texture( + self._create_mask(n_rows, n_cols) + ) + self._fill_lut() + + material._highlight_mask_texture = self._mask_texture + material.uniform_buffer.data["highlight_alpha"] = 1.0 + material.uniform_buffer.update_range() + + def _update_all_graphics(self) -> None: + if not self._graphics: + return + + shapes = {g.data.value.shape[:2] for g in self._graphics} + if len(shapes) > 1: + raise ValueError( + f"All targeted Image data must have the same shape, your images have shapes: {shapes}" + ) + + n_rows, n_cols = self._graphics[0].data.value.shape[:2] + mask = self._create_mask(n_rows, n_cols) + + # Re-create GPU texture if shape changed + if self._mask_texture is None or self._mask_texture.size != (n_cols, n_rows, 1): + self._mask_texture = self._create_mask_texture(mask) + for g in self._graphics: + g._material._highlight_mask_texture = self._mask_texture + else: + # just send the new data + self._mask_texture.send_data((0, 0, 0), mask) + + self._fill_lut() + # uniform_buffer is per-material and cannot be shared + for g in self._graphics: + g._material.uniform_buffer.data["highlight_alpha"] = 1.0 + g._material.uniform_buffer.update_range() + + def _clear_highlight_buffers(self, graphic) -> None: + # Restore the detached material to minimal self-owned placeholders + # this is done when a graphic is removed from the selector + mat = graphic._material + mat._highlight_mask_texture = pygfx.Texture( + np.zeros((1, 1), dtype=np.uint16), dim=2 + ) + mat._highlight_lut_buffer = pygfx.Buffer(np.zeros((1, 4), dtype=np.float32)) + + def __len__(self) -> int: + if self._selection_options is not None: + return len(self._selected_indices) + + return self._len_dict(self._selection) + + def __contains__(self, item: int | dict) -> bool: + if self._selection_options is not None: + return int(item) in self._selected_indices + + # check if a single row-col pair is in the selection + if "rows" in item and "cols" in item: + if ( + item["rows"] in self._selection["rows"] + and item["cols"] in self._selection["cols"] + ): + return True + + # check for basic membership + if "rows" in item: + return item["rows"] in self._selection["rows"] + if "cols" in item: + return item["cols"] in self._selection["col"] + if "pixels" in item: + return item["pixels"] in self._selection["pixels"] + + def __iter__(self): + if self._selection_options is not None: + return iter(self._selected_indices) + + return iter(self._selection.values()) + + def __repr__(self) -> str: + if self._selection_options is not None: + # options mode + return ( + f"ImageHighlightSelector\n" + f"selected: {self._selected_indices}\n" + f"options: {self._selection_options}\n" + ) + + return f"ImageHighlightSelector\n" f"selection: {self._selection}, " diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 0c956d57b..f652a3d9e 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -27,7 +27,7 @@ def selection(self) -> float: return self._selection.value @selection.setter - def selection(self, value: int): + def selection(self, value: float): graphic = self._parent if isinstance(graphic, GraphicCollection): @@ -45,10 +45,8 @@ def limits(self, values: tuple[float, float]): # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): raise TypeError("limits must be an iterable of two numeric values") - self._limits = tuple( - map(round, values) - ) # if values are close to zero things get weird so round them - self.selection._limits = self._limits + self._limits = np.asarray(values) # if values are close to zero things get weird so round them + self._selection._limits = self._limits @property def edge_color(self) -> pygfx.Color: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 70a8dffa8..10dcfdc3e 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -341,6 +341,12 @@ def get_selected_data( """ source = self._get_source(graphic) + + if source.data.value is None: + raise ValueError( + "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." + ) + ixs = self.get_selected_indices(source) if "Line" in source.__class__.__name__: @@ -472,9 +478,9 @@ def _move_graphic(self, move_info: MoveInfo): 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)) + self._selection.set_value(self, (new_min, cur_max), change="min") 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)) + self._selection.set_value(self, (cur_min, new_max), change="max") diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py index e02c627ac..5a05bc886 100644 --- a/fastplotlib/graphics/selectors/_polygon.py +++ b/fastplotlib/graphics/selectors/_polygon.py @@ -200,6 +200,12 @@ def get_selected_data( view or list of views of the full array, returns empty array if selection is empty """ source = self._get_source(graphic) + + if source.data.value is None: + raise ValueError( + "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." + ) + ixs = self.get_selected_indices(source) # do not need to check for mode for images, because the selector is bounded by the image shape diff --git a/fastplotlib/graphics/selectors/_protocols.py b/fastplotlib/graphics/selectors/_protocols.py new file mode 100644 index 000000000..f6fc375df --- /dev/null +++ b/fastplotlib/graphics/selectors/_protocols.py @@ -0,0 +1,30 @@ +from collections.abc import Callable +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class SelectorProtocol(Protocol): + @property + def selection(self): ... + + @selection.setter + def selection(self, new): ... + + def add_event_handler(self, handler: Callable): ... + + def remove_event_handler(self, handler: Callable): ... + + +@runtime_checkable +class MultiSelectorProtocol(SelectorProtocol, Protocol): + def append(self, item): ... + + def remove(self, item): ... + + def clear(self): ... + + def __len__(self): ... + + def __contains__(self, item): ... + + def __iter__(self): ... diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py index e30165dae..f15f292f8 100644 --- a/fastplotlib/graphics/selectors/_rectangle.py +++ b/fastplotlib/graphics/selectors/_rectangle.py @@ -381,6 +381,12 @@ def get_selected_data( view or list of views of the full array, returns empty array if selection is empty """ source = self._get_source(graphic) + + if source.data.value is None: + raise ValueError( + "Cannot get selected data. The graphic has no local buffer, `cpu_buffer` is probably `False`." + ) + ixs = self.get_selected_indices(source) # do not need to check for mode for images, because the selector is bounded by the image shape diff --git a/fastplotlib/graphics/selectors/_selection_vector.py b/fastplotlib/graphics/selectors/_selection_vector.py new file mode 100644 index 000000000..c03de019b --- /dev/null +++ b/fastplotlib/graphics/selectors/_selection_vector.py @@ -0,0 +1,168 @@ +from collections.abc import Callable +from functools import partial +from typing import Any, Sequence, TypeAlias +from numbers import Integral + +import numpy as np + +from ._protocols import SelectorProtocol, MultiSelectorProtocol + +Mapping = np.ndarray | dict[int, int] | Callable + +def identity(val: Any) -> Any: + return val + +def array_map(arr: np.ndarray, index: Integral): + """ + Used to map local to global indices + """ + return None if np.isnan(arr[index]) else int(arr[index]) + +def inv_array_map(arr: np.ndarray, + value: int) -> None | int: + """ + arr[i] gives the global index + """ + x = np.flatnonzero(arr == value) + return None if x.size == 0 else int(x[0]) + +def dict_map(my_dict: dict, key: Integral): + if key is None: + return None + elif int(key) not in my_dict: + return None + else: + return my_dict[key] + + +class SelectionVector: + """ + A class for performing coordinated selections across multiple selectors. + For each selector in the selection vector, the user specifies how the global indices (shared across selectors) + maps to the local indices (each selector has its own local index space). + + The SelectionVector coordinates across individual selectors, including the coordinated updating of indices whenever a selection changes + """ + def __init__(self): + # selector -> (map, map_inv) + + ## Key is a selector, value is a (1) local to global index map (2) global to local index map (3) list of event handlers + self._selectors: dict[ + SelectorProtocol | MultiSelectorProtocol, tuple[Callable, Callable, list[Callable]] + ] = dict() + self._selection: list[Any] = list() + self._block_reentrance = False + + @property + def selection(self) -> tuple[Any]: + return tuple(self._selection) + + @selection.setter + def selection(self, new: Integral | Sequence[Any]): + if self._block_reentrance: + return + else: + self._block_reentrance = True + if isinstance(new, Integral): + new = [new] + self._selection = list(new) + for value in new: + if value < 0: + raise ValueError("Only nonnegative selection indices are allowed") + # iterate through each selector that operates in its own "local" space + for selector_local, (map_, map_inv, handler) in self._selectors.items(): + local_indices = [] + for value in new: + curr_indices = map_(value) + local_indices.append(curr_indices) + selector_local.selection = local_indices + self._block_reentrance = False + + def append(self, index): + self._selection.append(index) + for selector, (map_, map_inv, handler_list) in self._selectors.items(): + if not isinstance(selector, MultiSelectorProtocol): + continue + + index_local = map_(index) + selector.append(index_local) + + def add_selector( + self, + new: ( + SelectorProtocol + | tuple[SelectorProtocol, dict] + | tuple[SelectorProtocol, np.ndarray] + |tuple[SelectorProtocol, Callable, Callable] + ), + ): + """ + User specifies (1) the selector and (2) The master --> local index mapping. This + mapping is given either as: + - A 1D np.ndarray of integers. The array index is the global index, and the array value is the local index + - A dictionary where keys (master indices) and values (local indices) are both integers + - Two callables. The first callable defines the global index --> local index map, the second specifies the local index --> global index map. + All callables take as input nonnegative integers and output nonnegative integers. + """ + if isinstance(new, (tuple, list)): + if not isinstance(new[0], SelectorProtocol): + raise TypeError + + if len(new) == 3: + if isinstance(new[1], Callable) and isinstance(new[2], Callable): + master_to_local = new[1] + local_to_master = new[2] + else: + raise ValueError(f"Both index mappings must be Callables, you provided {type(new[1])} and {type(new[2])}") + elif len(new) == 2: + if isinstance(new[1], dict): + ## Construct inverse mapping + inverse_dict = dict() + for key, val in new[1].items(): + inverse_dict[int(val)] = int(key) + master_to_local = partial(dict_map, new[1]) + local_to_master = partial(dict_map, inverse_dict) + + elif isinstance(new[1], np.ndarray): + if not new[1].ndim == 1: + raise ValueError("If you pass in an array mapping, it must be 1-D") + master_to_local = partial(array_map, new[1]) + local_to_master = partial(inv_array_map, new[1]) + else: + raise ValueError(f"Must either provide a single dict or numpy array specifying the local to global index mapping, or two callables" + f"specifying the mapping in both directions") + + selector = new[0] + + elif isinstance(new, SelectorProtocol): + selector, master_to_local, local_to_master = new, identity, identity + + else: + raise ValueError + + handler = selector.add_event_handler(partial(self._inv_handler, local_to_master)) + self._selectors[selector] = (master_to_local, local_to_master, [handler]) + + def _inv_handler(self, map_inv: Callable, local_selection: dict): + """ + HighlightSelector and VisibilitySelector emit a dictionary with keys selector and value + """ + input_to_map = local_selection['value'] + for i in range(len(input_to_map)): + if isinstance(input_to_map[i], Integral) and input_to_map[i] < 0: + raise ValueError("You can only provide nonnegative values as local indices to a selector") + + self.selection = [map_inv(input_to_map[i]) for i in range(len(input_to_map))] + + def remove_selector(self, selector: SelectorProtocol | MultiSelectorProtocol): + if selector in self._selectors: + map, map_inv, handler_list = self._selectors.pop(selector) + for handler in handler_list: + selector.remove_event_handler(handler) + if isinstance(selector, MultiSelectorProtocol): + selector.clear() + + def clear_selectors(self): + for selector in self._selectors.keys(): + if isinstance(selector, MultiSelectorProtocol): + selector.clear() \ No newline at end of file diff --git a/fastplotlib/graphics/selectors/_selector_collection.py b/fastplotlib/graphics/selectors/_selector_collection.py new file mode 100644 index 000000000..0386cd7bd --- /dev/null +++ b/fastplotlib/graphics/selectors/_selector_collection.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from numbers import Integral +from typing import Callable +from warnings import warn + +import pygfx + +from .._base import Graphic +from ._base_selector import BaseSelector +from ._linear import LinearSelector +from ._linear_region import LinearRegionSelector +from ._polygon import PolygonSelector +from ._rectangle import RectangleSelector + + +_SELECTOR_TYPES = (LinearSelector, LinearRegionSelector, RectangleSelector, PolygonSelector) + + +class SelectorCollection(Graphic): + """ + Dynamically-sized collection of same-type selectors on a shared parent graphic. + + Do not instantiate directly; use a concrete subclass such as + ``RectangleSelectors``. + + ``selection`` is a list of each child selector's ``selection`` value in + append order. Assigning to it resizes the collection as needed. shorter + lists remove tail selectors, longer lists create new ones. + + Parameters + ---------- + parent : Graphic + Parent graphic forwarded to every child selector. + selection : list, optional + Initial selection values. + name : str, optional + **selector_kwargs + Forwarded verbatim to each child selector on creation. + """ + + _selector_type: type = None + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + t = getattr(cls, "_selector_type", None) + if t is not None and t not in _SELECTOR_TYPES: + raise TypeError( + f"{cls.__name__}._selector_type must be one of " + f"{[c.__name__ for c in _SELECTOR_TYPES]}, got {t!r}" + ) + + def __init__( + self, + parent: Graphic, + selection: list | None = None, + name: str = None, + **selector_kwargs, + ): + if type(self)._selector_type is None: + raise TypeError( + f"{type(self).__name__} cannot be instantiated directly; " + "use a concrete subclass." + ) + super().__init__(name=name) + self._set_world_object(pygfx.Group()) + self._parent_graphic = parent + self._selector_kwargs = selector_kwargs + self._selectors: list[BaseSelector] = [] + self._event_handlers: list[Callable] = [] + + if selection is not None: + self.selection = selection + + # ------------------------------------------------------------------ hooks + + def _fpl_add_plot_area_hook(self, plot_area): + super()._fpl_add_plot_area_hook(plot_area) + for sel in self._selectors: + sel._fpl_add_plot_area_hook(plot_area) + + def _fpl_prepare_del(self): + for sel in list(self._selectors): + sel._fpl_prepare_del() + self.world_object.remove(sel.world_object) + self._selectors.clear() + super()._fpl_prepare_del() + + # ------------------------------------------------------------------ selection + + @property + def selection(self) -> list: + """Child selector selections in append order.""" + return [s.selection for s in self._selectors] + + @selection.setter + def selection(self, values: list) -> None: + n_old = len(self._selectors) + for sel, val in zip(self._selectors, values): + sel.selection = val + while len(self._selectors) > len(values): + self._remove_selector(-1) + for val in values[n_old:]: + self._append_selector(val) + self._emit({"value": self.selection}) + + # ------------------------------------------------------------------ public + + def append(self, selection) -> BaseSelector: + """Create a new child selector and return it.""" + sel = self._append_selector(selection) + self._emit({"value": self.selection}) + return sel + + def remove(self, item: int | BaseSelector) -> None: + """Remove a child selector by index or reference.""" + self._remove_selector(item) + self._emit({"value": self.selection}) + + def clear(self) -> None: + """Remove all child selectors.""" + while self._selectors: + self._remove_selector(-1) + self._emit({"value": []}) + + # ------------------------------------------------------------------ internal + + def _append_selector(self, selection) -> BaseSelector: + sel = self._selector_type( + selection=selection, + parent=self._parent_graphic, + **self._selector_kwargs, + ) + self.world_object.add(sel.world_object) + self._selectors.append(sel) + if self._plot_area is not None: + sel._fpl_add_plot_area_hook(self._plot_area) + return sel + + def _remove_selector(self, item: int | BaseSelector) -> None: + sel = self._selectors[item] if isinstance(item, Integral) else item + sel._fpl_prepare_del() + self.world_object.remove(sel.world_object) + self._selectors.remove(sel) + + # ------------------------------------------------------------------ events + + def add_event_handler(self, handler: Callable) -> None: + """Register a callback fired on any selection change.""" + if not callable(handler): + raise TypeError("event handler must be callable") + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + # ------------------------------------------------------------------ dunder + + def __getitem__(self, index: int) -> BaseSelector: + return self._selectors[index] + + def __len__(self) -> int: + return len(self._selectors) + + def __contains__(self, item) -> bool: + return item in self._selectors + + def __iter__(self): + return iter(self._selectors) + + def __repr__(self) -> str: + n = len(self._selectors) + s = f"{self.__class__.__name__}(n={n})" + if self.name: + s = f"'{self.name}': {s}" + return s + + +class LinearSelectors(SelectorCollection): + """ + Collection of :class:`.LinearSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float] + ``(min, max)`` bounds on the selector axis. + selection : list[float], optional + Initial selector positions. + axis : "x" or "y" + edge_color : color + thickness : float + arrow_keys_modifier : str + extra_width : float + name : str, optional + """ + + _selector_type = LinearSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float], + selection: list[float] | None = None, + *, + axis: str = "x", + edge_color="yellow", + thickness: float = 1.0, + arrow_keys_modifier: str = "Shift", + extra_width: float = 14.0, + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + axis=axis, + edge_color=edge_color, + thickness=thickness, + arrow_keys_modifier=arrow_keys_modifier, + extra_width=extra_width, + ) + + +class LinearRegionSelectors(SelectorCollection): + """ + Collection of :class:`.LinearRegionSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float] + ``(min, max)`` range the selector can occupy. + size : float + Extent of each region box along the axis orthogonal to ``axis``. + center : float + Centre of each box along the orthogonal axis. + selection : list[tuple[float, float]], optional + Initial ``(min, max)`` pairs. + axis : "x" or "y" + resizable : bool + fill_color : color + edge_color : color + edge_thickness : float + arrow_keys_modifier : str + extra_width : float + name : str, optional + """ + + _selector_type = LinearRegionSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float], + size: float, + center: float, + selection: list | None = None, + *, + axis: str = "x", + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color="yellow", + edge_thickness: float = 1.0, + arrow_keys_modifier: str = "Shift", + extra_width: float = 14.0, + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + size=size, + center=center, + axis=axis, + resizable=resizable, + fill_color=fill_color, + edge_color=edge_color, + edge_thickness=edge_thickness, + arrow_keys_modifier=arrow_keys_modifier, + extra_width=extra_width, + ) + + +class RectangleSelectors(SelectorCollection): + """ + Collection of :class:`.RectangleSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float, float, float] + ``(xmin, xmax, ymin, ymax)`` bounds. + selection : list[tuple[float, float, float, float]], optional + Initial ``(xmin, xmax, ymin, ymax)`` rectangles. + resizable : bool + fill_color : color + edge_color : color + edge_thickness : float + vertex_color : color + vertex_size : float + arrow_keys_modifier : str + name : str, optional + """ + + _selector_type = RectangleSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float, float, float], + selection: list | None = None, + *, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, + vertex_color=(0.7, 0.4, 0), + vertex_size: float = 8, + arrow_keys_modifier: str = "Shift", + name: str = None, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + resizable=resizable, + fill_color=fill_color, + edge_color=edge_color, + edge_thickness=edge_thickness, + vertex_color=vertex_color, + vertex_size=vertex_size, + arrow_keys_modifier=arrow_keys_modifier, + ) + + +class PolygonSelectors(SelectorCollection): + """ + Collection of :class:`.PolygonSelector` instances on a shared parent graphic. + + Parameters + ---------- + parent : Graphic + limits : tuple[float, float, float, float] + ``(xmin, xmax, ymin, ymax)`` bounds. + selection : list, optional + Initial polygon vertex lists; each element is a sequence of + ``(x, y)`` or ``(x, y, 0)`` points, or ``None`` for an empty polygon. + resizable : bool + fill_color : color + edge_color : color + edge_thickness : float + vertex_color : color + vertex_size : float + name : str, optional + """ + + _selector_type = PolygonSelector + + def __init__( + self, + parent: Graphic, + limits: tuple[float, float, float, float], + selection: list | None = 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, + ): + super().__init__( + parent, selection, name=name, + limits=limits, + resizable=resizable, + fill_color=fill_color, + edge_color=edge_color, + edge_thickness=edge_thickness, + vertex_color=vertex_color, + vertex_size=vertex_size, + ) diff --git a/fastplotlib/graphics/selectors/_visibility_selector.py b/fastplotlib/graphics/selectors/_visibility_selector.py new file mode 100644 index 000000000..d54f5ba90 --- /dev/null +++ b/fastplotlib/graphics/selectors/_visibility_selector.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +from collections.abc import Iterable +from numbers import Integral +from typing import Callable +from warnings import warn + +import cmap as cmap_lib +import numpy as np + +from .._collection_base import GraphicCollection +from ..shaders._highlight_materials import HighlightableImageMaterial +from ._highlight_selector import _build_lut + +_AXES = {"x": 0, "y": 1, "z": 2} + + +def _validate_int_collection(value, name: str) -> set | int: + if isinstance(value, Integral): + return int(value) + + s = set(value) + + if not all(isinstance(i, Integral) or i is None for i in s): + raise TypeError(f"{name} must contain only integers or None, got: {s!r}") + + return value + + +class VisibilitySelector: + """ + Shows a subset of graphics in a GraphicCollection by toggling their visibility. + + ``selection = list()`` or ``None``: all invisible. + ``selection = [s1, s2, ..., s_n]``: only these indices visible + + For ``LineStack`` and ``ScatterStack``, visible graphics are re-stacked + along the stack axis when the selection changes. + + If a ``lut`` is provided, each visible graphic is colored by its position in + the selection + + Parameters + ---------- + collection : GraphicCollection + selection : list[int] or None + Initial selection. + + lut : str or array-like of shape (n, 4), optional + color or stack of RGBA arrays + + lut_wrap : "fixed" or "repeat" + How to handle selection indices beyond the end of the lut. + """ + + def __init__( + self, + collection: GraphicCollection, + selection: list[int] | None = None, + lut: str | np.ndarray | None = None, + lut_wrap: str = "fixed", + ): + if not isinstance(collection, GraphicCollection): + raise TypeError( + f"VisibilitySelector requires a GraphicCollection, " + f"got {type(collection).__name__}." + ) + if lut_wrap not in ("fixed", "repeat"): + raise ValueError(f"lut_wrap must be 'fixed' or 'repeat', got {lut_wrap!r}") + + self._collection = collection + self._selection: list[int | None] = [] + self._event_handlers: list[Callable] = [] + self._lut_wrap = lut_wrap + + self._lut = lut + + # save original colors so they can be restored when this selector is deleted + self._original_colors: dict[int, np.ndarray] = {} + for i, g in enumerate(collection.graphics): + c = g.colors + if hasattr(c, "value"): + self._original_colors[i] = np.asarray(c.value, dtype=np.float32).copy() + else: + self._original_colors[i] = np.asarray(c, dtype=np.float32).copy() + + self._is_stack = hasattr(collection, "separation") + if self._is_stack: + self._sep_axis = collection.separation_axis + ax_i = _AXES[self._sep_axis] + self._data_ranges = np.array( + [float(g.data.value[:, ax_i].max()) for g in collection.graphics] + ) + + for g in collection.graphics: + g.visible = False + + if selection is not None and len(selection) > 0: + self.selection = selection + + def __del__(self): + for g in self._collection.graphics: + g.visible = True + + if self._lut is None: + return + + for i, g in enumerate(self._collection.graphics): + g.colors = self._original_colors[i] + + @property + def selection(self) -> tuple[int | None, ...]: + """Get or set the selection""" + return tuple(self._selection) + + @selection.setter + def selection(self, new_selection: Iterable[int | None] | int): + if new_selection: + _validate_int_collection(new_selection, "selection") + + for index in self._selection: + if index is None: + continue + # set any selected things to be invisible + self._collection.graphics[index].visible = False + + if isinstance(new_selection, Integral): + new_selection = [new_selection] + + self._selection = list(new_selection) if new_selection else list() + + for index in self._selection: + if index is None: + continue + # set the new selection to be visible + self._collection.graphics[index].visible = True + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": tuple(self._selection)}) + + def append(self, item: int): + """Add an index to the selection. Already-selected indices are skipped.""" + if not isinstance(item, Integral) and item is not None: + raise TypeError(f"item must be an integer or None, got {type(item)}") + + if item in self._selection and item is not None: + return + + if item is not None: + self._collection.graphics[item].visible = True + + self._selection.append(item) + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": tuple(self._selection)}) + + def remove(self, item: int): + """Remove an index from the selection.""" + if not isinstance(item, Integral): + raise TypeError(f"item must be an integer, got {type(item).__name__}") + + if item not in self._selection: + return + + self._collection.graphics[item].visible = False + self._selection.remove(item) + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": list(self._selection)}) + + def pop(self, index: int): + """pop item at the given index""" + + if not isinstance(index, Integral): + raise TypeError( + f"pop argument must be an integer, got: {type(index).__name__}" + ) + + if index >= len(self): + raise IndexError( + f"index: {index} out of bounds for {self.__class__.__name__} with length: {len(self)}" + ) + + item = self._selection[index] + if item is not None: + self._collection.graphics[item].visible = False + + self._selection.pop(index) + + if self._is_stack: + self._restack() + + self._apply_lut() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Hide all graphics. Stack offsets are left as-is.""" + for idx in self._selection: + if idx is None: + continue + self._collection.graphics[idx].visible = False + + self._selection = list() + self._emit({"value": []}) + + @property + def lut(self) -> np.ndarray | None: + """Optional per-item colors, shape ``(n, 4)`` float32 RGBA""" + return self._lut + + @lut.setter + def lut(self, value: str | np.ndarray | None) -> None: + self._lut = value + self._apply_lut() + + @property + def lut_wrap(self) -> str: + """LUT wrap mode: ``'fixed'`` or ``'repeat'``.""" + return self._lut_wrap + + def _apply_lut(self) -> None: + if self._lut is None or not self._selection: + return + + colors = _build_lut( + color=None, lut=self._lut, n=len(self._selection), lut_wrap=self._lut_wrap + ) + for sel_index, graphic_index in enumerate(self._selection): + if graphic_index is None: + continue + self._collection.graphics[graphic_index].colors = colors[sel_index] + + def _restack(self) -> None: + sep = self._collection.separation + ax_i = _AXES[self._sep_axis] + + distance = 0.0 + for index in self._selection: + if index is None: + continue + + g = self._collection.graphics[index] + offset = list(g.offset) + offset[ax_i] = distance + g.offset = tuple(offset) + distance += self._data_ranges[index] + sep + + def add_event_handler(self, handler: Callable) -> None: + """Register a callback fired when the selection changes.""" + if not callable(handler): + raise TypeError("event handler must be callable") + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return item in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + return f"VisibilitySelector\n" f"selection: {self._selection}" + + +class ImageVisibilitySelector: + """ + Shows a subset of rows or columns of an ``ImageGraphic`` via GPU shader remapping. + + Selected rows/columns are rendered as a compact stack with no gaps. Non-selected + rows/columns are discarded in the fragment shader. + + Requires ``HighlightableImageMaterial`` and ``interpolation='nearest'``. + + Can be combined with ``ImageHighlightSelector`` on the same graphic; highlight + indices always refer to original source coordinates regardless of visibility state. + + Parameters + ---------- + graphic : ImageGraphic + axis : "rows" or "cols" + Axis to subset. + selection : list[int] or None + Initial selection. + """ + + def __init__(self, graphic, axis: str = "rows", selection: list[int] | None = None): + if axis not in ("rows", "cols"): + raise ValueError(f"axis must be 'rows' or 'cols', got {axis!r}") + + mat = getattr(graphic, "_material", None) + if not isinstance(mat, HighlightableImageMaterial): + raise TypeError( + "ImageVisibilitySelector requires HighlightableImageMaterial, " + f"got {type(mat).__name__}." + ) + + if graphic.interpolation != "nearest": + raise ValueError( + "ImageVisibilitySelector requires interpolation='nearest'; " + f"got {graphic.interpolation!r}. Set graphic.interpolation = 'nearest' first." + ) + + tiles = list(graphic.world_object.children) + if len(tiles) != 1: + raise ValueError( + f"ImageVisibilitySelector only supports single-tile images, " + f"got {len(tiles)} tiles." + ) + + self._graphic = graphic + self._tile = tiles[0] + self._axis = axis + self._selection: list[int] = list() + self._event_handlers: list[Callable] = list() + + mat.uniform_buffer.data["fpl_vis_axis_y"] = np.uint32( + 1 if axis == "rows" else 0 + ) + mat.uniform_buffer.data["fpl_n_visible"] = np.uint32(0) + mat.uniform_buffer.update_range() + + if selection is not None and len(selection) > 0: + self.selection = selection + + @property + def axis(self) -> str: + """ + 'rows' or 'cols' + """ + return self._axis + + @property + def selection(self) -> tuple[int, ...]: + """Get or set row/col selection indices""" + return tuple(self._selection) + + @selection.setter + def selection(self, value: Iterable[int]): + if value: + _validate_int_collection(value, "selection") + + if isinstance(value, Integral): + value = [value] + + self._selection = list(value) if value else list() + + self._update_material() + self._emit({"value": tuple(self._selection)}) + + def append(self, item: int | None): + """add a row/col index to the selection""" + if not isinstance(item, Integral) and item is not None: + raise TypeError(f"item must be an integer or None, got {type(item)}") + + if item in self._selection and item is not None: + return + + self._selection.append(item) + + self._update_material() + self._emit({"value": list(self._selection)}) + + def remove(self, item) -> None: + """Remove a row/col index from the selection.""" + if not isinstance(item, Integral): + raise TypeError(f"item must be an integer, got {type(item)}") + + if item not in self._selection: + return + + self._selection.remove(item) + self._update_material() + self._emit({"value": list(self._selection)}) + + def pop(self, index: int): + """pop item at the given index""" + if not isinstance(index, Integral): + raise TypeError( + f"pop argument must be an integer, got: {type(index).__name__}" + ) + + if index >= len(self): + raise IndexError( + f"index: {index} out of bounds for {self.__class__.__name__} with length: {len(self)}" + ) + + self._selection.pop(index) + self._update_material() + self._emit({"value": list(self._selection)}) + + def clear(self) -> None: + """Clear the selection (all invisible, fpl_n_visible=0).""" + self._selection = list() + self._update_material() + self._emit({"value": list()}) + + def _update_material(self) -> None: + mat = self._graphic._material + n = len(self._selection) + if n > 0: + mat._vis_lut_buffer.data[:n] = np.array( + list( + map( + # 0xFFFFFFFF, 2^32 - 1, indicates None vals and shader discard + lambda x: x if x is not None else np.uint32(0xFFFFFFFF), + self._selection, + ) + ), + dtype=np.uint32, + ) + + mat._vis_lut_buffer.update_range() + mat.uniform_buffer.data["fpl_n_visible"] = np.uint32(n) + mat.uniform_buffer.update_range() + + self._update_bbox() + + def _update_bbox(self) -> None: + data = self._graphic.data.value + n_total = data.shape[0] if self._axis == "rows" else data.shape[1] + + n_visible = len(self._selection) + ax_i = 1 if self._axis == "rows" else 0 + self._graphic.world_object.children[0]._vis_scale = ( + ax_i, + n_visible / n_total if n_total > 0 else 0.0, + ) + + def add_event_handler(self, handler: Callable) -> None: + """register an event handler that is called when the selection changes""" + if not callable(handler): + raise TypeError("event handler must be callable") + + if handler in self._event_handlers: + warn(f"{handler} is already registered.") + return + + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable) -> None: + if handler not in self._event_handlers: + raise KeyError(f"{handler} is not registered.") + + self._event_handlers.remove(handler) + + def _emit(self, info: dict) -> None: + for h in self._event_handlers: + h({"selector": self, **info}) + + def __len__(self) -> int: + return len(self._selection) + + def __contains__(self, item) -> bool: + return item in self._selection + + def __iter__(self): + return iter(self._selection) + + def __repr__(self) -> str: + data = self._graphic.data.value + return ( + f"ImageVisibilitySelector\n" + f"axis: {self._axis}\n" + f"selection: {self._selection}\n" + ) diff --git a/fastplotlib/graphics/shaders/__init__.py b/fastplotlib/graphics/shaders/__init__.py new file mode 100644 index 000000000..43b13147a --- /dev/null +++ b/fastplotlib/graphics/shaders/__init__.py @@ -0,0 +1,15 @@ +from ._highlight_shaders import ( + HighlightableLineShader, + HighlightableThinLineShader, + HighlightablePointsShader, + HighlightableImageShader, +) +from ._highlight_materials import ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, + HighlightableImageMaterial, +) diff --git a/fastplotlib/graphics/shaders/_highlight_materials.py b/fastplotlib/graphics/shaders/_highlight_materials.py new file mode 100644 index 000000000..b90f16a3a --- /dev/null +++ b/fastplotlib/graphics/shaders/_highlight_materials.py @@ -0,0 +1,102 @@ +import numpy as np +import pygfx +from pygfx.resources import Buffer, Texture + +_HIGHLIGHT_UNIFORM_FIELDS = dict(highlight_alpha="f4") + +_IMAGE_HIGHLIGHT_UNIFORM_FIELDS = dict( + highlight_alpha="f4", + fpl_n_visible="u4", # 0 = visibility disabled; >0 = number of visible rows/cols + fpl_vis_axis_y="u4", # 1 = rows (y-axis), 0 = cols (x-axis) +) + + +class HighlightableLineMaterial(pygfx.LineMaterial): + uniform_type = dict(pygfx.LineMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightableLineThinMaterial(pygfx.LineThinMaterial): + uniform_type = dict(pygfx.LineThinMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsMaterial(pygfx.PointsMaterial): + uniform_type = dict(pygfx.PointsMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsMarkerMaterial(pygfx.PointsMarkerMaterial): + uniform_type = dict(pygfx.PointsMarkerMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsSpriteMaterial(pygfx.PointsSpriteMaterial): + uniform_type = dict(pygfx.PointsSpriteMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightablePointsGaussianBlobMaterial(pygfx.PointsGaussianBlobMaterial): + uniform_type = dict( + pygfx.PointsGaussianBlobMaterial.uniform_type, **_HIGHLIGHT_UNIFORM_FIELDS + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._highlight_ids_buffer = Buffer(np.zeros(1, dtype=np.uint32)) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.update_range() + + +class HighlightableImageMaterial(pygfx.ImageBasicMaterial): + uniform_type = dict(pygfx.ImageBasicMaterial.uniform_type, **_IMAGE_HIGHLIGHT_UNIFORM_FIELDS) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Store through _store so the PropTracker detects replacement and re-calls get_bindings(). + self._store.highlight_mask_texture = Texture(np.zeros((1, 1), dtype=np.uint16), dim=2) + self._highlight_lut_buffer = Buffer(np.zeros((1, 4), dtype=np.float32)) + self._vis_lut_buffer = Buffer(np.zeros(65535, dtype=np.uint32)) + self.uniform_buffer.data["highlight_alpha"] = 1.0 + self.uniform_buffer.data["fpl_n_visible"] = np.uint32(0) + self.uniform_buffer.data["fpl_vis_axis_y"] = np.uint32(1) + self.uniform_buffer.update_range() + + @property + def _highlight_mask_texture(self): + return self._store.highlight_mask_texture + + @_highlight_mask_texture.setter + def _highlight_mask_texture(self, texture): + self._store.highlight_mask_texture = texture diff --git a/fastplotlib/graphics/shaders/_highlight_shaders.py b/fastplotlib/graphics/shaders/_highlight_shaders.py new file mode 100644 index 000000000..6b114bf7a --- /dev/null +++ b/fastplotlib/graphics/shaders/_highlight_shaders.py @@ -0,0 +1,376 @@ +""" +Highlightable shader subclasses for fastplotlib. + +Each shader subclass: + 1. Adds two extra bindings: s_highlight_ids (u32 storage) and s_highlight_lut (vec4 storage), + or t_highlight_mask (R8Uint texture) + s_highlight_lut for images. + 2. Patches the compiled WGSL to mix a highlight color into out.color before returning, + leaving out.pick completely untouched. + +Anchor strings are validated at runtime; a warning is emitted and highlighting falls back +to a no-op if a pygfx version change has moved them. +""" + +import warnings + +from pygfx.objects import Points, Line, Image +from pygfx.renderers.wgpu.shaders.pointsshader import PointsShader +from pygfx.renderers.wgpu.shaders.lineshader import LineShader, ThinLineShader +from pygfx.renderers.wgpu.shaders.imageshader import ImageShader +from pygfx.renderers.wgpu import ( + register_wgpu_render_function, + Binding, + GfxTextureView, +) + +from ._highlight_materials import ( + HighlightableLineMaterial, + HighlightableLineThinMaterial, + HighlightablePointsMaterial, + HighlightablePointsMarkerMaterial, + HighlightablePointsSpriteMaterial, + HighlightablePointsGaussianBlobMaterial, + HighlightableImageMaterial, +) + +_POINTS_HELPER = """\ +fn fpl_apply_highlight(base_color: vec4, vertex_idx: u32) -> vec4 { + if (vertex_idx >= arrayLength(&s_highlight_ids)) { return base_color; } + let id = s_highlight_ids[vertex_idx]; + if (id == 0u) { return base_color; } + let h = s_highlight_lut[id - 1u]; + return vec4(mix(base_color.rgb, h.rgb, h.a * u_material.highlight_alpha), base_color.a); +} + +""" + +_THIN_LINE_HELPER = """\ +fn fpl_apply_highlight(base_color: vec4, hl: vec4) -> vec4 { + if (hl.a <= 0.0) { return base_color; } + return vec4(mix(base_color.rgb, hl.rgb, hl.a * u_material.highlight_alpha), base_color.a); +} + +""" + +_LINE_HELPER = """\ +fn fpl_apply_highlight_line( + base_color: vec4, + hl_node: vec4, + hl_vert: vec4, + is_join: bool, + join_coord_lin: f32, + join_coord_fan: f32, +) -> vec4 { + var hl: vec4 = hl_vert; + if (is_join) { + let hl_seg = hl_node - (hl_node - hl_vert) / (1.0 - abs(join_coord_lin)); + hl = mix(hl_seg, hl_node, abs(join_coord_fan)); + } + if (hl.a <= 0.0) { return base_color; } + return vec4(mix(base_color.rgb, hl.rgb, hl.a * u_material.highlight_alpha), base_color.a); +} + +""" + +_IMAGE_HELPER = """\ +fn fpl_apply_highlight_img(base_color: vec4, mask_id: u32) -> vec4 { + if (mask_id == 0u) { return base_color; } + let h = s_highlight_lut[mask_id - 1u]; + return vec4(mix(base_color.rgb, h.rgb, h.a * u_material.highlight_alpha), base_color.a); +} + +""" + +_IMAGE_SAMPLE_ANCHOR = " let value = sample_im(varyings.texcoord.xy, sizef);" + +_IMAGE_VIS_PRE_SAMPLE = """\ + var fpl_texcoord = varyings.texcoord; + if (u_material.fpl_n_visible > 0u) { + let fpl_vis_px = vec2(varyings.texcoord * sizef); + let fpl_vis_idx = select(fpl_vis_px.x, fpl_vis_px.y, u_material.fpl_vis_axis_y == 1u); + if (fpl_vis_idx >= u_material.fpl_n_visible) { discard; } + + // discard Nones which we map to 0xFFFFFFFF + let fpl_src_u = s_vis_lut[fpl_vis_idx]; + if (fpl_src_u == 0xFFFFFFFFu) { discard; } + let fpl_src_f = f32(fpl_src_u); + + if (u_material.fpl_vis_axis_y == 1u) { + fpl_texcoord.y = (fpl_src_f + 0.5) / sizef.y; + } else { + fpl_texcoord.x = (fpl_src_f + 0.5) / sizef.x; + } + } + let value = sample_im(fpl_texcoord.xy, sizef);\ +""" + + +# fragment shader replacement position, same for most shaders +_FS_COLOR_ANCHOR = " out.color = out_color;" + +_THIN_VS_ANCHOR = " return varyings;\n }" +_THIN_FS_COLOR_ANCHOR = " out.color = out_color;" +_THIN_FRAGMENT_ENTRY = " @fragment\n fn fs_main" + + +def _warn_anchor_missing(label: str, anchor: str) -> None: + warnings.warn( + f"fpl highlight: anchor {anchor!r} not found in {label} WGSL. " + "Highlighting disabled for this graphic type. " + "This is likely caused by a pygfx version change, update the anchor string.", + stacklevel=3, + ) + + +def _check(wgsl: str, anchor: str, label: str) -> bool: + if wgsl.count(anchor) != 1: + _warn_anchor_missing(label, anchor) + return False + return True + + +def _add_ids_bindings(shader, group0: dict, material) -> None: + """Append s_highlight_ids and s_highlight_lut bindings to group0.""" + next_idx = max(group0.keys()) + 1 + new = { + next_idx: Binding( + "s_highlight_ids", + "buffer/read_only_storage", + material._highlight_ids_buffer, + "FRAGMENT", + ), + next_idx + + 1: Binding( + "s_highlight_lut", + "buffer/read_only_storage", + material._highlight_lut_buffer, + "FRAGMENT", + ), + } + shader.define_bindings(0, new) + group0.update(new) + + +def _add_ids_bindings_vs_fs(shader, group0: dict, material) -> None: + """Append s_highlight_ids (VERTEX+FRAGMENT) and s_highlight_lut bindings to group0.""" + import wgpu as _wgpu + + vs_fs = _wgpu.ShaderStage.VERTEX | _wgpu.ShaderStage.FRAGMENT + next_idx = max(group0.keys()) + 1 + new = { + next_idx: Binding( + "s_highlight_ids", + "buffer/read_only_storage", + material._highlight_ids_buffer, + vs_fs, + ), + next_idx + + 1: Binding( + "s_highlight_lut", + "buffer/read_only_storage", + material._highlight_lut_buffer, + vs_fs, + ), + } + shader.define_bindings(0, new) + group0.update(new) + + +def _add_mask_bindings(shader, group0: dict, material) -> None: + """Append t_highlight_mask, s_highlight_lut, and s_vis_lut bindings to group0.""" + next_idx = max(group0.keys()) + 1 + mask_view = GfxTextureView(material._highlight_mask_texture) + new = { + next_idx: Binding( + "t_highlight_mask", + "texture/auto", + mask_view, + "FRAGMENT", + ), + next_idx + + 1: Binding( + "s_highlight_lut", + "buffer/read_only_storage", + material._highlight_lut_buffer, + "FRAGMENT", + ), + next_idx + + 2: Binding( + "s_vis_lut", + "buffer/read_only_storage", + material._vis_lut_buffer, + "FRAGMENT", + ), + } + shader.define_bindings(0, new) + group0.update(new) + + +@register_wgpu_render_function(Points, HighlightablePointsMaterial) +@register_wgpu_render_function(Points, HighlightablePointsMarkerMaterial) +@register_wgpu_render_function(Points, HighlightablePointsSpriteMaterial) +@register_wgpu_render_function(Points, HighlightablePointsGaussianBlobMaterial) +class HighlightablePointsShader(PointsShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + _add_ids_bindings(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + if not _check(wgsl, _FS_COLOR_ANCHOR, "points.wgsl"): + return wgsl + if not _check(wgsl, "@fragment\nfn fs_main", "points.wgsl"): + return wgsl + + wgsl = wgsl.replace( + _FS_COLOR_ANCHOR, + " out.color = fpl_apply_highlight(out_color, varyings.pick_idx);", + 1, + ) + wgsl = wgsl.replace( + "@fragment\nfn fs_main", + _POINTS_HELPER + "@fragment\nfn fs_main", + 1, + ) + return wgsl + + +@register_wgpu_render_function(Image, HighlightableImageMaterial) +class HighlightableImageShader(ImageShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + _add_mask_bindings(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + + # Pre-sample: inject visibility LUT remapping. fpl_texcoord is always + # declared here so the highlight block below can safely reference it + # regardless of whether visibility is active + if not _check(wgsl, _IMAGE_SAMPLE_ANCHOR, "image.wgsl sample"): + return wgsl + wgsl = wgsl.replace(_IMAGE_SAMPLE_ANCHOR, _IMAGE_VIS_PRE_SAMPLE, 1) + + # Post-sample: highlight blend. Uses fpl_texcoord (source coords) so that + # highlight indices always refer to original data positions whether or not + # visibility remapping is active. + if not _check(wgsl, _FS_COLOR_ANCHOR, "image.wgsl"): + return wgsl + mask_lines = ( + " let fpl_px = vec2(fpl_texcoord * vec2(textureDimensions(t_highlight_mask)));\n" + " let fpl_mask_id = textureLoad(t_highlight_mask, fpl_px, 0).r;\n" + " out.color = fpl_apply_highlight_img(out_color, fpl_mask_id);" + ) + wgsl = wgsl.replace(_FS_COLOR_ANCHOR, mask_lines, 1) + + if not _check(wgsl, "@fragment\nfn fs_main", "image.wgsl FS entry"): + return wgsl + wgsl = wgsl.replace( + "@fragment\nfn fs_main", + _IMAGE_HELPER + "@fragment\nfn fs_main", + 1, + ) + return wgsl + + +_THIN_VS_INJECTION = ( + " let fpl_hl_id = select(0u, s_highlight_ids[u32(i0)],\n" + " u32(i0) < arrayLength(&s_highlight_ids));\n" + " varyings.fpl_hl_color = select(\n" + " vec4(0.0), s_highlight_lut[fpl_hl_id - 1u], fpl_hl_id != 0u);\n" + " return varyings;\n" + " }" +) + + +@register_wgpu_render_function(Line, HighlightableLineThinMaterial) +class HighlightableThinLineShader(ThinLineShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + # Needs VERTEX stage so the VS can read the ids buffer + _add_ids_bindings_vs_fs(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + + if not _check(wgsl, _THIN_VS_ANCHOR, "ThinLineShader VS"): + return wgsl + wgsl = wgsl.replace(_THIN_VS_ANCHOR, _THIN_VS_INJECTION, 1) + + if not _check(wgsl, _THIN_FS_COLOR_ANCHOR, "ThinLineShader FS"): + return wgsl + wgsl = wgsl.replace( + _THIN_FS_COLOR_ANCHOR, + " out.color = fpl_apply_highlight(out_color, varyings.fpl_hl_color);", + 1, + ) + + if not _check(wgsl, _THIN_FRAGMENT_ENTRY, "ThinLineShader FS entry"): + return wgsl + wgsl = wgsl.replace( + _THIN_FRAGMENT_ENTRY, + _THIN_LINE_HELPER + _THIN_FRAGMENT_ENTRY, + 1, + ) + return wgsl + + +_LINE_VS_ANCHOR = " varyings.pick_idx = u32(node_index);" + +_LINE_VS_INJECTION = """\ + let fpl_other_idx = select(node_index_prev, node_index_next, node_index_is_even); + let fpl_hl_id_node = select(0u, s_highlight_ids[u32(node_index)], + u32(node_index) < arrayLength(&s_highlight_ids)); + let fpl_hl_id_other = select(0u, s_highlight_ids[u32(fpl_other_idx)], + u32(fpl_other_idx) < arrayLength(&s_highlight_ids)); + let fpl_hl_raw_node = select(vec4(0.0), s_highlight_lut[fpl_hl_id_node - 1u], fpl_hl_id_node != 0u); + let fpl_hl_raw_other = select(vec4(0.0), s_highlight_lut[fpl_hl_id_other - 1u], fpl_hl_id_other != 0u); + varyings.fpl_hl_color_node = fpl_hl_raw_node; + varyings.fpl_hl_color_vert = mix(fpl_hl_raw_node, fpl_hl_raw_other, ratio_interp); + varyings.pick_idx = u32(node_index);\ +""" + +_LINE_FS_REPLACEMENT = ( + " out.color = fpl_apply_highlight_line(\n" + " out_color, varyings.fpl_hl_color_node, varyings.fpl_hl_color_vert,\n" + " is_join, join_coord_lin, join_coord_fan);" +) + + +@register_wgpu_render_function(Line, HighlightableLineMaterial) +class HighlightableLineShader(LineShader): + + def get_bindings(self, wobject, shared, scene): + result = super().get_bindings(wobject, shared, scene) + group0 = result[0] + _add_ids_bindings_vs_fs(self, group0, wobject.material) + return {0: group0} + + def get_code(self): + wgsl = super().get_code() + + if not _check(wgsl, _LINE_VS_ANCHOR, "line.wgsl VS"): + return wgsl + wgsl = wgsl.replace(_LINE_VS_ANCHOR, _LINE_VS_INJECTION, 1) + + if not _check(wgsl, _FS_COLOR_ANCHOR, "line.wgsl FS"): + return wgsl + wgsl = wgsl.replace(_FS_COLOR_ANCHOR, _LINE_FS_REPLACEMENT, 1) + + if not _check(wgsl, "@fragment\nfn fs_main", "line.wgsl FS entry"): + return wgsl + wgsl = wgsl.replace( + "@fragment\nfn fs_main", + _LINE_HELPER + "@fragment\nfn fs_main", + 1, + ) + return wgsl diff --git a/fastplotlib/graphics/utils.py b/fastplotlib/graphics/utils.py index 6be5aefc4..0fc1aa088 100644 --- a/fastplotlib/graphics/utils.py +++ b/fastplotlib/graphics/utils.py @@ -1,13 +1,19 @@ from contextlib import contextmanager +from typing import Callable, Iterable, Sequence +import numpy as np + +from ._collection_base import GraphicCollection from ._base import Graphic @contextmanager -def pause_events(*graphics: Graphic): +def pause_events(*graphics: Graphic, event_handlers: Iterable[Callable] = None): """ Context manager for pausing Graphic events. + Optionally pass in only specific event handlers which are blocked. Other events for the graphic will not be blocked. + Examples -------- @@ -30,8 +36,90 @@ def pause_events(*graphics: Graphic): original_vals = [g.block_events for g in graphics] for g in graphics: - g.block_events = True + if event_handlers is not None: + g.block_handlers.extend([e for e in event_handlers]) + else: + g.block_events = True yield for g, value in zip(graphics, original_vals): - g.block_events = value + if event_handlers is not None: + g.block_handlers.clear() + else: + g.block_events = value + + +def get_nearest_graphics_indices( + pos: tuple[float, float] | tuple[float, float, float], + graphics: Sequence[Graphic] | GraphicCollection, +) -> np.ndarray[int]: + """ + Returns indices of the nearest ``graphics`` to the passed position ``pos`` in world space + in order of closest to furtherst. Uses the distance between ``pos`` and the center of the + bounding sphere for each graphic. + + Parameters + ---------- + pos: (x, y) | (x, y, z) + position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D + + graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection + the graphics from which to return a sorted array of graphics in order of closest + to furthest graphic + + Returns + ------- + ndarray[int] + indices of the nearest nearest graphics to ``pos`` in order + + """ + if isinstance(graphics, GraphicCollection): + graphics = graphics.graphics + + if not all(isinstance(g, Graphic) for g in graphics): + raise TypeError("all elements of `graphics` must be Graphic objects") + + pos = np.asarray(pos).ravel() + + if pos.shape != (2,) and pos.shape != (3,): + raise TypeError( + f"pos.shape must be (2,) or (3,), the shape of pos you have passed is: {pos.shape}" + ) + + # get centers + centers = np.empty(shape=(len(graphics), len(pos))) + for i in range(centers.shape[0]): + centers[i] = graphics[i].world_object.get_world_bounding_sphere()[: len(pos)] + + # l2 + distances = np.linalg.norm(centers[:, : len(pos)] - pos, ord=2, axis=1) + + sort_indices = np.argsort(distances) + return sort_indices + + +def get_nearest_graphics( + pos: tuple[float, float] | tuple[float, float, float], + graphics: Sequence[Graphic] | GraphicCollection, +) -> np.ndarray[Graphic]: + """ + Returns the nearest ``graphics`` to the passed position ``pos`` in world space. + Uses the distance between ``pos`` and the center of the bounding sphere for each graphic. + + Parameters + ---------- + pos: (x, y) | (x, y, z) + position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D + + graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection + the graphics from which to return a sorted array of graphics in order of closest + to furthest graphic + + Returns + ------- + ndarray[Graphic] + nearest graphics to ``pos`` in order + + """ + sort_indices = get_nearest_graphics_indices(pos, graphics) + return np.asarray(graphics)[sort_indices] diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 28b7c4a49..edb01f482 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -19,7 +19,7 @@ from ._utils import controller_types as valid_controller_types from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera -from .. import ImageGraphic +from .. import ImageGraphic, ImageYUVGraphic class Figure: @@ -548,7 +548,7 @@ def _render(self, draw=True): # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) - for subplot in self: + for subplot in self._subplots.ravel(): subplot._render() # overlay render pass @@ -615,14 +615,16 @@ def show( sidecar_kwargs = dict() # flip y-axis if ImageGraphics are present - for subplot in self: + for subplot in self._subplots.ravel(): for g in subplot.graphics: - if isinstance(g, ImageGraphic): - subplot.camera.local.scale_y *= -1 + if isinstance(g, (ImageGraphic, ImageYUVGraphic)): + if subplot.camera.local.scale_y == 1: + # if it's 1 it's likely not been touched manually before show was called + subplot.camera.local.scale_y = -1 break if autoscale: - for subplot in self: + for subplot in self._subplots.ravel(): if maintain_aspect is None: _maintain_aspect = subplot.camera.maintain_aspect else: @@ -631,7 +633,7 @@ def show( # set axes visibility if False if not axes_visible: - for subplot in self: + for subplot in self._subplots.ravel(): subplot.axes.visible = False # parse based on canvas type @@ -655,15 +657,15 @@ def show( elif self.canvas.__class__.__name__ == "OffscreenRenderCanvas": # for test and docs gallery screenshots self._fpl_reset_layout() - for subplot in self: + for subplot in self._subplots.ravel(): subplot.axes.update_using_camera() # render call is blocking only on github actions for some reason, # but not for rtd build, this is a workaround # for CI tests, the render call works if it's in test_examples # but it is necessary for the gallery images too so that's why this check is here - if "RTD_BUILD" in os.environ.keys(): - if os.environ["RTD_BUILD"] == "1": + if "DOCS_BUILD" in os.environ.keys(): + if os.environ["DOCS_BUILD"] == "1": self._render() else: # assume GLFW @@ -779,7 +781,7 @@ def clear_animations(self, removal: str = None): def clear(self): """Clear all Subplots""" - for subplot in self: + for subplot in self._subplots.ravel(): subplot.clear() def export_numpy(self, rgb: bool = False) -> np.ndarray: @@ -853,9 +855,6 @@ def export(self, uri: str | Path | bytes, **kwargs): return iio.imwrite(uri, snapshot, **kwargs) - def open_popup(self, *args, **kwargs): - warn("popups only supported by ImguiFigure") - def _fpl_reset_layout(self, *ev): """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event""" self.layout.canvas_resized(self.get_pygfx_render_area()) @@ -938,18 +937,20 @@ def __getitem__(self, index: str | int | tuple[int, int]) -> Subplot: return subplot raise IndexError(f"no subplot with given name: {index}") + if isinstance(index, (int, np.integer)): + return self._subplots.ravel()[index] + if isinstance(self.layout, GridLayout): return self._subplots[index[0], index[1]] - return self._subplots[index] + raise TypeError( + f"Can index figure using subplot name, numerical subplot index, or a " + f"tuple[int, int] if the layout is a grid" + ) def __iter__(self): - self._current_iter = iter(range(len(self))) - return self - - def __next__(self) -> Subplot: - pos = self._current_iter.__next__() - return self._subplots.ravel()[pos] + for subplot in self._subplots.ravel(): + yield subplot def __len__(self): """number of subplots""" @@ -964,6 +965,6 @@ def __repr__(self): return ( f"fastplotlib.{self.__class__.__name__}" f" Subplots:\n" - f"\t{newline.join(subplot.__str__() for subplot in self)}" + f"\t{newline.join(subplot.__str__() for subplot in self._subplots.ravel())}" f"\n" ) diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py index 1c308590f..3b3fab12e 100644 --- a/fastplotlib/layouts/_frame.py +++ b/fastplotlib/layouts/_frame.py @@ -115,6 +115,7 @@ def __init__( resizeable, title, docks, + imgui_windows, toolbar_visible, canvas_rect, ): @@ -144,6 +145,9 @@ def __init__( docks: dict[str, PlotArea] subplot dock + imgui_windows: dict[str, ImguiWindow] + imgui windows confined to this subplot, keyed by location + toolbar_visible: bool toolbar visibility @@ -154,6 +158,7 @@ def __init__( self.viewport = viewport self.docks = docks + self._imgui_windows = imgui_windows self._toolbar_visible = toolbar_visible # create rect manager to handle all the backend rect calculations @@ -254,11 +259,35 @@ def rect(self, rect: np.ndarray): self.reset_viewport() def reset_viewport(self): - """reset the viewport rect for the subplot and docks""" + """reset the viewport rect for the subplot, docks, and imgui windows""" # get rect of the render area x, y, w, h = self.get_render_rect() + # imgui edge windows reserve space outboard of the docks + g_left = self._imgui_size("left") + g_top = self._imgui_size("top") + g_right = self._imgui_size("right") + g_bottom = self._imgui_size("bottom") + + # top and bottom imgui windows are inset by the left and right imgui windows + w_g_top_bottom = w - g_left - g_right + x_g_top_bottom = x + g_left + + # set imgui edge window rects + self._set_imgui_rect("left", (x, y, g_left, h)) + self._set_imgui_rect("top", (x_g_top_bottom, y, w_g_top_bottom, g_top)) + self._set_imgui_rect( + "bottom", (x_g_top_bottom, y + h - g_bottom, w_g_top_bottom, g_bottom) + ) + self._set_imgui_rect("right", (x + w - g_right, y, g_right, h)) + + # shrink the render area to fit inside the imgui edge windows + x += g_left + y += g_top + w -= g_left + g_right + h -= g_top + g_bottom + # dock sizes s_left = self.docks["left"].size s_top = self.docks["top"].size @@ -291,6 +320,31 @@ def reset_viewport(self): # set subplot rect self.viewport.rect = x, y, w, h + # toolbar occupies the reserved bottom band of the frame + self._set_toolbar_rect() + + def _imgui_size(self, location: str) -> int: + """thickness in pixels reserved by the imgui edge window at ``location``, 0 if none""" + window = self._imgui_windows.get(location) + return window.size if window is not None else 0 + + def _set_imgui_rect(self, location: str, rect: tuple): + """set the pixel rect of the imgui edge window at ``location``, if present""" + window = self._imgui_windows.get(location) + if window is not None: + window._fpl_set_rect(*(round(v) for v in rect)) + + def _set_toolbar_rect(self): + """set the pixel rect of the subplot toolbar window, if present""" + window = self._imgui_windows.get("toolbar") + if window is None: + return + + x, y, w, h = self.rect + window._fpl_set_rect( + round(x + 1), round(y + h - IMGUI_TOOLBAR_HEIGHT), round(w - 2), IMGUI_TOOLBAR_HEIGHT + ) + def get_render_rect(self) -> tuple[float, float, float, float]: """ Get the actual render area of the subplot, including the docks. diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 06a4c7517..d6189c4bd 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -3,11 +3,17 @@ from typing import * import numpy +from numpy.typing import NDArray + +from numpy.typing import NDArray import pygfx from ..graphics import * from ..graphics._base import Graphic +from ..utils import enums +import typing +import fastplotlib class GraphicMethodsMixin: @@ -31,14 +37,16 @@ def add_image( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "nearest", cmap_interpolation: str = "linear", - isolated_buffer: bool = True, - **kwargs, + colorspace: fastplotlib.utils.enums.ColorspacesRGB = "srgb", + cpu_buffer: bool = True, + **kwargs ) -> ImageGraphic: """ - Create an Image Graphic + Create an ImageGraphic Parameters ---------- @@ -56,17 +64,51 @@ def add_image( colormap to use to display the data. For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" cmap_interpolation: str, optional, default "linear" colormap interpolation method, one of "nearest" or "linear" - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer - useful if the - array is large. + colorspace: one of "srgb", "tex-srgb", "physical", default "srgb" + colorspace in which to interpret the provided data. + + * "srgb": the data represents intensity, rgb, or rgba pixels in the sRGB space. + sRGB is a standard color space designed for consistent representation of colors + across devices like monitors. Most images store colors in this space. + The shader convers sRGB colors to physical in the shader before doing color computations. + + * "tex-srgb": the underlying texture will be of an sRGB format. This means the data + is automatically converted to sRGB when it is sampled. This results in better glTF + compliance (because interpolation in the sampling happens in linear space). + Note that sampling *always* results in the sRGB values, also when not interpreted as color. + Only supported for rgb and rgba data. + + * "physical": the colors are (already) in the physical / linear space, where lighting + calculations can be applied. Shader code that interprets the data as color will use it as-is. + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + * vmin, vmax must be explicitly provided if sharing an existing buffer from another ImageGraphic + * ``reset_vmin_vmax()`` is not supported + * selector tools will not be able to return the data under the selection kwargs: additional keyword arguments passed to :class:`.Graphic` @@ -79,10 +121,12 @@ def add_image( vmin, vmax, cmap, + gamma, interpolation, cmap_interpolation, - isolated_buffer, - **kwargs, + colorspace, + cpu_buffer, + **kwargs ) def add_image_volume( @@ -92,6 +136,7 @@ def add_image_volume( vmin: float = None, vmax: float = None, cmap: str = "plasma", + gamma: float = 1.0, interpolation: str = "linear", cmap_interpolation: str = "linear", plane: tuple[float, float, float, float] = (0, 0, -1, 0), @@ -100,8 +145,7 @@ def add_image_volume( substep_size: float = 0.1, emissive: str | tuple | numpy.ndarray = (0, 0, 0), shininess: int = 30, - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ @@ -125,6 +169,9 @@ def add_image_volume( cmap: str, default "plasma" colormap for grayscale volumes + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + interpolation: str, default "linear" interpolation method for sampling pixels @@ -158,11 +205,6 @@ def add_image_volume( 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` @@ -175,6 +217,7 @@ def add_image_volume( vmin, vmax, cmap, + gamma, interpolation, cmap_interpolation, plane, @@ -183,8 +226,191 @@ def add_image_volume( substep_size, emissive, shininess, - isolated_buffer, - **kwargs, + **kwargs + ) + + def add_image_yuv( + self, + data: ( + tuple[NDArray[numpy.uint8], NDArray[numpy.uint8], NDArray[numpy.uint8]] + | fastplotlib.graphics.features._image.TextureYUV + ), + vmin: float = 0, + vmax: float = 255, + gamma: float = 1.0, + interpolation: str = "nearest", + colorspace: fastplotlib.utils.enums.ColorspacesYUV = "yuv420p", + colorrange: fastplotlib.utils.enums.ColorRange = "limited", + **kwargs + ) -> ImageYUVGraphic: + """ + + Create an ImageYUVGraphic. Similar to ImageGraphic but handles data that is in yuv42p or yuv444p colorspace. + + Note that the buffers for YUV Images only exist on the GPU. When setting the image data, the new values are + directly sent to the GPU. + + ``reset_vmin_vmax()`` just sets (vmin, vmax) to (0, 255) + + Parameters + ---------- + data: TupleYUV + tuple of arrays that represent YUV channels. If the colorspace is yuv420p, the U and V array dims + must be 4 times smaller than the Y array dims. + + vmin: float, optional, default 0 + minimum value for color scaling + + vmax: float, optional, default 255 + maximum value for color scaling + + gamma: float, default 1.0 + gamma correction, the value scaled by ``vmin`` and ``vmax`` is raised to the power of ``gamma`` + + interpolation: str, optional, default "nearest" + interpolation filter, one of "nearest" or "linear" + + colorspace: "yuv42p" | "yuv444p" + colorspace in which to interpret the provided data. + + * "yuv420p": A common video format. The data is represented as 3 planes (y, u, and v). + The y represents intensity, and is at full resolution. The u and v planes are a + quarter of the size. + + * "yuv444p": A lesser common video format. The data is represented as 3 planes + (y, u, and v) similar to yuv420p however the u and v planes are stored + at full resolution. + + colorrange: Literal["full", "limited"] = "limited", + Relevant for yuv colorspaces. Most videos use "limited". + + * "limited": The luma plane (Y) is limited to the range of 16-235 for 8 bits. + The chroma planes (U and V) are limited to the range of 16-240 for 8 bits + * "full": The luma plane and chroma plane use the full range of the storage format. + + See the following links from the FFMPEG documentation for more details: + https://trac.ffmpeg.org/wiki/colorspace + https://ffmpeg.org/doxygen/7.0/pixfmt_8h_source.html#l00609 + + cpu_buffer: bool, default True + If ``True``, maintains a buffer of system RAM that is sychronized with a corresponding storage buffer + on the GPU. + If ``False``, setting the graphic data will send the new data directly to the GPU, we also + call this "bufferless". This is much faster but lacks the following features: + + * you must update the entire data array, i.e. you can perform ``image.data = new_data``, and you + cannot perform partial updates such as ``image.data[indices] = ``. + + * RGB arrays of shape [rows, cols, 3] are not supported since wgpu does not have RGB textures, + use RGBA or use `cpu_buffer=True` if you really need RGB instead of RGBA. + + * tooltip values for grayscale data are estimated using an inverse transforms on the colormap LUT. + The tooltip values may or may not be accurate for a given colormap and vmin, vmax. If you require + precise and reliable tooltip values for grayscale data use `cpu_buffer=True`. + + kwargs: + additional keyword arguments passed to :class:`.Graphic` + + + """ + return self._create_graphic( + ImageYUVGraphic, + data, + vmin, + vmax, + gamma, + interpolation, + colorspace, + colorrange, + **kwargs + ) + + def add_inf_line( + self, + data: Any, + axis: Optional[Literal["x", "y", "z"]] = None, + thickness: float = 2.0, + colors: Union[str, numpy.ndarray, Sequence] = "w", + cmap: str = None, + cmap_transform: Union[numpy.ndarray, Sequence] = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", + start_is_infinite: bool = True, + end_is_infinite: bool = True, + dash_pattern: str | tuple | list = (), + size_space: str = "screen", + **kwargs + ) -> InfLineGraphic: + """ + + Create a collection of infinite lines. + + Parameters + ---------- + data: array-like + The line positions. If ``axis`` is "x", "y", or "z", a 1D array of positions along + that axis; one infinite line is drawn at each position. If ``axis`` is None, ``data`` + is used directly as the segment endpoints, of shape [n_points, 2 | 3], where every two + consecutive points define one line. + + axis: "x", "y", "z", or None, default None + The axis along which the line positions are given. If None, ``data`` is interpreted + directly as the segment endpoints. + + thickness: float, optional, default 2.0 + thickness of the lines + + colors: str, array, or iterable, default "w" + specify colors as a single human-readable string, a single RGBA array, or a Sequence + (array, tuple, or list) of strings or RGBA arrays. A sequence of colors provides one + color per line. + + cmap: str, optional + Apply a colormap to the lines instead of assigning colors manually, one color per line. + This overrides any argument passed to "colors". For supported colormaps see the + ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all lines. + "vertex" allows an independent color per line. + For most cases you can keep it as "auto" and the `color_mode` is determined automatically + based on the argument passed to `colors`. + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + start_is_infinite: bool, default True + whether the start of each line is extended to infinity + + end_is_infinite: bool, default True + whether the end of each line is extended to infinity + + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. + + size_space: str, default "screen" + coordinate space in which the thickness is expressed ("screen", "world", "model") + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + InfLineGraphic, + data, + axis, + thickness, + colors, + cmap, + cmap_transform, + color_mode, + start_is_infinite, + end_is_infinite, + dash_pattern, + size_space, + **kwargs ) def add_line_collection( @@ -192,16 +418,15 @@ def add_line_collection( 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, cmap: Union[Sequence[str], str] = None, cmap_transform: Union[numpy.ndarray, List] = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", name: str = None, names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -235,6 +460,9 @@ def add_line_collection( cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + The color mode for each line in the collection. See `color_mode` in :class:`.LineGraphic` for details. + name: str, optional name of the line collection as a whole @@ -261,16 +489,15 @@ def add_line_collection( data, thickness, colors, - uniform_colors, cmap, cmap_transform, + color_mode, name, names, metadata, metadatas, - isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -278,12 +505,13 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Sequence] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: Union[numpy.ndarray, Sequence] = None, - isolated_buffer: bool = True, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", size_space: str = "screen", - **kwargs, + dash_pattern: str | tuple | list = (), + thin: bool = False, + **kwargs ) -> LineGraphic: """ @@ -304,21 +532,34 @@ def add_line( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default ``False`` - if True, uses a uniform buffer for the line color, - basically saves GPU VRAM when the entire line has a single color - 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 ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap size_space: str, default "screen" coordinate space in which the thickness is expressed ("screen", "world", "model") + dash_pattern: str, tuple, or list, default () + The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` + or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the + length of strokes and gaps. Ignored when ``thin`` is True. + + thin: bool, default False + Use the more performant thin line material, which is always one physical pixel wide. + Thickness, dashing, and anti-aliasing are ignored when True. + **kwargs passed to :class:`.Graphic` @@ -329,12 +570,13 @@ def add_line( data, thickness, colors, - uniform_color, cmap, cmap_transform, - isolated_buffer, + color_mode, size_space, - **kwargs, + dash_pattern, + thin, + **kwargs ) def add_line_stack( @@ -348,11 +590,10 @@ def add_line_stack( names: list[str] = None, metadata: Any = None, metadatas: Union[Sequence[Any], numpy.ndarray] = None, - isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -425,11 +666,10 @@ def add_line_stack( names, metadata, metadatas, - isolated_buffer, separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_mesh( @@ -448,8 +688,7 @@ def add_mesh( | numpy.ndarray ) = None, clim: tuple[float, float] = None, - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> MeshGraphic: """ @@ -488,12 +727,6 @@ def add_mesh( Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. An image can also be used, this is basically a 2D colormap. - 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. In almost all cases this should be ``True``. - **kwargs passed to :class:`.Graphic` @@ -509,8 +742,7 @@ def add_mesh( mapcoords, cmap, clim, - isolated_buffer, - **kwargs, + **kwargs ) def add_polygon( @@ -527,7 +759,7 @@ def add_polygon( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> PolygonGraphic: """ @@ -570,16 +802,100 @@ def add_polygon( PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs ) + def add_scatter_collection( + self, + data: Union[numpy.ndarray, List[numpy.ndarray]], + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + sizes: Union[float, Sequence[float]] = 5.0, + uniform_size: bool = True, + markers: Union[numpy.ndarray, Sequence[str]] = None, + uniform_marker: bool = True, + edge_width: float = 1.0, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + **kwargs + ) -> ScatterCollection: + """ + + Create a collection of :class:`.ScatterGraphic` + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + meatadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + ScatterCollection, + data, + colors, + cmap, + cmap_transform, + sizes, + uniform_size, + markers, + uniform_marker, + edge_width, + name, + names, + metadata, + metadatas, + **kwargs + ) + def add_scatter( self, data: Any, colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", - uniform_color: bool = False, cmap: str = None, cmap_transform: numpy.ndarray = None, + color_mode: Literal["auto", "uniform", "vertex"] = "auto", mode: Literal["markers", "simple", "gaussian", "image"] = "markers", markers: Union[str, numpy.ndarray, Sequence[str]] = "o", - uniform_marker: bool = False, + uniform_marker: bool = True, custom_sdf: str = None, edge_colors: Union[ str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float] @@ -589,11 +905,10 @@ def add_scatter( 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, + sizes: Union[float, numpy.ndarray, Sequence[float]] = 5, + uniform_size: bool = True, size_space: str = "screen", - isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -609,18 +924,23 @@ def add_scatter( specify colors as a single human-readable string, a single RGBA array, or a Sequence (array, tuple, or list) of strings or RGBA arrays - uniform_color: bool, default False - 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. - 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 - ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + overrides any argument passed to "colors". + For supported colormaps see the ``cmap`` library catalogue: + https://cmap-docs.readthedocs.io/en/stable/catalog/ cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap + color_mode: one of "auto", "uniform", "vertex", default "auto" + "uniform" restricts to a single color for all line datapoints. + "vertex" allows independent colors per vertex. + For most cases you can keep it as "auto" and the `color_mode` is determineed automatically based on the + argument passed to `colors`. if `colors` represents a single color, then the mode is set to "uniform". + If `colors` represents a unique color per-datapoint, or if a cmap is provided, then `color_mode` is set to + "vertex". You can switch between "uniform" and "vertex" `color_mode` after creating the graphic. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" The scatter points mode, cannot be changed after the graphic has been created. @@ -640,9 +960,10 @@ def add_scatter( * 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. + uniform_marker: bool, default ``True`` + If ``True``, 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. If ``False``, you can + set per-vertex markers. custom_sdf: str = None, The SDF code for the marker shape when the marker is set to custom. @@ -662,8 +983,9 @@ def add_scatter( 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. + uniform_edge_color: bool, default ``True`` + Set the same edge color for all markers. Useful for saving GPU RAM. Set to ``False`` for per-vertex edge + colors edge_width: float = 1.0, Width of the marker edges. used when `mode` is "markers". @@ -684,17 +1006,13 @@ def add_scatter( sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points - uniform_size: bool, default False - if True, uses a uniform buffer for the scatter point sizes. Useful if you need to - save GPU VRAM when all points have the same size. + uniform_size: bool, default ``False`` + if ``True``, uses a uniform buffer for the scatter point sizes. Useful if you need to + save GPU VRAM when all points have the same size. Set to ``False`` if you need per-vertex sizes. size_space: str, default "screen" 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` @@ -704,9 +1022,9 @@ def add_scatter( ScatterGraphic, data, colors, - uniform_color, cmap, cmap_transform, + color_mode, mode, markers, uniform_marker, @@ -720,8 +1038,92 @@ def add_scatter( sizes, uniform_size, size_space, - isolated_buffer, - **kwargs, + **kwargs + ) + + def add_scatter_stack( + self, + data: Union[numpy.ndarray, List[numpy.ndarray]], + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, + name: str = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + separation: float = 0.0, + separation_axis: str = "y", + **kwargs + ) -> ScatterStack: + """ + + Create a stack of :class:`.LineGraphic` that are separated along the "x" or "y" axis. + + Parameters + ---------- + data: list of array-like + List or array-like of multiple line data to plot + + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] + + thickness: float or Iterable of float, default 2.0 + | if ``float``, single thickness will be used for all lines + | if ``list`` of ``float``, each value will apply to the individual lines + + colors: str, RGBA array, Iterable of RGBA array, or Iterable of str, default "w" + | if single ``str`` such as "w", "r", "b", etc, represents a single color for all lines + | if single ``RGBA array`` (tuple or list of size 4), represents a single color for all lines + | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] + | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + + cmap: Iterable of str or str, optional + | if ``str``, single cmap will be used for all lines + | if ``list`` of ``str``, each cmap will apply to the individual lines + + .. note:: + ``cmap`` overrides any arguments passed to ``colors`` + + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap + + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` + + separation: float, default 0.0 + space in between each line graphic in the stack + + separation_axis: str, default "y" + axis in which the line graphics in the stack should be separated + + kwargs_collection + kwargs for the collection, passed to GraphicCollection + + + """ + return self._create_graphic( + ScatterStack, + data, + colors, + cmap, + cmap_transform, + name, + names, + metadata, + metadatas, + separation, + separation_axis, + **kwargs ) def add_surface( @@ -738,7 +1140,7 @@ def add_surface( | numpy.ndarray ) = None, clim: tuple[float, float] | None = None, - **kwargs, + **kwargs ) -> SurfaceGraphic: """ @@ -792,7 +1194,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -843,7 +1245,7 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) def add_vectors( @@ -853,7 +1255,7 @@ def add_vectors( color: Union[str, Sequence[float], numpy.ndarray] = "w", size: float = None, vector_shape_options: dict = None, - **kwargs, + **kwargs ) -> VectorsGraphic: """ @@ -898,5 +1300,5 @@ def add_vectors( color, size, vector_shape_options, - **kwargs, + **kwargs ) diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 33cc6d925..ae7102524 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import Literal, Iterable @@ -12,8 +14,10 @@ import pygfx from ._figure import Figure -from ..ui import EdgeWindow, SubplotToolbar, StandardRightClickMenu, Popup, GUI_EDGES -from ..ui import ColormapPicker +from ._rect import RectManager +from ._utils import IMGUI_TOOLBAR_HEIGHT +from ..ui import ImguiWindow, ImguiPopup, SubplotToolbar, StandardRightClickMenu, EDGES +from ..ui._base import _wrap_update_call class ImguiFigure(Figure): @@ -45,7 +49,15 @@ def __init__( size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, ): - self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} + # edge windows reserve canvas space, keyed by location; floating windows draw over the plots + self._edge_windows: dict[str, ImguiWindow] = {loc: None for loc in EDGES} + self._floating_windows: list[ImguiWindow] = [] + + # figure level right-click popup, and the popup opened by the most recent right-click + self._imgui_right_click: ImguiPopup = None + self._currently_open_imgui_right_click: ImguiPopup = None + + self._right_click_press_pos: imgui.ImVec2 = None super().__init__( shape=shape, @@ -97,31 +109,24 @@ def __init__( self.imgui_renderer.set_gui(self._draw_imgui) - self._subplot_toolbars: np.ndarray[SubplotToolbar] = np.empty( - shape=self._subplots.size, dtype=object - ) - - for i, subplot in enumerate(self._subplots.ravel()): - toolbar = SubplotToolbar(subplot=subplot) - self._subplot_toolbars[i] = toolbar - - self._right_click_menu = StandardRightClickMenu(figure=self) + for subplot in self._subplots.ravel(): + subplot.add_imgui_window( + SubplotToolbar(), location="toolbar", size=IMGUI_TOOLBAR_HEIGHT + ) - self._popups: dict[str, Popup] = {} + self.set_imgui_right_click(StandardRightClickMenu()) self.imgui_show_fps = False self._stats = Stats(self.renderer.device, self.canvas) - 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""" - return self._guis + def imgui_windows(self) -> dict[str, ImguiWindow]: + """edge imgui windows added to the Figure, keyed by location""" + return self._edge_windows @property def imgui_renderer(self) -> ImguiRenderer: @@ -141,60 +146,236 @@ def _render(self, draw=False): self.canvas.request_draw() def _draw_imgui(self) -> imgui.ImDrawData: - # imgui.new_frame() - - for subplot, toolbar in zip( - self._subplots.ravel(), self._subplot_toolbars.ravel() - ): - if not subplot.toolbar: - # if subplot.toolbar is False + # figure-level windows: edge windows then floating windows + for window in (*self._edge_windows.values(), *self._floating_windows): + if window is None: continue - toolbar.update() + self._layout_imgui_window(window) + window.draw() + + # subplot windows, edge window rects are set by Frame.reset_viewport + for subplot in self._subplots.ravel(): + for location, window in subplot.imgui_windows.items(): + if window is None: + continue + if location == "toolbar" and not subplot.toolbar: + continue + window.draw() + + self._fpl_handle_right_click() + + # the currently open popup is drawn first, opening it closes any other popup that is still open. + # it keeps being drawn after it closes so that it can also draw its own windows + popup = self._currently_open_imgui_right_click + if popup is not None: + popup.draw() + + if self._imgui_right_click is not None and self._imgui_right_click is not popup: + self._imgui_right_click.draw() + + def add_imgui_window( + self, + window: ImguiWindow = None, + *, + location: Literal["left", "right", "top", "bottom", "floating"] = None, + size: int = None, + rect: tuple | np.ndarray = None, + extent: tuple | np.ndarray = None, + title: str = None, + window_flags: imgui.WindowFlags_ = None, + ): + """ + Add an imgui window to the Figure. Can also be used as a decorator, see examples. - for gui in self.guis.values(): - if gui is not None: - gui.draw_window() + A window can be placed on an edge ("left", "right", "top", "bottom") where it reserves canvas space so it + does not cover the subplots, "floating" for an auto-sized draggable window, or at a fixed fractional or pixel + ``rect`` or ``extent`` of the canvas. An existing window at an edge ``location`` is replaced. - for popup in self._popups.values(): - popup.update() + For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide. - self._right_click_menu.update() + Parameters + ---------- + window: ImguiWindow, optional + an ``ImguiWindow`` instance, omit when decorating - # imgui.end_frame() + location: str, "left" | "right" | "top" | "bottom" | "floating" + edge windows reserve canvas space, "floating" is auto-sized and draggable - # imgui.render() + size: int + edge window thickness in pixels, required for edge windows - # return imgui.get_draw_data() + rect: (x, y, w, h), optional + fractional or pixel rect for a fixed floating window - def add_gui(self, gui: EdgeWindow): - """ - Add a GUI to the Figure. GUIs can be added to the left or bottom edge. + extent: (xmin, xmax, ymin, ymax), optional + fractional or pixel extent for a fixed floating window - Parameters - ---------- - gui: EdgeWindow - A GUI EdgeWindow instance + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. + + window_flags: ``imgui.WindowFlags_`` + imgui window flags, used when decorating; if not provided, the default depends on placement — edge + windows use ``no_collapse | no_resize | no_title_bar | no_bring_to_front_on_focus`` (custom title bar, + stays behind overlays), floating windows use ``none`` (native title bar, collapsible and movable), + fixed rect/extent windows use ``no_collapse | no_move | no_resize`` (native title bar) + + Examples + -------- + + As a decorator:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure() + figure[0, 0].add_line(np.random.rand(100)) + + @figure.add_imgui_window(location="right", title="controls", size=200) + def gui(fig): # the figure is passed if the function takes an argument + if imgui.button("reset data"): + fig[0, 0].graphics[0].data[:, 1] = np.random.rand(100) + + Instance:: + + figure.add_imgui_window(MyWindow(), location="bottom", size=100) """ - if not isinstance(gui, EdgeWindow): - raise TypeError( - f"GUI must be of type: {EdgeWindow} you have passed a {type(gui)}" + + def decorator(_window): + if isinstance(_window, ImguiWindow): + win = _window + elif callable(_window): + win = ImguiWindow(update_call=_wrap_update_call(_window, self)) + else: + raise TypeError( + "add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` instance" + ) + + win._fpl_add_hook( + figure=self, + subplot=None, + location=location, + size=size, + rect=rect, + extent=extent, + title=title, + window_flags=window_flags, ) + self._register_imgui_window(win) + return _window + + if window is None: + return decorator + + decorator(window) + return window + + def _register_imgui_window(self, window: ImguiWindow): + """store a figure-level window and reset the layout if it reserves canvas space""" + location = window.location - location = gui.location + if location in EDGES: + if window.size is None: + raise ValueError(f"must provide `size` for an edge window, location: {location}") + self._edge_windows[location] = window + self._fpl_reset_layout() - if location not in GUI_EDGES: + elif window._floating or window._rect_manager is not None: + self._floating_windows.append(window) + + else: raise ValueError( - f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" + "imgui window must have a valid `location` (an edge or 'floating'), or a `rect` or `extent`" ) - if self.guis[location] is not None: - raise ValueError(f"GUI already exists in the desired location: {location}") + def append_imgui_window(self, gui: Callable = None, *, location: str = None): + """ + Append imgui elements to an existing edge window. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" + location of the existing window to append to - self.guis[location] = gui + """ + if location not in EDGES: + raise ValueError(f"valid locations to append to are: {EDGES}, you have passed: {location}") + + window = self._edge_windows[location] + if window is None: + raise ValueError(f"no imgui window at location to append to: {location}") + + def decorator(_gui): + window._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_window(self, location: str) -> ImguiWindow: + """ + Remove and return the edge imgui window at the given location + Parameters + ---------- + location: str + "left" | "right" | "top" | "bottom" + + Returns + ------- + ImguiWindow + the removed window, it can be added again later + + """ + if location not in EDGES: + raise ValueError(f"valid locations are: {EDGES}, you have passed: {location}") + + window = self._edge_windows[location] + self._edge_windows[location] = None self._fpl_reset_layout() + return window + + def _edge_size(self, edge: str) -> int: + """thickness in pixels reserved by the edge window at ``edge``, 0 if none""" + window = self._edge_windows[edge] + return window.size if window is not None else 0 + + def _layout_imgui_window(self, window: ImguiWindow): + """compute and set the pixel rect of a figure-level imgui window""" + if window._floating: + # imgui auto-sizes a floating window from its content, nothing to compute + return + + width, height = self.canvas.get_logical_size() + + if window._rect_manager is not None: + window._rect_manager.canvas_resized((0, 0, width, height)) + window._fpl_set_rect(*(round(v) for v in window._rect_manager.rect)) + return + + # edge window, spans the full edge minus any perpendicular edge windows + sl, sr = self._edge_size("left"), self._edge_size("right") + st, sb = self._edge_size("top"), self._edge_size("bottom") + mid_y, mid_h = st, height - st - sb + + match window.location: + case "top": + rect = (0, 0, width, st) + case "bottom": + rect = (0, height - sb, width, sb) + case "left": + rect = (0, mid_y, sl, mid_h) + case "right": + rect = (width - sr, mid_y, sr, mid_h) + + window._fpl_set_rect(*(round(v) for v in rect)) def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ @@ -209,53 +390,187 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ width, height = self.canvas.get_logical_size() - x = 0 - y = 0 - - for edge in ["right"]: - if self.guis[edge]: - width -= self._guis[edge].size - for edge in ["bottom"]: - if self.guis[edge]: - height -= self._guis[edge].size + sl, sr = self._edge_size("left"), self._edge_size("right") + st, sb = self._edge_size("top"), self._edge_size("bottom") - for edge in ["top"]: - if self.guis[edge]: - y += self._guis[edge].size - height -= self._guis[edge].size + x = sl + y = st + width = width - sl - sr + height = height - st - sb return x, y, max(1, width), max(1, height) - def register_popup(self, popup: Popup.__class__): + @property + def imgui_right_click(self) -> ImguiPopup | None: + """ + The imgui popup that is opened by a right-click within a subplot, a ``StandardRightClickMenu`` by default. + A popup set on a subplot or graphic replaces it for that subplot or graphic. + """ + return self._imgui_right_click + + def set_imgui_right_click( + self, + popup: ImguiPopup | Callable = None, + *, + window_flags: imgui.WindowFlags_ = None, + ): """ - Register a popup class. Note that this takes the class, not an instance + Set the imgui popup that is opened by a right-click within a subplot, replaces the standard right-click + menu. Can also be used as a decorator, see examples. + + For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide. Parameters ---------- - popup: Popup subclass + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup + + Examples + -------- + + As a decorator:: + + import numpy as np + import fastplotlib as fpl + from imgui_bundle import imgui + + figure = fpl.Figure() + figure[0, 0].add_line(np.random.rand(100)) + + @figure.set_imgui_right_click() + def popup(fig): # the figure is passed if the function takes an argument + if imgui.menu_item("autoscale", "", False)[0]: + fig.imgui_right_click.subplot.auto_scale() + + Function, the same function can be set on any number of figures, subplots or graphics:: + + def popup(subplot): + imgui.text(f"subplot: {subplot.name}") + + figure[0, 0].set_imgui_right_click(popup) + figure[0, 1].set_imgui_right_click(popup) + + Instance:: + + figure.set_imgui_right_click(MyPopup()) """ - self._popups[popup.name] = popup(self) - def open_popup(self, name: str, pos: tuple[int, int], **kwargs): + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=self, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui: Callable = None): """ - Open a registered popup + Append imgui elements to the Figure's right-click popup, the standard right-click menu by default. Can also + be used as a decorator. Parameters ---------- - name: str - The registered name of the popup + gui: callable, optional + function that draws imgui elements, omit when decorating - pos: int, int - x_pos, y_pos for the popup + """ + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this figure to append to, set one using " + "`figure.set_imgui_right_click()`" + ) + + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_right_click(self) -> ImguiPopup: + """ + Remove and return the Figure's right-click popup - kwargs - any additional kwargs to pass to the Popup's open() method + Returns + ------- + ImguiPopup + the removed popup, it can be set again later """ + popup = self._imgui_right_click + self._imgui_right_click = None + + return popup + + def _fpl_handle_right_click(self): + """open the popup of the graphic, subplot or Figure that was right-clicked""" + if imgui.is_mouse_down(1): + if self._right_click_press_pos is None: + self._right_click_press_pos = imgui.get_mouse_pos() + return + + press_pos = self._right_click_press_pos + self._right_click_press_pos = None - if self._popups[name].is_open: + if press_pos is None or not imgui.is_mouse_released(1): return - self._popups[name].open(pos, **kwargs) + pos = imgui.get_mouse_pos() + + if press_pos != pos: + # right-drag zooms the camera + return + + if imgui.is_window_hovered(imgui.HoveredFlags_.any_window): + # pointer is over an imgui window, not the pygfx render area + return + + for subplot in self._subplots.ravel(): + if subplot.viewport.is_inside(pos.x, pos.y): + break + else: + return + + pick_info = subplot.get_pick_info((pos.x, pos.y)) + graphic = pick_info["graphic"] if pick_info is not None else None + + # the most specific popup wins + if graphic is not None and graphic.imgui_right_click is not None: + popup = graphic.imgui_right_click + elif subplot.imgui_right_click is not None: + popup = subplot.imgui_right_click + else: + popup = self._imgui_right_click + + if popup is not None: + self._fpl_open_imgui_right_click(popup, subplot=subplot, graphic=graphic) + + def _fpl_open_imgui_right_click(self, popup: ImguiPopup, subplot, graphic): + """set the popup that is drawn as the open popup, and open it""" + previous = self._currently_open_imgui_right_click + if previous is not None and previous is not popup: + previous._fpl_close() + + self._currently_open_imgui_right_click = popup + popup._fpl_open(subplot=subplot, graphic=graphic) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 5d38ce37d..0c07fbb4b 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -5,13 +5,13 @@ import numpy as np import pygfx -from pylinalg import vec_transform, vec_unproject +from pylinalg import vec_transform, vec_unproject, aabb_to_sphere from rendercanvas import BaseRenderCanvas from ._utils import create_controller from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC -from ..graphics import ImageGraphic -from ..graphics.selectors._base_selector import BaseSelector +from ..graphics import ImageGraphic, MeshGraphic +from ..graphics.selectors import SelectorProtocol from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend from ..tools import Tooltip @@ -27,6 +27,26 @@ IPYTHON = get_ipython() +def _get_visible_bounding_box(obj: pygfx.Scene | pygfx.Group | pygfx.WorldObject): + """Recursively compute world bounding box of only visible objects, down to leaf nodes.""" + if not obj.visible: + return None + children = list(obj.children) + + if not children: + return obj.get_world_bounding_box() + + bboxes = [] + for child in children: + bbox = _get_visible_bounding_box(child) + if bbox is not None: + bboxes.append(bbox) + if not bboxes: + return None + bboxes = np.array(bboxes) + return np.array([bboxes[:, 0, :].min(axis=0), bboxes[:, 1, :].max(axis=0)]) + + class PlotArea(GraphicMethodsMixin): def __init__( self, @@ -95,7 +115,7 @@ def __init__( self._graphics: list[Graphic] = list() # selectors are in their own list so they can be excluded from scene bbox calculations - self._selectors: list[BaseSelector] = list() + self._selectors: list[SelectorProtocol] = list() # legends, managed just like other graphics as explained above self._legends: list[Legend] = list() @@ -120,11 +140,8 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) - self._ambient_light = pygfx.AmbientLight() - self._directional_light = pygfx.DirectionalLight() - - self.scene.add(self._ambient_light) - self.scene.add(self._camera.add(self._directional_light)) + self._ambient_light = None + self._directional_light = None self._tooltip = Tooltip() self.get_figure()._fpl_overlay_scene.add(self._tooltip._fpl_world_object) @@ -179,8 +196,9 @@ def camera(self, new_camera: str | pygfx.PerspectiveCamera): # user wants to set completely new camera, remove current camera from controller if isinstance(new_camera, pygfx.PerspectiveCamera): self.controller.remove_camera(self._camera) - # add directional light to new camera - new_camera.add(self._directional_light) + if self._directional_light is not None: + # add directional light to new camera + new_camera.add(self._directional_light) # add new camera to controller self.controller.add_camera(new_camera) @@ -233,7 +251,10 @@ def controller(self, new_controller: str | pygfx.Controller): # pygfx plans on refactoring viewports anyways if self.parent is not None: if self.parent.__class__.__name__.endswith("Figure"): - for subplot in self.parent: + # always use figure._subplots.ravel() in internal fastplotlib code + # otherwise if we use `for subplot in figure`, this could conflict + # with a user's iterator where they are doing `for subplot in figure` !!! + for subplot in self.parent._subplots.ravel(): if subplot.camera in cameras_list: new_controller.register_events(subplot.viewport) subplot._controller = new_controller @@ -246,7 +267,7 @@ def graphics(self) -> tuple[Graphic, ...]: return tuple(self._graphics) @property - def selectors(self) -> tuple[BaseSelector, ...]: + def selectors(self) -> tuple[SelectorProtocol, ...]: """Selectors in the plot area.""" return tuple(self._selectors) @@ -256,7 +277,7 @@ def legends(self) -> tuple[Legend, ...]: return tuple(self._legends) @property - def objects(self) -> tuple[Graphic | BaseSelector | Legend, ...]: + def objects(self) -> tuple[Graphic | SelectorProtocol | Legend, ...]: return *self.graphics, *self.selectors, *self.legends @property @@ -290,12 +311,12 @@ def background_color(self, colors: str | tuple[float]): self._background_material.set_colors(*colors) @property - def ambient_light(self) -> pygfx.AmbientLight: + def ambient_light(self) -> pygfx.AmbientLight | None: """the ambient lighting in the scene""" return self._ambient_light @property - def directional_light(self) -> pygfx.DirectionalLight: + def directional_light(self) -> pygfx.DirectionalLight | None: """the directional lighting on the camera in the scene""" return self._directional_light @@ -628,6 +649,13 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if isinstance(graphic, ImageGraphic): self._sort_images_by_depth() + if isinstance(graphic, MeshGraphic): + self._ambient_light = pygfx.AmbientLight() + self._directional_light = pygfx.DirectionalLight() + + self.scene.add(self._ambient_light) + self.scene.add(self._camera.add(self._directional_light)) + def insert_graphic( self, graphic: Graphic, @@ -684,7 +712,7 @@ def _add_or_insert_graphic( if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) - if isinstance(graphic, BaseSelector): + if isinstance(graphic, SelectorProtocol): obj_list = self._selectors self.scene.add(graphic.world_object) @@ -697,7 +725,7 @@ def _add_or_insert_graphic( self._fpl_graphics_scene.add(graphic.world_object) else: - raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") + raise TypeError("graphic must be of type Graphic | SelectorProtocol | Legend") if action == "insert": obj_list.insert(index, graphic) @@ -775,7 +803,12 @@ def center_scene(self, *, zoom: float = 1.0): def _auto_center_scene( self, camera: pygfx.PerspectiveCamera, scene: pygfx.Scene, zoom: float ): - camera.show_object(scene) + bb = _get_visible_bounding_box(scene) + if bb is not None: + sphere = aabb_to_sphere(bb) + camera.show_object(sphere) + else: + camera.show_object(scene) # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate # probably because camera.show_object uses bounding sphere camera.zoom = zoom @@ -841,8 +874,9 @@ def _auto_scale_scene( ): camera.maintain_aspect = maintain_aspect - if len(scene.children) > 0: - width, height, depth = np.ptp(scene.get_world_bounding_box(), axis=0) + bb = _get_visible_bounding_box(scene) + if bb is not None: + width, height, depth = np.ptp(bb, axis=0) else: width, height, depth = (1, 1, 1) @@ -857,6 +891,49 @@ def _auto_scale_scene( camera.zoom = zoom + @property + def x_range(self) -> tuple[float, float]: + """ + Get or set the x-range currently in view. + Really only valid for orthographic projections of the xy plane. + Use camera.set_state() to set the camera position for arbitrary projections. + """ + hw = self.camera.projection_matrix_inverse[0, 0] + x = self.camera.local.x + return x - hw, x + hw + + @x_range.setter + def x_range(self, xr: tuple[float, float]): + hw = (xr[1] - xr[0]) / 2 + if self.camera.fov > 0: + # really shouldn't use this for fov > 0 but ¯\_(ツ)_/¯ + self.camera.zoom *= self.camera.projection_matrix_inverse[0, 0] / hw + else: + # sets correct x_range for orthographic projection of xy plane + self.camera.width = (xr[1] - xr[0]) * self.camera.zoom + self.camera.local.x = (xr[0] + xr[1]) / 2 + + @property + def y_range(self) -> tuple[float, float]: + """ + Get or set the y-range currently in view. + Really only valid for orthographic projections of the xy plane. + Use camera.set_state() to set the camera position for arbitrary projections. + """ + hh = self.camera.projection_matrix_inverse[1, 1] + y = self.camera.local.y + return y - hh, y + hh + + @y_range.setter + def y_range(self, yr: tuple[float, float]): + hh = (yr[1] - yr[0]) / 2 + if self.camera.fov > 0: + # shouldn't really do this but ¯\_(ツ)_/¯ + self.camera.zoom *= self.camera.projection_matrix_inverse[1, 1] / hh + else: + self.camera.height = (yr[1] - yr[0]) * self.camera.zoom + self.camera.local.y = (yr[0] + yr[1]) / 2 + def remove_graphic(self, graphic: Graphic): """ Remove a ``Graphic`` from the scene. Note: This does not garbage collect the graphic, @@ -870,7 +947,7 @@ def remove_graphic(self, graphic: Graphic): """ - if isinstance(graphic, (BaseSelector, Legend)): + if isinstance(graphic, (SelectorProtocol, Legend)): self.scene.remove(graphic.world_object) elif isinstance(graphic, Graphic): @@ -889,7 +966,7 @@ def delete_graphic(self, graphic: Graphic): if graphic not in self: raise KeyError(f"Graphic not found in plot area: {graphic}") - if isinstance(graphic, BaseSelector): + if isinstance(graphic, SelectorProtocol): self._selectors.remove(graphic) elif isinstance(graphic, Legend): diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 73f669fe5..89329a3db 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -9,7 +9,7 @@ from ._utils import create_camera, create_controller from ._plot_area import PlotArea from ._frame import Frame -from ..graphics._axes import Axes +from ..axes import Axes class Subplot(PlotArea): @@ -62,10 +62,7 @@ def __init__( self._docks = dict() - if "Imgui" in parent.__class__.__name__: - toolbar_visible = True - else: - toolbar_visible = False + toolbar_visible = "Imgui" in parent.__class__.__name__ super().__init__( parent=parent, @@ -83,6 +80,11 @@ def __init__( self.docks[pos] = dv self.children.append(dv) + # imgui windows confined to this subplot, keyed by location + self._imgui_windows = {loc: None for loc in ["left", "right", "top", "bottom", "toolbar"]} + + self._imgui_right_click = None + self._axes = Axes(self) self.scene.add(self.axes.world_object) @@ -93,6 +95,7 @@ def __init__( resizeable=resizeable, title=name, docks=self.docks, + imgui_windows=self._imgui_windows, toolbar_visible=toolbar_visible, canvas_rect=parent.get_pygfx_render_area(), ) @@ -165,6 +168,251 @@ def frame(self) -> Frame: """Frame that the subplot lives in""" return self._frame + @property + def imgui_windows(self) -> dict: + """ + The imgui windows of this subplot, keyed by location. + + The locations are the four edges ["left", "right", "top", "bottom"] and "toolbar" + + Returns + ------- + dict[str, ImguiWindow] + {location: ImguiWindow} + + """ + return self._imgui_windows + + def add_imgui_window( + self, + window=None, + *, + location: str = None, + size: int = None, + title: str = None, + window_flags=None, + ): + """ + Add an imgui window confined to this subplot. Can also be used as a decorator, see the + ``Figure.add_imgui_window`` examples. + + Edge windows ("left", "right", "top", "bottom") reserve space outboard of the subplot dock on that edge. + The "toolbar" location replaces the subplot toolbar. An existing window at a ``location`` is replaced. + + Parameters + ---------- + window: ImguiWindow, optional + an ``ImguiWindow`` instance, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" + edge windows reserve canvas space, "toolbar" replaces the subplot toolbar + + size: int + edge or toolbar thickness in pixels, required for edge windows + + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags, used when decorating, uses the ``ImguiWindow`` default flags if not provided + + """ + figure = self.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError("imgui windows can only be added to a subplot of an ImguiFigure") + + from ..ui._base import ImguiWindow, EDGES, _wrap_update_call + + valid = EDGES + ["toolbar"] + if location not in valid: + raise ValueError( + f"subplot imgui window location must be one of: {valid}, you have passed: {location}" + ) + if location in EDGES and size is None: + raise ValueError(f"must provide `size` for an edge window, location: {location}") + + hook_kwargs = dict(figure=figure, subplot=self, location=location, size=size, title=title) + if window_flags is not None: + hook_kwargs["window_flags"] = window_flags + + def decorator(_window): + if isinstance(_window, ImguiWindow): + win = _window + elif callable(_window): + win = ImguiWindow(update_call=_wrap_update_call(_window, self)) + else: + raise TypeError( + "add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` instance" + ) + + win._fpl_add_hook(**hook_kwargs) + self._imgui_windows[location] = win + + # edge windows reserve space, reset the layout + if location in EDGES: + figure._fpl_reset_layout() + + return _window + + if window is None: + return decorator + + decorator(window) + return window + + def append_imgui_window(self, gui=None, *, location: str = None): + """ + Append imgui elements to an existing window of this subplot. Can also be used as a decorator. Useful for + appending elements to the subplot toolbar with ``location="toolbar"``. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" + location of the existing window to append to + + """ + from ..ui._base import _wrap_update_call + + window = self._imgui_windows.get(location) + if window is None: + raise ValueError(f"no imgui window at location to append to: {location}") + + def decorator(_gui): + window._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_window(self, location: str): + """ + Remove and return the imgui window at the given location + + Parameters + ---------- + location: str + "left" | "right" | "top" | "bottom" | "toolbar" + + Returns + ------- + ImguiWindow + the removed window, it can be added again later + + """ + from ..ui._base import EDGES + + window = self._imgui_windows.get(location) + self._imgui_windows[location] = None + + # edge windows reserve space, reset the layout + if location in EDGES: + self.get_figure()._fpl_reset_layout() + + return window + + @property + def imgui_right_click(self): + """ + The imgui popup that is opened by a right-click within this subplot. + + Returns + ------- + ImguiPopup | None + + """ + return self._imgui_right_click + + def set_imgui_right_click(self, popup=None, *, window_flags=None): + """ + Set the imgui popup that is opened by a right-click within this subplot, replaces the Figure's popup within + this subplot. Can also be used as a decorator, see the ``ImguiFigure.set_imgui_right_click`` examples. + + Parameters + ---------- + popup: ImguiPopup | callable, optional + an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating. + + window_flags: ``imgui.WindowFlags_``, optional + imgui window flags for the popup + + """ + figure = self.get_figure() + if "Imgui" not in figure.__class__.__name__: + raise TypeError( + "imgui right-click popups can only be set on a subplot of an ImguiFigure" + ) + + from ..ui._base import ImguiPopup, _wrap_update_call + + def decorator(_popup): + if isinstance(_popup, ImguiPopup): + p = _popup + elif callable(_popup): + p = ImguiPopup(update_call=_wrap_update_call(_popup, self)) + else: + raise TypeError( + "set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a " + "function that draws imgui elements" + ) + + p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags) + self._imgui_right_click = p + return _popup + + if popup is None: + return decorator + + decorator(popup) + return popup + + def append_imgui_right_click(self, gui=None): + """ + Append imgui elements to the right-click popup of this subplot. Can also be used as a decorator. + + Parameters + ---------- + gui: callable, optional + function that draws imgui elements, omit when decorating + + """ + from ..ui._base import _wrap_update_call + + popup = self._imgui_right_click + if popup is None: + raise ValueError( + "no imgui right-click popup set on this subplot to append to, set one using " + "`subplot.set_imgui_right_click()`" + ) + + def decorator(_gui): + popup._update_calls.append(_wrap_update_call(_gui, self)) + return _gui + + if gui is None: + return decorator + + return decorator(gui) + + def remove_imgui_right_click(self): + """ + Remove and return the right-click popup of this subplot + + Returns + ------- + ImguiPopup + the removed popup, it can be set again later + + """ + popup = self._imgui_right_click + self._imgui_right_click = None + + return popup + class Dock(PlotArea): def __init__( diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 49120c71a..453b1ce11 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -4,7 +4,7 @@ import numpy as np import pygfx -from pygfx import WgpuRenderer, Texture, Renderer +from pygfx import WgpuRenderer, Texture from ..utils.gui import BaseRenderCanvas, RenderCanvas @@ -22,7 +22,7 @@ def make_canvas_and_renderer( canvas: str | BaseRenderCanvas | Texture | None, - renderer: Renderer | None, + renderer: WgpuRenderer | None, canvas_kwargs: dict, ): """ @@ -45,9 +45,13 @@ def make_canvas_and_renderer( if renderer is None: renderer = WgpuRenderer(canvas) - elif not isinstance(renderer, Renderer): + + # disable AA and set pixel_scale = 1.0 for performance + renderer.ppaa = "none" + renderer.pixel_scale = 1.0 + elif not isinstance(renderer, WgpuRenderer): raise TypeError( - f"renderer option must be a pygfx.Renderer instance such as pygfx.WgpuRenderer" + f"renderer option must be a pygfx.WgpuRenderer instance" ) return canvas, renderer diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index 761183f76..9c5492d80 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,9 +1,7 @@ -from ._histogram_lut import HistogramLUTTool from ._textbox import TextBox, Tooltip from ._cursor import Cursor __all__ = [ - "HistogramLUTTool", "TextBox", "Tooltip", "Cursor", diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py deleted file mode 100644 index d651137da..000000000 --- a/fastplotlib/tools/_histogram_lut.py +++ /dev/null @@ -1,439 +0,0 @@ -from math import ceil -from typing import Sequence -import weakref - -import numpy as np - -import pygfx - -from ..utils import subsample_array -from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic -from ..graphics.utils import pause_events -from ..graphics._base import Graphic -from ..graphics.selectors import LinearRegionSelector - - -def _get_image_graphic_events(image_graphic: ImageGraphic) -> list[str]: - """Small helper function to return the relevant events for an ImageGraphic""" - events = ["vmin", "vmax"] - - if not image_graphic.data.value.ndim > 2: - events.append("cmap") - - # if RGB(A), do not add cmap - - return events - - -# TODO: This is a widget, we can think about a BaseWidget class later if necessary -class HistogramLUTTool(Graphic): - _fpl_support_tooltip = False - - def __init__( - self, - data: np.ndarray, - 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: 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: passed to ``Graphic`` - - """ - super().__init__(**kwargs) - - self._nbins = nbins - self._flank_divisor = flank_divisor - - 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) - - self._scale_factor: float = 1.0 - - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data) - - line_data = np.column_stack([hist_scaled, edges_flanked]) - - self._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]) - size = 120 # since it's scaled to 100 - origin = (hist_scaled.max() / 2, 0) - - self._linear_region_selector = LinearRegionSelector( - selection=bounds, - limits=limits, - size=size, - center=origin[0], - axis="y", - 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._vmin * self._scale_factor, - self._vmax * self._scale_factor, - ) - - vmin_str, vmax_str = self._get_vmin_vmax_str() - - self._text_vmin = TextGraphic( - text=vmin_str, - font_size=16, - offset=(0, 0, 0), - anchor="top-left", - outline_color="black", - outline_thickness=0.5, - alpha_mode="solid", - ) - - self._text_vmin.world_object.material.pick_write = False - - self._text_vmax = TextGraphic( - text=vmax_str, - font_size=16, - offset=(0, 0, 0), - anchor="bottom-left", - outline_color="black", - outline_thickness=0.5, - alpha_mode="solid", - ) - - self._text_vmax.world_object.material.pick_write = False - - widget_wo = pygfx.Group() - widget_wo.add( - self._histogram_line.world_object, - self._linear_region_selector.world_object, - self._text_vmin.world_object, - self._text_vmax.world_object, - ) - - self._set_world_object(widget_wo) - - self.world_object.local.scale_x *= -1 - - self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) - - self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) - - self._linear_region_selector.add_event_handler( - self._linear_region_handler, "selection" - ) - - ig_events = _get_image_graphic_events(self.images[0]) - - for ig in self.images: - ig.add_event_handler(self._image_cmap_handler, *ig_events) - - # colorbar for grayscale images - 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") - - self.world_object.add(self._colorbar.world_object) - else: - self._colorbar = None - self._cmap = None - - def _make_colorbar(self, edges_flanked) -> ImageGraphic: - # use the histogram edge values as data for an - # image with 2 columns, this will be our colorbar! - colorbar_data = np.column_stack( - [ - np.linspace( - edges_flanked[0], edges_flanked[-1], ceil(np.ptp(edges_flanked)) - ) - ] - * 2 - ).astype(np.float32) - - colorbar_data /= self._scale_factor - - cbar = ImageGraphic( - data=colorbar_data, - vmin=self.vmin, - vmax=self.vmax, - cmap=self.images[0].cmap, - interpolation="linear", - offset=(-55, edges_flanked[0], -1), - ) - - cbar.world_object.world.scale_x = 20 - self._cmap = self.images[0].cmap - - return cbar - - def _get_vmin_vmax_str(self) -> tuple[str, str]: - if self.vmin < 0.001 or self.vmin > 99_999: - vmin_str = f"{self.vmin:.2e}" - else: - vmin_str = f"{self.vmin:.2f}" - - if self.vmax < 0.001 or self.vmax > 99_999: - vmax_str = f"{self.vmax:.2e}" - else: - vmax_str = f"{self.vmax:.2f}" - - return vmin_str, vmax_str - - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - self._linear_region_selector._fpl_add_plot_area_hook(plot_area) - self._histogram_line._fpl_add_plot_area_hook(plot_area) - - self._plot_area.auto_scale() - self._plot_area.controller.enabled = True - - def _calculate_histogram(self, data): - - # get a subsampled view of this array - data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default - hist, edges = np.histogram(data_ss, bins=self._nbins) - - # used if data ptp <= 10 because event things get weird - # with tiny world objects due to floating point error - # so if ptp <= 10, scale up by a factor - data_interval = edges[-1] - edges[0] - self._scale_factor: int = max(1, 100 * int(10 / data_interval)) - - edges = edges * self._scale_factor - - bin_width = edges[1] - edges[0] - - flank_nbins = int(self._nbins / self._flank_divisor) - flank_size = flank_nbins * bin_width - - flank_left = np.arange(edges[0] - flank_size, edges[0], bin_width) - flank_right = np.arange( - edges[-1] + bin_width, edges[-1] + flank_size, bin_width - ) - - edges_flanked = np.concatenate((flank_left, edges, flank_right)) - - hist_flanked = np.concatenate( - (np.zeros(flank_nbins), hist, np.zeros(flank_nbins)) - ) - - # scale 0-100 to make it easier to see - # float32 data can produce unnecessarily high values - hist_scale_value = hist_flanked.max() - if np.allclose(hist_scale_value, 0): - hist_scale_value = 1 - hist_scaled = hist_flanked / (hist_scale_value / 100) - - if edges_flanked.size > hist_scaled.size: - # we don't care about accuracy here so if it's off by 1-2 bins that's fine - edges_flanked = edges_flanked[: hist_scaled.size] - - return hist, edges, hist_scaled, edges_flanked - - def _linear_region_handler(self, ev): - # must use world coordinate values directly from selection() - # otherwise the linear region bounds jump to the closest bin edges - selected_ixs = self._linear_region_selector.selection - vmin, vmax = selected_ixs[0], selected_ixs[1] - vmin, vmax = vmin / self._scale_factor, vmax / self._scale_factor - self.vmin, self.vmax = vmin, vmax - - def _image_cmap_handler(self, ev): - setattr(self, ev.type, ev.info["value"]) - - @property - def cmap(self) -> str: - return self._cmap - - @cmap.setter - def cmap(self, name: str): - if self._colorbar is None: - return - - with pause_events(*self.images): - for ig in self.images: - ig.cmap = name - - self._cmap = name - self._colorbar.cmap = name - - @property - def vmin(self) -> float: - return self._vmin - - @vmin.setter - def vmin(self, value: float): - 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], - ) - for ig in self.images: - ig.vmin = value - - self._vmin = value - if self._colorbar is not None: - self._colorbar.vmin = value - - vmin_str, vmax_str = self._get_vmin_vmax_str() - self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) - self._text_vmin.text = vmin_str - - @property - def vmax(self) -> float: - return self._vmax - - @vmax.setter - def vmax(self, value: float): - 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 = ( - self._linear_region_selector.selection[0], - value * self._scale_factor, - ) - - for ig in self.images: - ig.vmax = value - - self._vmax = value - if self._colorbar is not None: - self._colorbar.vmax = value - - vmin_str, vmax_str = self._get_vmin_vmax_str() - self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) - self._text_vmax.text = vmax_str - - def set_data(self, data, reset_vmin_vmax: bool = True): - hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data) - - line_data = np.column_stack([hist_scaled, edges_flanked]) - - # set x and y vals - self._histogram_line.data[:, :2] = line_data - - bounds = (edges[0], edges[-1]) - limits = (edges_flanked[0], edges_flanked[-11]) - origin = (hist_scaled.max() / 2, 0) - - if reset_vmin_vmax: - # reset according to the new data - self._linear_region_selector.limits = limits - self._linear_region_selector.selection = bounds - else: - with pause_events(self._linear_region_selector, *self.images): - # don't change the current selection - self._linear_region_selector.limits = limits - - self._data = weakref.proxy(data) - - if self._colorbar is not None: - self._colorbar.clear_event_handlers() - self.world_object.remove(self._colorbar.world_object) - - 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") - - self.world_object.add(self._colorbar.world_object) - else: - self._colorbar = None - self._cmap = None - - # reset plotarea dims - self._plot_area.auto_scale() - - @property - 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"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a " - f"tuple or list or ImageGraphic | ImageVolumeGraphic" - ) - - 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) - - self._images = images - - ig_events = _get_image_graphic_events(self._images[0]) - - 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 - if ev.button != 2: - return - - pos = ev.x, ev.y - - self._plot_area.get_figure().open_popup("colormap-picker", pos, lut_tool=self) - - def _fpl_prepare_del(self): - self._linear_region_selector._fpl_prepare_del() - self._histogram_line._fpl_prepare_del() - del self._histogram_line - del self._linear_region_selector diff --git a/fastplotlib/ui/__init__.py b/fastplotlib/ui/__init__.py index a1e57a9c5..7f6a6ae3d 100644 --- a/fastplotlib/ui/__init__.py +++ b/fastplotlib/ui/__init__.py @@ -1,3 +1,5 @@ -from ._base import BaseGUI, Window, EdgeWindow, Popup, GUI_EDGES +from ._base import ImguiBase, ImguiWindow, ImguiPopup, EDGES, LOCATIONS +from ._utils import ChangeFlag from ._subplot_toolbar import SubplotToolbar -from .right_click_menus import StandardRightClickMenu, ColormapPicker +from ._colorbar import ImguiColorbar +from .right_click_menus import StandardRightClickMenu diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 355edc46d..47f828c1c 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -1,16 +1,38 @@ -import enum +from __future__ import annotations +import inspect +from collections.abc import Callable +from functools import partial from typing import Literal -import numpy as np from imgui_bundle import imgui -from ..layouts._figure import Figure +from ..layouts._rect import RectManager -GUI_EDGES = ["right", "bottom", "top"] +# edges that reserve space, ordered as they are carved from the render area +EDGES = ["left", "right", "top", "bottom"] +# all valid keyed locations, "toolbar" is subplot only, "floating" uses auto-placement +LOCATIONS = EDGES + ["toolbar", "floating"] -class BaseGUI: + +def _wrap_update_call(func: Callable, parent) -> Callable: + """ + Wrap an imgui draw function for use as a window or popup update call. The parent, a ``Figure``, ``Subplot`` or + ``Graphic``, is passed as the only positional arg if the function accepts one, otherwise the function is called + with no args. + """ + params = inspect.signature(func).parameters.values() + takes_arg = any( + p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL) + for p in params + ) + if takes_arg: + return partial(func, parent) + return func + + +class ImguiBase: """ Base class for all ImGUI based GUIs, windows and popups @@ -22,51 +44,106 @@ class BaseGUI: ID_COUNTER: int = 0 def __init__(self): - BaseGUI.ID_COUNTER += 1 - self._id_counter = BaseGUI.ID_COUNTER + ImguiBase.ID_COUNTER += 1 + self._id_counter = ImguiBase.ID_COUNTER - def update(self): + def draw(self): """must be implemented in subclass""" raise NotImplementedError -class Window(BaseGUI): - """Base class for imgui windows drawn within Figures""" +class ImguiWindow(ImguiBase): + def __init__(self, update_call: Callable = None): + """ + An imgui window drawn within a Figure. Subclass and implement ``update()`` to draw imgui elements, or pass a + callable as ``update_call`` (this is what the ``add_imgui_window()`` decorator does). - pass + Windows are not added directly, use ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()`` which + provide the host and placement, i.e. location, size, window flags, etc., via ``_fpl_add_hook()``. + Parameters + ---------- + update_call: callable + a callable that draws imgui elements, used instead of ``update()`` when decorating, see ``add_imgui_window`` -class EdgeWindow(Window): - def __init__( + """ + super().__init__() + + # imgui element draw calls, run in order within the window on each render + if update_call is None: + self._update_calls = [self.update] + else: + self._update_calls = [update_call] + + # host and placement, set by the host in add_imgui_window() via _fpl_add_hook() + self._figure = None + self._subplot = None + self._location = None + self._size = None + self._rect_manager = None + self._floating = False + self._title = None + self._window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + ) + + # pixel rect, set by the host on each layout pass + self._x, self._y, self._width, self._height = 0, 0, 0, 0 + + # resize and collapse state, only used by figure-level resizeable edge windows + self._resize_cursor_set = False + self._resize_blocked = False + self._right_gui_resizing = False + self._separator_thickness = 14.0 + self._collapsed = False + self._old_size = None + + def _fpl_add_hook( self, - figure: Figure, - size: int, - location: Literal["bottom", "right", "top"], - title: str, - window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse - | imgui.WindowFlags_.no_resize, - *args, - **kwargs, + figure, + subplot=None, + location: Literal["left", "right", "top", "bottom", "toolbar", "floating"] = None, + size: int = None, + rect: tuple = None, + extent: tuple = None, + title: str = None, + window_flags: imgui.WindowFlags_ = None, ): """ - A base class for imgui windows displayed at the bottom or top edge of a Figure + Set the host and placement of this window, called by ``Figure.add_imgui_window()`` or + ``Subplot.add_imgui_window()``. Parameters ---------- - figure: Figure - Figure instance that this window will be placed in + figure: ImguiFigure + the figure this window is drawn in + + subplot: Subplot, optional + the subplot this window is confined to, ``None`` for figure-level windows + + location: str, "left" | "right" | "top" | "bottom" | "toolbar" | "floating" + edge and toolbar windows reserve canvas space, "floating" is auto-sized and draggable size: int - width or height of the window, depending on its location + edge or toolbar thickness in pixels + + rect: (x, y, w, h), optional + fractional or pixel rect for a fixed floating window - location: str, "bottom" | "right" - location of the window + extent: (xmin, xmax, ymin, ymax), optional + fractional or pixel extent for a fixed floating window - title: str - window title + title: str, optional + window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn. - window_flags: enum.IntFlag - Window flag enum, can be compared with ``|`` operator. Valid flags are: + window_flags: ``imgui.WindowFlags_`` + window flag enum, can be combined with the ``|`` operator. If not provided, the default depends on the + placement: edge and toolbar windows use ``no_collapse | no_resize | no_title_bar | + no_bring_to_front_on_focus`` (custom title bar, and they stay behind floating and fixed overlays); + floating windows use ``none`` (native imgui title bar, collapsible and movable); fixed rect/extent + windows use ``no_collapse | no_move | no_resize`` (native imgui title bar). Valid flags are: .. code-block:: py @@ -94,42 +171,71 @@ def __init__( imgui.WindowFlags_.no_decoration imgui.WindowFlags_.no_inputs - *args - additional args for the GUI - - **kwargs - additional kwargs for teh GUI """ - super().__init__() - - if location not in GUI_EDGES: - f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" - self._figure = figure - self._size = size + self._subplot = subplot self._location = location + self._size = int(size) if size is not None else None self._title = title + self._floating = location == "floating" + + if rect is not None: + width, height = figure.canvas.get_logical_size() + self._rect_manager = RectManager(*rect, (0, 0, width, height)) + elif extent is not None: + width, height = figure.canvas.get_logical_size() + self._rect_manager = RectManager.from_extent(extent, (0, 0, width, height)) + + if window_flags is None: + # edge and toolbar windows draw their own title bar; floating and fixed windows use the native + # imgui title bar so they can be collapsed, and floating windows can also be moved + if location in EDGES or location == "toolbar": + # reserved windows never come to front on focus, otherwise clicking one would bury a + # floating or fixed overlay drawn over it and make the overlay inaccessible + window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize + | imgui.WindowFlags_.no_title_bar + | imgui.WindowFlags_.no_bring_to_front_on_focus + ) + elif location == "floating": + window_flags = imgui.WindowFlags_.none + else: + # fixed rect or extent window + window_flags = ( + imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_move + | imgui.WindowFlags_.no_resize + ) self._window_flags = window_flags - self._x, self._y, self._width, self._height = self.get_rect() - - self._figure.canvas.add_event_handler(self._set_rect, "resize") + @property + def location(self) -> str: + """location of the window""" + return self._location @property def size(self) -> int | None: - """width or height of the edge window""" + """edge or toolbar thickness in pixels, ``None`` for floating and fractional windows""" return self._size @size.setter - def size(self, value): + def size(self, value: int): if not isinstance(value, int): - raise TypeError + raise TypeError(f"{self.__class__.__name__}.size must be an ") self._size = value + # reserving windows change the layout when resized + if self._reserves and self._figure is not None: + self._figure._fpl_reset_layout() @property - def location(self) -> str: - """location of the window""" - return self._location + def window_flags(self) -> imgui.WindowFlags_: + """imgui window flags""" + return self._window_flags + + @window_flags.setter + def window_flags(self, flags: imgui.WindowFlags_): + self._window_flags = flags @property def x(self) -> int: @@ -143,7 +249,7 @@ def y(self) -> int: @property def width(self) -> int: - """with the window""" + """width of the window""" return self._width @property @@ -151,99 +257,381 @@ def height(self) -> int: """height of the window""" return self._height - def _set_rect(self, *args): - self._x, self._y, self._width, self._height = self.get_rect() + @property + def _reserves(self) -> bool: + """whether this window reserves canvas space, i.e. edge or toolbar windows""" + return self._location in EDGES or self._location == "toolbar" + + def _fpl_set_rect(self, x: int, y: int, width: int, height: int): + """set the pixel rect, called by the host on each layout pass""" + self._x, self._y, self._width, self._height = x, y, width, height + + def _draw_resize_handle(self): + if self._location not in ("bottom", "right"): + return - def get_rect(self) -> tuple[int, int, int, int]: + if self._location == "bottom": + imgui.set_cursor_pos((0, 0)) + imgui.invisible_button("##resize_handle", imgui.ImVec2(imgui.get_window_width(), self._separator_thickness)) + + hovered = imgui.is_item_hovered() + active = imgui.is_item_active() + + # Get the actual screen rect of the button after it's been laid out + rect_min = imgui.get_item_rect_min() + rect_max = imgui.get_item_rect_max() + + elif self._location == "right": + imgui.set_cursor_pos((0, 0)) + screen_pos = imgui.get_cursor_screen_pos() + win_height = imgui.get_window_height() + mouse_pos = imgui.get_mouse_pos() + + rect_min = imgui.ImVec2(screen_pos.x, screen_pos.y) + rect_max = imgui.ImVec2(screen_pos.x + self._separator_thickness, screen_pos.y + win_height) + + hovered = ( + rect_min.x <= mouse_pos.x <= rect_max.x + and rect_min.y <= mouse_pos.y <= rect_max.y + ) + + if hovered and imgui.is_mouse_clicked(0): + self._right_gui_resizing = True + + if not imgui.is_mouse_down(0): + self._right_gui_resizing = False + + active = self._right_gui_resizing + + imgui.set_cursor_pos((self._separator_thickness, 0)) + + if hovered and imgui.is_mouse_double_clicked(0): + if not self._collapsed: + self._old_size = self.size + if self._location == "bottom": + self.size = int(self._separator_thickness) + elif self._location == "right": + self.size = int(self._separator_thickness) + self._collapsed = True + else: + self.size = self._old_size + self._collapsed = False + + if hovered or active: + if not self._resize_cursor_set: + if self._location == "bottom": + self._figure.canvas.set_cursor("ns_resize") + + elif self._location == "right": + self._figure.canvas.set_cursor("ew_resize") + + self._resize_cursor_set = True + imgui.set_tooltip("Drag to resize, double click to expand/collapse") + + elif self._resize_cursor_set: + self._figure.canvas.set_cursor("default") + self._resize_cursor_set = False + + if active and imgui.is_mouse_dragging(0): + if self._location == "bottom": + delta = imgui.get_mouse_drag_delta(0).y + + elif self._location == "right": + delta = imgui.get_mouse_drag_delta(0).x + + imgui.reset_mouse_drag_delta(0) + px, py, pw, ph = self._figure.get_pygfx_render_area() + + if self._location == "bottom": + new_render_size = ph + delta + elif self._location == "right": + new_render_size = pw + delta + + # check if the new size would make the pygfx render area too small + if (delta < 0) and (new_render_size < 150): + print("not enough render area") + self._resize_blocked = True + + if self._resize_blocked: + # check if cursor has returned + if self._location == "bottom": + _min, pos, _max = rect_min.y, imgui.get_mouse_pos().y, rect_max.y + + elif self._location == "right": + _min, pos, _max = rect_min.x, imgui.get_mouse_pos().x, rect_max.x + + if ((_min - 5) <= pos <= (_max + 5)) and delta > 0: + # if the mouse cursor is back on the bar and the delta > 0, i.e. render area increasing + self._resize_blocked = False + + if not self._resize_blocked: + self.size = max(30, round(self.size - delta)) + self._collapsed = False + + draw_list = imgui.get_window_draw_list() + + line_color = ( + imgui.get_color_u32(imgui.ImVec4(0.9, 0.9, 0.9, 1.0)) + if (hovered or active) + else imgui.get_color_u32(imgui.ImVec4(0.5, 0.5, 0.5, 0.8)) + ) + bg_color = ( + imgui.get_color_u32(imgui.ImVec4(0.2, 0.2, 0.2, 0.8)) + if (hovered or active) + else imgui.get_color_u32(imgui.ImVec4(0.15, 0.15, 0.15, 0.6)) + ) + + # Background bar + draw_list.add_rect_filled( + imgui.ImVec2(rect_min.x, rect_min.y), + imgui.ImVec2(rect_max.x, rect_max.y), + bg_color, + ) + + # Three grip dots centered on the line + dot_spacing = 7.0 + dot_radius = 2 + if self._location == "bottom": + mid_y = (rect_min.y + rect_max.y) * 0.5 + center_x = (rect_min.x + rect_max.x) * 0.5 + for i in (-1, 0, 1): + cx = center_x + i * dot_spacing + draw_list.add_circle_filled(imgui.ImVec2(cx, mid_y), dot_radius, line_color) + + imgui.set_cursor_pos((0, imgui.get_cursor_pos_y() - imgui.get_style().item_spacing.y)) + + elif self._location == "right": + mid_x = (rect_min.x + rect_max.x) * 0.5 + center_y = (rect_min.y + rect_max.y) * 0.5 + for i in (-1, 0, 1): + cy = center_y + i * dot_spacing + draw_list.add_circle_filled( + imgui.ImVec2(mid_x, cy), dot_radius, line_color + ) + + def _draw_title(self, title: str): + padding = imgui.ImVec2(10, 4) + text_size = imgui.calc_text_size(title) + win_width = imgui.get_window_width() + box_size = imgui.ImVec2(win_width, text_size.y + padding.y * 2) + + box_screen_pos = imgui.get_cursor_screen_pos() + + draw_list = imgui.get_window_draw_list() + + # Background — use imgui's default title bar color + draw_list.add_rect_filled( + imgui.ImVec2(box_screen_pos.x, box_screen_pos.y), + imgui.ImVec2(box_screen_pos.x + box_size.x, box_screen_pos.y + box_size.y), + imgui.get_color_u32(imgui.Col_.title_bg_active), + ) + + # Centered text + text_pos = imgui.ImVec2( + box_screen_pos.x + (win_width - text_size.x) * 0.5, + box_screen_pos.y + padding.y, + ) + draw_list.add_text( + text_pos, imgui.get_color_u32(imgui.ImVec4(1, 1, 1, 1)), title + ) + + imgui.dummy(imgui.ImVec2(win_width, box_size.y)) + + def draw(self): + """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" + # window position & size + if self._floating: + # floating windows are auto-sized by imgui, only set the initial position + imgui.set_next_window_pos((self.x, self.y), imgui.Cond_.appearing) + else: + imgui.set_next_window_size((self.width, self.height)) + imgui.set_next_window_pos((self.x, self.y)) + + # append the id to keep the window unique without changing the visible title + expanded = imgui.begin(f"{self._title or ''}##{self._id_counter}", p_open=None, flags=self._window_flags) + + if self._reserves: + # edge and toolbar windows draw a custom title bar and collapse via the resize handle + # resize handle for right and bottom edge windows on the figure + if self._subplot is None and self._location in ("bottom", "right"): + self._draw_resize_handle() + + # push ID to prevent conflict between multiple figs with same UI + imgui.push_id(self._id_counter) + + # collapse the UI if the separator state is collapsed + # otherwise the UI renders partially on the separator for "right" guis and it looks weird + main_height = 1.0 if self._collapsed else 0.0 + imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) + + if self._title is not None: + self._draw_title(self._title) + + imgui.indent(6.0) + # draw imgui elements from the subclass or decorated function(s) + for update_call in self._update_calls: + update_call() + + imgui.end_child() + imgui.pop_id() + + elif expanded: + # floating and fixed windows use the native imgui title bar; only draw when not collapsed + imgui.push_id(self._id_counter) + for update_call in self._update_calls: + update_call() + imgui.pop_id() + + # end the window + imgui.end() + + def update(self): + """Implement your GUI here and it will be drawn within the window. See the GUI examples""" + raise NotImplementedError + + +class ImguiPopup(ImguiBase): + def __init__(self, update_call: Callable = None): """ - Compute the rect that defines the area this GUI is drawn to + An imgui popup drawn within a Figure, opened by a right-click. Subclass and implement ``update()`` to draw + imgui elements, or pass a callable as ``update_call``. - Returns - ------- - int, int, int, int - x_pos, y_pos, width, height + Popups are not added directly, use ``ImguiFigure.set_imgui_right_click()``, + ``Subplot.set_imgui_right_click()`` or ``Graphic.set_imgui_right_click()`` which provide the parent and + window flags via ``_fpl_add_hook()``. + + Parameters + ---------- + update_call: callable + a callable that draws imgui elements, used instead of ``update()``, see ``set_imgui_right_click`` """ + super().__init__() - width_canvas, height_canvas = self._figure.canvas.get_logical_size() + if update_call is None: + self._update_calls = [self.update] + else: + self._update_calls = [update_call] - match self._location: - case "bottom": - x_pos = 0 - y_pos = height_canvas - self.size - width, height = (width_canvas, self.size) + # parent, set by the parent in set_imgui_right_click() via _fpl_add_hook() + self._figure = None + self._parent = None + self._window_flags = imgui.WindowFlags_.none - case "right": - x_pos, y_pos = (width_canvas - self.size, 0) - width, height = (self.size, height_canvas) + # popups are identified by a str id, the counter keeps it unique between popups + self._popup_id = f"popup##{self._id_counter}" - if self._figure.guis["bottom"] is not None: - height -= self._figure.guis["bottom"].size + # what this popup was opened on, set by the right-click dispatch in Subplot + self._subplot = None + self._graphic = None - if self._figure.guis["top"] is not None: - # decrease the height - height -= self._figure.guis["top"].size - # increase the y start - y_pos += self._figure.guis["top"].size + self._open_requested = False + self._pos = None + self._is_open = False - case "top": - x_pos, y_pos = (0, 0) - width, height = (width_canvas, self.size) + def _fpl_add_hook( + self, + figure, + parent, + window_flags: imgui.WindowFlags_ = None, + ): + """ + Set the parent of this popup, called by ``set_imgui_right_click()``. - return x_pos, y_pos, width, height + Parameters + ---------- + figure: ImguiFigure + the figure this popup is drawn in - def draw_window(self): - """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" - # window position & size - x, y, w, h = self.get_rect() - imgui.set_next_window_size((self.width, self.height)) - imgui.set_next_window_pos((self.x, self.y)) - # imgui.set_next_window_pos((x, y)) - # imgui.set_next_window_size((w, h)) - flags = self._window_flags + parent: ImguiFigure | Subplot | Graphic + the object this popup is set on - # begin window - imgui.begin(self._title, p_open=None, flags=flags) + window_flags: ``imgui.WindowFlags_`` + window flag enum, can be combined with the ``|`` operator, see ``ImguiWindow._fpl_add_hook`` for the + valid flags - # push ID to prevent conflict between multiple figs with same UI - imgui.push_id(self._id_counter) + """ + self._figure = figure + self._parent = parent - # draw stuff from subclass into window - self.update() + if window_flags is not None: + self._window_flags = window_flags - # pop ID - imgui.pop_id() + @property + def parent(self): + """the object this popup is set on, an ``ImguiFigure``, ``Subplot`` or ``Graphic``""" + return self._parent - # end the window - imgui.end() + @property + def subplot(self): + """the subplot this popup was opened in""" + return self._subplot - def update(self): - """Implement your GUI here and it will be drawn within the window. See the GUI examples""" - raise NotImplementedError + @property + def graphic(self): + """the graphic this popup was opened on, ``None`` if it was not opened on a graphic""" + return self._graphic + + @property + def is_open(self) -> bool: + """whether the popup is currently open""" + return self._is_open + + @property + def window_flags(self) -> imgui.WindowFlags_: + """imgui window flags""" + return self._window_flags + @window_flags.setter + def window_flags(self, flags: imgui.WindowFlags_): + self._window_flags = flags -class Popup(BaseGUI): - def __init__(self, figure: Figure, *args, **kwargs): + def open(self, pos: tuple[int, int] = None): """ - Base class for creating ImGUI popups within Figures + Request that this popup is opened on the next render. Parameters ---------- - figure: Figure - Figure instance - *args - any args to pass to subclass constructor + pos: (int, int), optional + canvas position of the popup, imgui uses the current mouse position if not provided - **kwargs - any kwargs to pass to subclass constructor """ + self._pos = pos + self._open_requested = True - super().__init__() + def _fpl_open(self, subplot, graphic): + """set what the popup is opened on and open it, called by the right-click dispatch in ``Subplot``""" + self._subplot = subplot + self._graphic = graphic + self.open() - self._figure = figure + def _fpl_close(self): + """called when another popup replaces this one as the open popup""" + self._is_open = False + + def draw(self): + """helps simplify using imgui by managing the popup open state, and pushing/popping the ID""" + if self._open_requested: + self._open_requested = False + if self._pos is not None: + imgui.set_next_window_pos(self._pos) + imgui.open_popup(self._popup_id) + + if imgui.begin_popup(self._popup_id, self._window_flags): + self._is_open = True - self.is_open = False + # push ID to prevent conflict between multiple figs with same UI + imgui.push_id(self._id_counter) - def open(self, pos: tuple[int, int], *args, **kwargs): - """implement in subclass""" + for update_call in self._update_calls: + update_call() + + imgui.pop_id() + imgui.end_popup() + + else: + self._is_open = False + + def update(self): + """Implement your GUI here and it will be drawn within the popup. See the GUI examples""" raise NotImplementedError diff --git a/fastplotlib/ui/_colorbar.py b/fastplotlib/ui/_colorbar.py new file mode 100644 index 000000000..7de048af9 --- /dev/null +++ b/fastplotlib/ui/_colorbar.py @@ -0,0 +1,635 @@ +import numpy as np +import wgpu +from cmap import Colormap +from imgui_bundle import imgui + +from ..graphics import ImageGraphic, ImageVolumeGraphic +from ..utils.functions import COLORMAP_NAMES, quick_min_max +from ._base import ImguiWindow + + +class ImguiColorbar(ImguiWindow): + LUT_HEIGHT = 256 + TEX_WIDTH = 2 + HANDLE_HEIGHT = 8 + HANDLE_OVERHANG = 3 # how far a handle extends past the bar on each side + BAR_BORDER = 1.0 # width of the outline drawn around the bar image + HIST_WIDTH = 50 # width in pixels of the optional histogram drawn left of the bar + HIST_GAP = 4 # gap in pixels between the histogram and the bar + FILL_OVERHANG = 4 # how far the vmin/vmax fill and lines extend past the histogram line-plot + + def __init__( + self, + images: ImageGraphic | ImageVolumeGraphic | list, + histogram: tuple[np.ndarray, np.ndarray] | None = None, + data_range: tuple[float, float] | None = None, + bar_width: int = 16, + region_drag: bool = True, + ): + """ + An imgui colorbar with draggable vmin/vmax handles, an optional histogram, a gamma slider, and a + right-click colormap picker. + + Parameters + ---------- + images: ImageGraphic | ImageVolumeGraphic | list + the image(s) whose vmin, vmax and cmap this colorbar controls + + histogram: tuple[np.ndarray, np.ndarray], optional + a precomputed ``(counts, edges)`` histogram drawn to the left of the bar. It is not recomputed when the + image data changes, set the ``histogram`` property to update it. + + data_range: (min, max), optional + the value range spanned by the bar. Defaults to the histogram edges if a histogram is provided, + otherwise to the data range of the first image. + + bar_width: int + width of the colored bar in pixels + + region_drag: bool + if ``True``, dragging between the handles shifts the vmin/vmax window without changing its width + """ + super().__init__() + + if isinstance(images, (ImageGraphic, ImageVolumeGraphic)): + images = [images] + self._images = list(images) + if len(self._images) == 0: + raise ValueError("must provide at least one image") + + image = self._images[0] + self._vmin = float(image.vmin) + self._vmax = float(image.vmax) + # rgb(a) images have no cmap, display the bar with "gray" so vmin, vmax are still adjustable + self._cmap_name = image.cmap if image.cmap is not None else "gray" + + self._gamma = 1.0 + self._bar_width = int(bar_width) + self._region_drag = bool(region_drag) + + # offset in data units between the grabbed value and the value under the cursor, captured when a drag + # starts so the handle tracks the cursor without jumping + self._grab_offset = 0.0 + + # prevents feedback loops when syncing vmin, vmax, cmap between this colorbar and the images + self._block_reentrance = False + + # GPU resources, created in _fpl_add_hook() once the figure and its device are known + self._device = None + self._bar_texture = None + self._bar_tex_id = None + self._picker_tex_ids = dict() + + # setting the histogram also sets the value axis to the histogram edges + self._histogram = None + self.histogram = histogram + + # data_range defaults to the histogram edges, otherwise the data range of the first image + if data_range is None: + if self._histogram is not None: + counts, edges = self._histogram + data_range = (float(edges[0]), float(edges[-1])) + else: + data_range = quick_min_max(image.data.value) + self._data_min, self._data_max = self._validate_range(data_range) + + def _fpl_add_hook( + self, + figure, + subplot=None, + location: str = None, + size: int = None, + rect: tuple = None, + extent: tuple = None, + title: str = "", + window_flags=None, + ): + super()._fpl_add_hook( + figure, + subplot=subplot, + location=location, + size=size, + rect=rect, + extent=extent, + title=title, + window_flags=window_flags, + ) + + # the colorbar manages its own layout and should never show a scrollbar + self.window_flags = self._window_flags | imgui.WindowFlags_.no_scrollbar + + self._device = figure.renderer.device + + # a preview texture for each non-qualitative colormap, used in the picker + for category, names in COLORMAP_NAMES.items(): + if category == "qualitative": + continue + for name in names: + self._picker_tex_ids[name] = self._make_picker_texture(name) + + self._bar_texture = self._device.create_texture( + size=(self.TEX_WIDTH, self.LUT_HEIGHT, 1), + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=wgpu.TextureFormat.rgba8unorm, + mip_level_count=1, + sample_count=1, + ) + self._bar_tex_id = figure.imgui_renderer.backend.register_texture( + self._bar_texture.create_view() + ) + self._update_bar_texture() + + # sync the colorbar when an image's vmin, vmax, cmap, or gamma is changed elsewhere + for image in self._images: + self._connect_image(image) + + @property + def images(self) -> tuple: + """get or set the images managed by this colorbar""" + return tuple(self._images) + + @images.setter + def images(self, new_images): + self._disconnect_images() + if isinstance(new_images, (ImageGraphic, ImageVolumeGraphic)): + new_images = [new_images] + self._images = list(new_images) + + # adopt the vmin, vmax, and cmap of the new first image + image = self._images[0] + self._vmin = float(image.vmin) + self._vmax = float(image.vmax) + self._cmap_name = image.cmap if image.cmap is not None else "gray" + self._update_bar_texture() + + for img in self._images: + self._connect_image(img) + + @property + def cmap(self) -> str: + """get or set the colormap""" + return self._cmap_name + + @cmap.setter + def cmap(self, name: str): + if self._block_reentrance or name is None or name == self._cmap_name: + return + self._block_reentrance = True + try: + self._cmap_name = name + self._update_bar_texture() + for image in self._images: + if image.cmap is None: + # rgb(a) images have no cmap + continue + image.cmap = name + finally: + self._block_reentrance = False + + @property + def vmin(self) -> float: + """get or set the lower contrast limit""" + return self._vmin + + @vmin.setter + def vmin(self, value: float): + value = float(value) + if self._block_reentrance or value == self._vmin: + return + self._block_reentrance = True + try: + self._vmin = value + self._update_bar_texture() + for image in self._images: + image.vmin = value + finally: + self._block_reentrance = False + + @property + def vmax(self) -> float: + """get or set the upper contrast limit""" + return self._vmax + + @vmax.setter + def vmax(self, value: float): + value = float(value) + if self._block_reentrance or value == self._vmax: + return + self._block_reentrance = True + try: + self._vmax = value + self._update_bar_texture() + for image in self._images: + image.vmax = value + finally: + self._block_reentrance = False + + @property + def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: + """the histogram as a precomputed (counts, edges) tuple, or ``None`` for no histogram""" + return self._histogram + + @histogram.setter + def histogram(self, value): + if value is None: + self._histogram = None + return + counts, edges = value + counts = np.asarray(counts, dtype=np.float32) + edges = np.asarray(edges, dtype=np.float64) + if edges.shape[0] != counts.shape[0] + 1: + raise ValueError( + "histogram edges must have one more element than counts, you have passed " + f"counts: {counts.shape[0]} and edges: {edges.shape[0]}" + ) + self._histogram = (counts, edges) + + # the histogram defines the value axis + self._data_min = float(edges[0]) + self._data_max = float(edges[-1]) + self._update_bar_texture() + + @property + def data_range(self) -> tuple[float, float]: + """the value range spanned by the bar""" + return (self._data_min, self._data_max) + + @data_range.setter + def data_range(self, value): + self._data_min, self._data_max = self._validate_range(value) + self._update_bar_texture() + + @property + def gamma(self) -> float: + """get or set the gamma, applied to the images and the bar""" + return self._gamma + + @gamma.setter + def gamma(self, value: float): + value = float(value) + if self._block_reentrance or value == self._gamma: + return + self._block_reentrance = True + try: + self._gamma = value + self._update_bar_texture() + for image in self._images: + image.gamma = value + finally: + self._block_reentrance = False + + @property + def bar_width(self) -> int: + """get or set the width of the colored bar in pixels""" + return self._bar_width + + @bar_width.setter + def bar_width(self, value: int): + self._bar_width = int(value) + + @staticmethod + def _validate_range(data_range): + data_min, data_max = float(data_range[0]), float(data_range[1]) + if data_max <= data_min: + raise ValueError( + f"data_range max ({data_max}) must be greater than min ({data_min})" + ) + return data_min, data_max + + def _image_event_handler(self, ev): + """when an image's vmin, vmax, or cmap changes, update this colorbar to match""" + setattr(self, ev.type, ev.info["value"]) + + def _connect_image(self, image): + """subscribe to an image's vmin, vmax and gamma events, and its cmap if it is grayscale""" + events = ["vmin", "vmax", "gamma"] + # rgb(a) images have no cmap feature to listen to + if image.cmap is not None: + events.append("cmap") + image.add_event_handler(self._image_event_handler, *events) + + def _disconnect_images(self, *args): + """disconnect the event handlers of the managed images""" + for image in self._images: + for ev, handlers in image.event_handlers: + if self._image_event_handler in handlers: + image.remove_event_handler(self._image_event_handler, ev) + + def _make_picker_texture(self, name): + lut = (Colormap(name)(np.linspace(0, 1, 256)) * 255).astype(np.uint8) + data = np.ascontiguousarray(np.tile(lut[None, :, :], (2, 1, 1))) + h, w = data.shape[:2] + texture = self._device.create_texture( + size=(w, h, 1), + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=wgpu.TextureFormat.rgba8unorm, + mip_level_count=1, + sample_count=1, + ) + self._device.queue.write_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"offset": 0, "bytes_per_row": w * 4}, + (w, h, 1), + ) + return self._renderer.backend.register_texture(texture.create_view()) + + def _update_bar_texture(self): + if self._bar_texture is None: + # not added to a figure yet, no device + return + # the bar spans the flanked axis so it aligns with the histogram and the handles + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + lo = (self._vmin - axis_min) / span + hi = (self._vmax - axis_min) / span + t = np.linspace(1.0, 0.0, self.LUT_HEIGHT) + norm = np.clip((t - lo) / (hi - lo), 0.0, 1.0) + norm = norm ** self._gamma + colors = (Colormap(self._cmap_name)(norm) * 255).astype(np.uint8) + data = np.ascontiguousarray(np.tile(colors[:, None, :], (1, self.TEX_WIDTH, 1))) + self._device.queue.write_texture( + {"texture": self._bar_texture, "mip_level": 0, "origin": (0, 0, 0)}, + data, + {"offset": 0, "bytes_per_row": self.TEX_WIDTH * 4}, + (self.TEX_WIDTH, self.LUT_HEIGHT, 1), + ) + + @property + def _renderer(self): + return self._figure.imgui_renderer + + def _axis_range(self) -> tuple[float, float]: + """the value axis: the data range flanked on each side so handles can move past the data extremes""" + flank = 0.1 * (self._data_max - self._data_min) + return self._data_min - flank, self._data_max + flank + + def update(self): + draw_list = imgui.get_window_draw_list() + avail = imgui.get_content_region_avail() + line_h = imgui.get_text_line_height_with_spacing() + + p0 = imgui.get_cursor_screen_pos() + total_h = avail.y + + bar_w = self._bar_width + # the value axis spans the height minus a line of padding at the top and bottom + bar_y = p0.y + line_h + bar_h = max(50.0, total_h - 2 * line_h) + + # accumulated across the region lines and bar handles to drive the resize cursor + self._hovering_handle = False + + # anchor the bar to the right edge of the window; the histogram and value text sit to its left, + # the handle overhang stays within the window padding + bar_x = p0.x + avail.x - self.HANDLE_OVERHANG - bar_w + + has_hist = self._histogram is not None + if has_hist: + # the histogram has a fixed width (HIST_WIDTH), drawn to the left of the bar + hist_x_right = bar_x - self.HIST_GAP + hist_x_left = hist_x_right - self.HIST_WIDTH + + # histogram line profile, inset so the vmin/vmax fill and lines extend beyond it + self._draw_histogram( + draw_list, + hist_x_left + self.FILL_OVERHANG, + hist_x_right - self.FILL_OVERHANG, + bar_y, + bar_h, + ) + # draggable vmin, vmax lines, shaded region, and value text drawn over the histogram + self._draw_region(hist_x_left, hist_x_right, bar_y, bar_h) + + # the colorbar bar + imgui.set_cursor_screen_pos((bar_x, bar_y)) + imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0)) + imgui.push_style_var(imgui.StyleVar_.image_border_size, self.BAR_BORDER) + imgui.image(self._bar_tex_id, image_size=(bar_w - 2 * self.BAR_BORDER, bar_h)) + imgui.pop_style_var() + imgui.pop_style_color() + + # right-click for the gamma slider and colormap picker + if imgui.begin_popup_context_window("##colorbar_popup"): + self._draw_popup() + imgui.end_popup() + + # without a histogram the vmin, vmax handles live on the bar itself + if not has_hist: + self._draw_bar_handles(bar_x, bar_y, bar_w, bar_h) + + # show a vertical-resize cursor while hovering any handle + if self._hovering_handle and not self._resize_cursor_set: + self._figure.canvas.set_cursor("ns_resize") + self._resize_cursor_set = True + elif not self._hovering_handle and self._resize_cursor_set: + self._figure.canvas.set_cursor("default") + self._resize_cursor_set = False + + def _value_to_y(self, v, y0, bar_h): + axis_min, axis_max = self._axis_range() + return y0 + (1.0 - (v - axis_min) / (axis_max - axis_min)) * bar_h + + def _y_to_value(self, y, y0, bar_h): + axis_min, axis_max = self._axis_range() + return axis_min + (1.0 - (y - y0) / bar_h) * (axis_max - axis_min) + + def _draw_histogram(self, draw_list, x_left, x_right, bar_y, bar_h): + counts, edges = self._histogram + cmin = counts.min() + cmax = counts.max() + span = cmax - cmin + if span <= 0: + return + + color = imgui.color_convert_float4_to_u32((0.7, 0.7, 0.7, 1.0)) + hist_w = x_right - x_left + if hist_w <= 0: + return + # min count maps to the right edge next to the bar, max count to the left edge, filling the width + norm = (counts - cmin) / span + centers = 0.5 * (edges[:-1] + edges[1:]) + + # frequency increases to the left, away from the bar, value maps to y, drawn as a line profile + points = [ + imgui.ImVec2(x_right - frac * hist_w, self._value_to_y(c, bar_y, bar_h)) + for frac, c in zip(norm, centers) + ] + draw_list.add_polyline(points, color, 1.5, 0) + + def _draw_region(self, x_left, x_right, bar_y, bar_h): + draw_list = imgui.get_window_draw_list() + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + # yellow highlight when a line is hovered/dragged, like the HistogramLUTTool + yellow = imgui.color_convert_float4_to_u32((1.0, 1.0, 0.0, 1.0)) + # dark blue fill, the same color as the HistogramLUTTool LinearRegionSelector + fill_color = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.35, 0.4)) + + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + width = x_right - x_left + grab = self.HANDLE_HEIGHT + min_sep = (grab / bar_h) * span + + def cursor_value(): + # the data value under the cursor. Lines track this absolute position (plus the grab offset) + # rather than accumulating per-frame deltas, so a fast drag past an edge pins the line to the extreme + return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) + + # shaded fill between the vmin and vmax lines + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + draw_list.add_rect_filled((x_left, y_vmax), (x_right, y_vmin), fill_color) + + # drag the region between the lines to move both together + if self._region_drag: + top = y_vmax + grab / 2 + bottom = y_vmin - grab / 2 + if bottom > top: + imgui.set_cursor_screen_pos((x_left, top)) + imgui.invisible_button("##region", (width, bottom - top)) + if imgui.is_item_activated(): + self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + if imgui.is_item_active(): + half = 0.5 * (self._vmax - self._vmin) + center = cursor_value() + self._grab_offset + center = max(axis_min + half, min(axis_max - half, center)) + self.vmin = center - half + self.vmax = center + half + + # each line has a hit-window for hovering/dragging; the line turns yellow when hovered or dragged + for label, attr, lo_fn, hi_fn in ( + ("##vmax_line", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), + ("##vmin_line", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ): + cur = getattr(self, attr) + y = self._value_to_y(cur, bar_y, bar_h) + imgui.set_cursor_screen_pos((x_left, y - grab / 2)) + imgui.invisible_button(label, (width, grab)) + hovered = imgui.is_item_hovered() or imgui.is_item_active() + self._hovering_handle = self._hovering_handle or hovered + if imgui.is_item_activated(): + self._grab_offset = cur - cursor_value() + if imgui.is_item_active(): + setattr(self, attr, max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset))) + y = self._value_to_y(getattr(self, attr), bar_y, bar_h) + draw_list.add_line((x_left, y), (x_right, y), yellow if hovered else white, 2.0) + + # current vmax above its line, vmin below its line + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + self._text_right(draw_list, f"{self._vmax:.4g}", x_right, y_vmax - imgui.get_text_line_height()) + self._text_right(draw_list, f"{self._vmin:.4g}", x_right, y_vmin) + + def _text_right(self, draw_list, text: str, x_right: float, y: float): + """draw text right-aligned so it ends at x_right""" + tw = imgui.calc_text_size(text).x + draw_list.add_text((x_right - tw, y), imgui.get_color_u32(imgui.Col_.text), text) + + def _draw_bar_handles(self, bar_x, bar_y, bar_w, bar_h): + draw_list = imgui.get_window_draw_list() + white = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + # yellow highlight when a handle is hovered/dragged, like the region lines + yellow = imgui.color_convert_float4_to_u32((1.0, 1.0, 0.0, 1.0)) + outline = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.0, 1.0)) + text_color = imgui.get_color_u32(imgui.Col_.text) + + axis_min, axis_max = self._axis_range() + span = axis_max - axis_min + h = self.HANDLE_HEIGHT + # the handles extend past the bar on each side + x_left = bar_x - self.HANDLE_OVERHANG + x_right = bar_x + bar_w + self.HANDLE_OVERHANG + min_sep = (h / bar_h) * span + + def cursor_value(): + return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h) + + # thin reference lines at the data min and max, so the flank beyond the data range is visible + ref = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0)) + for v in (self._data_min, self._data_max): + y = self._value_to_y(v, bar_y, bar_h) + draw_list.add_line((x_left, y), (x_right, y), ref, 1.0) + + # drag the region between the handles to move vmin and vmax together + if self._region_drag: + y_vmax = self._value_to_y(self._vmax, bar_y, bar_h) + y_vmin = self._value_to_y(self._vmin, bar_y, bar_h) + top = y_vmax + h / 2 + bottom = y_vmin - h / 2 + if bottom > top: + imgui.set_cursor_screen_pos((x_left, top)) + imgui.invisible_button("##bar_region", (x_right - x_left, bottom - top)) + if imgui.is_item_activated(): + self._grab_offset = 0.5 * (self._vmin + self._vmax) - cursor_value() + if imgui.is_item_active(): + half = 0.5 * (self._vmax - self._vmin) + center = cursor_value() + self._grab_offset + center = max(axis_min + half, min(axis_max - half, center)) + self.vmin = center - half + self.vmax = center + half + + for label, attr, lo_fn, hi_fn in ( + ("##bar_vmax", "vmax", lambda: self._vmin + min_sep, lambda: axis_max), + ("##bar_vmin", "vmin", lambda: axis_min, lambda: self._vmax - min_sep), + ): + cur = getattr(self, attr) + y = self._value_to_y(cur, bar_y, bar_h) + imgui.set_cursor_screen_pos((x_left, y - h / 2)) + imgui.invisible_button(label, (x_right - x_left, h)) + hovered = imgui.is_item_hovered() or imgui.is_item_active() + self._hovering_handle = self._hovering_handle or hovered + if imgui.is_item_activated(): + self._grab_offset = cur - cursor_value() + if imgui.is_item_active(): + setattr(self, attr, max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset))) + y = self._value_to_y(getattr(self, attr), bar_y, bar_h) + + draw_list.add_rect_filled((x_left, y - h / 2), (x_right, y + h / 2), yellow if hovered else white) + draw_list.add_rect((x_left, y - h / 2), (x_right, y + h / 2), outline, thickness=1.0) + + # current value to the left of the bar, vmax above its handle and vmin below + text = f"{getattr(self, attr):.4g}" + ty = y - imgui.get_text_line_height() if attr == "vmax" else y + tw = imgui.calc_text_size(text).x + draw_list.add_text((x_left - 3 - tw, ty), text_color, text) + + def _draw_popup(self): + imgui.set_next_item_width(150) + changed, gamma = imgui.slider_float("gamma", self._gamma, 0.1, 5.0) + if changed: + self.gamma = gamma + + # reset vmin, vmax using the data of each image + if imgui.menu_item("Reset vmin-vmax", "", False)[0]: + for image in self._images: + image.reset_vmin_vmax() + + # reset gamma to 1.0 + if imgui.menu_item("Reset gamma", "", False)[0]: + self.gamma = 1.0 + + texture_height = imgui.get_font_size() - 2 + + # colormaps grouped by category, qualitative colormaps are not useful for a continuous colorbar + for category, names in COLORMAP_NAMES.items(): + if category == "qualitative": + continue + + imgui.separator() + imgui.text(category.capitalize()) + + for name in names: + 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) + imgui.image(self._picker_tex_ids[name], image_size=(75, texture_height)) + imgui.pop_style_var() + imgui.pop_style_color() + + imgui.same_line() + + clicked, selected = imgui.selectable(name, p_selected=(name == self._cmap_name)) + if clicked and selected: + self.cmap = name diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index 435de4206..4c1bd289a 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -1,20 +1,18 @@ from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx -from ..layouts._subplot import Subplot -from ._base import Window +from ._base import ImguiWindow from ..layouts._utils import IMGUI_TOOLBAR_HEIGHT -class SubplotToolbar(Window): - def __init__(self, subplot: Subplot): +class SubplotToolbar(ImguiWindow): + def __init__(self): """ - Subplot toolbar shown below all subplots + Subplot toolbar shown below all subplots. The subplot is provided via ``_fpl_add_hook()`` when the + toolbar is added to the subplot. """ super().__init__() - self._subplot = subplot - - def update(self): + def draw(self): # get subplot rect x, y, width, height = self._subplot.frame.rect @@ -27,12 +25,26 @@ def update(self): imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_background + # stay behind floating and fixed overlays so they remain accessible when drawn over the toolbar + | imgui.WindowFlags_.no_bring_to_front_on_focus ) imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags) # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) + + # draw the toolbar and any appended imgui elements + for update_call in self._update_calls: + update_call() + + # pop id when all UI has been written to window + imgui.pop_id() + + # end window + imgui.end() + + def update(self): with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): @@ -59,9 +71,3 @@ def update(self): ) if imgui.is_item_hovered(0): imgui.set_tooltip("maintain aspect") - - # pop id when all UI has been written to window - imgui.pop_id() - - # end window - imgui.end() diff --git a/fastplotlib/ui/_utils.py b/fastplotlib/ui/_utils.py new file mode 100644 index 000000000..32c6f9f68 --- /dev/null +++ b/fastplotlib/ui/_utils.py @@ -0,0 +1,46 @@ +class ChangeFlag: + """ + A flag that helps detect whether an imgui UI has been changed by the user. + Basically, once True, always True. + + Example:: + + changed = ChangeFlag(False) + + changed.value, bah = (False, False) + + print(changed.value) + + changed.value, bah = (True, False) + + print(changed.value) + + changed.value, bah = (False, False) + + print(changed.value) + + """ + + def __init__(self, value: bool): + self._value = bool(value) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool): + if value: + self._value = True + + def __bool__(self): + return self.value + + def __or__(self, other): + return self._value | other + + def __eq__(self, other): + return self.value == other + + def force_value(self, value): + self._value = value diff --git a/fastplotlib/ui/right_click_menus/__init__.py b/fastplotlib/ui/right_click_menus/__init__.py index 6ccc50646..a32b87263 100644 --- a/fastplotlib/ui/right_click_menus/__init__.py +++ b/fastplotlib/ui/right_click_menus/__init__.py @@ -1,2 +1 @@ -from ._colormap_picker import ColormapPicker from ._standard_menu import StandardRightClickMenu diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py deleted file mode 100644 index a80e5b2aa..000000000 --- a/fastplotlib/ui/right_click_menus/_colormap_picker.py +++ /dev/null @@ -1,175 +0,0 @@ -import ctypes - -import numpy as np -import cmap - -import wgpu -from imgui_bundle import imgui -from wgpu import GPUTexture - -from .. import Popup -from ...utils.functions import ( - COLORMAP_NAMES, - SEQUENTIAL_CMAPS, - CYCLIC_CMAPS, - DIVERGING_CMAPS, - MISC_CMAPS, -) - -all_cmaps = [*SEQUENTIAL_CMAPS, *CYCLIC_CMAPS, *DIVERGING_CMAPS, *MISC_CMAPS] - - -class ColormapPicker(Popup): - """Colormap picker menu popup tool""" - - # name used to trigger this popup after it has been registered with a Figure - name = "colormap-picker" - - def __init__(self, figure): - super().__init__(figure=figure) - - self.renderer = self._figure.renderer - self.imgui_renderer = self._figure.imgui_renderer - - # maps str cmap names -> int texture IDs - 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: - # get data that represents cmap - colormap = cmap.Colormap(name) - data = colormap(np.linspace(0, 1)) * 255 - - # needs to be 2D to create a texture - data = np.vstack([[data]] * 2).astype(np.uint8) - - # upload the texture to the GPU, get the texture ID and texture - self._cmap_texture_refs[name] = self._create_texture_and_upload(data) - - # used to set the states of the UI - self._lut_tool = None - self._pos: tuple[int, int] = -1, -1 - self._open_new: bool = False - - self.is_open = False - - self._popup_state = "never-opened" - - self._texture_height = None - - def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture]: - """crates a GPUTexture from the 2D data and uploads it""" - - # create a GPUTexture - texture = self.renderer.device.create_texture( - size=(data.shape[1], data.shape[0], 4), - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, - dimension=wgpu.TextureDimension.d2, - format=wgpu.TextureFormat.rgba8unorm, - mip_level_count=1, - sample_count=1, - ) - - # upload to the GPU - self.renderer.device.queue.write_texture( - {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, - data, - {"offset": 0, "bytes_per_row": data.shape[1] * 4}, - (data.shape[1], data.shape[0], 1), - ) - - # get a view - texture_view = texture.create_view() - - # return texture ref - return self.imgui_renderer.backend.register_texture(texture_view) - - def open(self, pos: tuple[int, int], lut_tool): - """ - Request that the popup be opened on the next render cycle - - Parameters - ---------- - pos: int, int - (x, y) position - - lut_tool: HistogramLUTTool - instance of the LUT tool - - Returns - ------- - - """ - self._lut_tool = lut_tool - - self._pos = pos - - self._open_new = True - - def close(self): - """cleanup after popup has closed""" - self._lut_tool = None - self._open_new = False - self._pos = -1, -1 - - self.is_open = False - - def _add_cmap_menu_item(self, cmap_name: str): - # 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_ref, - image_size=(50, self._texture_height), - ) - # pop white border - imgui.pop_style_var() - imgui.pop_style_color() - - imgui.same_line() - - clicked, selected = imgui.selectable( - label=cmap_name, - p_selected=cmap_name == self._lut_tool.cmap, - ) - - if clicked and selected: - self._lut_tool.cmap = cmap_name - - def update(self): - if self._open_new: - # new popup has been triggered by a LUT tool - self._open_new = False - - imgui.set_next_window_pos(self._pos) - imgui.open_popup("cmap-picker") - - if imgui.begin_popup("cmap-picker"): - self.is_open = True - - # make the cmap image height the same as the text height - self._texture_height = (imgui.get_font_size()) - 2 - - if imgui.menu_item("Reset vmin-vmax", "", False)[0]: - self._lut_tool.images[0].reset_vmin_vmax() - - # add all the cmap options - for cmap_type in COLORMAP_NAMES.keys(): - if cmap_type == "qualitative": - continue - - imgui.separator() - imgui.text(cmap_type.capitalize()) - - for cmap_name in COLORMAP_NAMES[cmap_type]: - self._add_cmap_menu_item(cmap_name) - - imgui.end_popup() - - else: - # popup went from open to closed - if self.is_open == True: - self.close() diff --git a/fastplotlib/ui/right_click_menus/_image_adjust.py b/fastplotlib/ui/right_click_menus/_image_adjust.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index bb9e5bdef..d5a25bca4 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -2,7 +2,7 @@ from ...layouts._utils import controller_types from ...layouts._plot_area import PlotArea -from ...ui import Popup +from ...ui import ImguiPopup def flip_axis(subplot: PlotArea, axis: str, flip: bool): @@ -19,167 +19,126 @@ def flip_axis(subplot: PlotArea, axis: str, flip: bool): setattr(camera.local, axis_attr, scale * -1) -class StandardRightClickMenu(Popup): +class StandardRightClickMenu(ImguiPopup): """Right click menu that is shown on subplots""" - def __init__(self, figure): - super().__init__(figure=figure) + def __init__(self): + super().__init__() - self._last_right_click_pos = None - self._mouse_down: bool = False + # the subplot whose controller window is open, False if no controller window is open + self._controller_window_open: bool | PlotArea = False - # whether the right click menu is currently open or not - self.is_open: bool = False + def update(self): + subplot = self.subplot - def get_subplot(self) -> PlotArea | bool | None: - """get the subplot that a click occurred in""" - if self._last_right_click_pos is None: - return False + if subplot.name is not None: + # text label at the top of the menu + imgui.text(f"subplot: {subplot.name}") + imgui.separator() - for subplot in self._figure: - if subplot.viewport.is_inside(*self._last_right_click_pos): - return subplot + _, show_fps = imgui.menu_item("Show fps", "", self._figure.imgui_show_fps) + self._figure.imgui_show_fps = show_fps - # not inside a subplot - return False + # autoscale, center, maintain aspect + if imgui.menu_item("Autoscale", "", False)[0]: + subplot.auto_scale() - def cleanup(self): - """called when the popup disappears""" - self.is_open = False + if imgui.menu_item("Center", "", False)[0]: + subplot.center_scene() - def update(self): - if imgui.is_mouse_down(1) and not self._mouse_down: - # mouse button was pressed down, store this position - self._mouse_down = True - self._last_right_click_pos = imgui.get_mouse_pos() - - if imgui.is_mouse_released(1) and self._mouse_down: - self._mouse_down = False - - # open popup only if mouse was not moved between mouse_down and mouse_up events - if self._last_right_click_pos == imgui.get_mouse_pos(): - if self.get_subplot() is not False: # must explicitly check for False - # open only if right click was inside a subplot - imgui.open_popup(f"right-click-menu") - - # TODO: call this just once when going from open -> closed state - if not imgui.is_popup_open("right-click-menu"): - self.cleanup() - - if imgui.begin_popup(f"right-click-menu"): - if self.get_subplot() is False: # must explicitly check for False - # for some reason it will still trigger at certain locations - # despite open_popup() only being called when an actual - # subplot is returned - imgui.end_popup() - imgui.close_current_popup() - self.cleanup() - return - - name = self.get_subplot().name - - if name is not None: - # text label at the top of the menu - imgui.text(f"subplot: {name}") - imgui.separator() - - _, show_fps = imgui.menu_item( - "Show fps", "", self.get_subplot().get_figure().imgui_show_fps - ) - self.get_subplot().get_figure().imgui_show_fps = show_fps + _, maintain_aspect = imgui.menu_item( + "Maintain Aspect", "", subplot.camera.maintain_aspect + ) + subplot.camera.maintain_aspect = maintain_aspect - # autoscale, center, maintain aspect - if imgui.menu_item(f"Autoscale", "", False)[0]: - self.get_subplot().auto_scale() + imgui.separator() - if imgui.menu_item(f"Center", "", False)[0]: - self.get_subplot().center_scene() + # toggles to flip axes cameras + for axis in ["x", "y", "z"]: + scale = getattr(subplot.camera.local, f"scale_{axis}") + changed, flip = imgui.menu_item(f"Flip {axis} axis", "", bool(scale < 0)) - _, maintain_aspect = imgui.menu_item( - "Maintain Aspect", "", self.get_subplot().camera.maintain_aspect - ) - self.get_subplot().camera.maintain_aspect = maintain_aspect + if changed: + flip_axis(subplot, axis, flip) - imgui.separator() + imgui.separator() - # toggles to flip axes cameras - for axis in ["x", "y", "z"]: - scale = getattr(self.get_subplot().camera.local, f"scale_{axis}") - changed, flip = imgui.menu_item( - f"Flip {axis} axis", "", bool(scale < 0) - ) + # toggles to show/hide the grid + for plane in ["xy", "xz", "yz"]: + grid = getattr(subplot.axes.grids, plane) + changed, visible = imgui.menu_item(f"Grid {plane}", "", grid.visible) - if changed: - flip_axis(self.get_subplot(), axis, flip) + if changed: + grid.visible = visible - imgui.separator() + imgui.separator() - # toggles to show/hide the grid - for plane in ["xy", "xz", "yz"]: - grid = getattr(self.get_subplot().axes.grids, plane) - visible = grid.visible - changed, new_visible = imgui.menu_item(f"Grid {plane}", "", visible) + # camera FOV + changed, fov = imgui.slider_float( + "FOV", v=subplot.camera.fov, v_min=0.0, v_max=180.0 + ) - if changed: - grid.visible = new_visible + if changed: + # FOV between 0 and 1 is numerically unstable + if 0 < fov < 1: + fov = 1 - imgui.separator() + # need to update FOV via controller, if FOV is directly set + # on the camera the controller will immediately set it back + subplot.controller.update_fov(fov - subplot.camera.fov, animate=False) - # camera FOV - changed, fov = imgui.slider_float( - "FOV", v=self.get_subplot().camera.fov, v_min=0.0, v_max=180.0 - ) + imgui.separator() - imgui.separator() + # controller options + if imgui.menu_item("Controller Options", "", False)[0]: + self._controller_window_open = subplot - if changed: - # FOV between 0 and 1 is numerically unstable - if 0 < fov < 1: - fov = 1 + def draw(self): + super().draw() - # need to update FOV via controller, if FOV is directly set - # on the camera the controller will immediately set it back - self.get_subplot().controller.update_fov( - fov - self.get_subplot().camera.fov, - animate=False, - ) + # the controller window is not part of the popup, it stays open after the popup closes + if self._controller_window_open: + self._draw_controller_window() - imgui.separator() + def _draw_controller_window(self): + subplot = self._controller_window_open - # controller options - if imgui.begin_menu("Controller"): - _, enabled = imgui.menu_item( - "Enabled", "", self.get_subplot().controller.enabled - ) + imgui.set_next_window_size((0, 0)) + _, keep_open = imgui.begin(f"Controller", True) + imgui.text(f"subplot: {subplot.name}") + _, enabled = imgui.menu_item( + "Enabled", "", subplot.controller.enabled + ) - self.get_subplot().controller.enabled = enabled + subplot.controller.enabled = enabled - changed, damping = imgui.slider_float( - "Damping", - v=self.get_subplot().controller.damping, - v_min=0.0, - v_max=10.0, - ) + changed, damping = imgui.slider_float( + "Damping", + v=subplot.controller.damping, + v_min=0.0, + v_max=10.0, + ) - if changed: - self.get_subplot().controller.damping = damping + if changed: + subplot.controller.damping = damping - imgui.separator() - imgui.text("Controller type:") - # switching between different controllers - for name, controller_type_iter in controller_types.items(): - current_type = type(self.get_subplot().controller) + imgui.separator() + imgui.text("Controller type:") + # switching between different controllers + for name, controller_type_iter in controller_types.items(): + current_type = type(subplot.controller) - clicked, _ = imgui.menu_item( - label=name, - shortcut="", - p_selected=current_type is controller_type_iter, - ) + clicked, _ = imgui.menu_item( + label=name, + shortcut="", + p_selected=current_type is controller_type_iter, + ) - if clicked and (current_type is not controller_type_iter): - # menu item was clicked and the desired controller isn't the current one - self.get_subplot().controller = name + if clicked and (current_type is not controller_type_iter): + # menu item was clicked and the desired controller isn't the current one + subplot.controller = name - imgui.end_menu() + if not keep_open: + self._controller_window_open = False - imgui.end_popup() + imgui.end() diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index dd527ca67..f454c7930 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -2,10 +2,10 @@ # this MUST be imported as early as possible in fpl.__init__ before any other wgpu stuff from .gui import loop +from .enums import * from .functions import * from .gpu import enumerate_adapters, select_adapter, print_wgpu_report -from ._plot_helpers import * -from .enums import * +from .protocols import ARRAY_LIKE_ATTRS, ArrayProtocol, FutureProtocol, CudaArrayProtocol @dataclass diff --git a/fastplotlib/utils/_plot_helpers.py b/fastplotlib/utils/_plot_helpers.py deleted file mode 100644 index 12afe1cb2..000000000 --- a/fastplotlib/utils/_plot_helpers.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Sequence - -import numpy as np - -from ..graphics._base import Graphic -from ..graphics._collection_base import GraphicCollection - - -def get_nearest_graphics_indices( - pos: tuple[float, float] | tuple[float, float, float], - graphics: Sequence[Graphic] | GraphicCollection, -) -> np.ndarray[int]: - """ - Returns indices of the nearest ``graphics`` to the passed position ``pos`` in world space - in order of closest to furtherst. Uses the distance between ``pos`` and the center of the - bounding sphere for each graphic. - - Parameters - ---------- - pos: (x, y) | (x, y, z) - position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D - - graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection - the graphics from which to return a sorted array of graphics in order of closest - to furthest graphic - - Returns - ------- - ndarray[int] - indices of the nearest nearest graphics to ``pos`` in order - - """ - if isinstance(graphics, GraphicCollection): - graphics = graphics.graphics - - if not all(isinstance(g, Graphic) for g in graphics): - raise TypeError("all elements of `graphics` must be Graphic objects") - - pos = np.asarray(pos).ravel() - - if pos.shape != (2,) and pos.shape != (3,): - raise TypeError( - f"pos.shape must be (2,) or (3,), the shape of pos you have passed is: {pos.shape}" - ) - - # get centers - centers = np.empty(shape=(len(graphics), len(pos))) - for i in range(centers.shape[0]): - centers[i] = graphics[i].world_object.get_world_bounding_sphere()[: len(pos)] - - # l2 - distances = np.linalg.norm(centers[:, : len(pos)] - pos, ord=2, axis=1) - - sort_indices = np.argsort(distances) - return sort_indices - - -def get_nearest_graphics( - pos: tuple[float, float] | tuple[float, float, float], - graphics: Sequence[Graphic] | GraphicCollection, -) -> np.ndarray[Graphic]: - """ - Returns the nearest ``graphics`` to the passed position ``pos`` in world space. - Uses the distance between ``pos`` and the center of the bounding sphere for each graphic. - - Parameters - ---------- - pos: (x, y) | (x, y, z) - position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D - - graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection - the graphics from which to return a sorted array of graphics in order of closest - to furthest graphic - - Returns - ------- - ndarray[Graphic] - nearest graphics to ``pos`` in order - - """ - sort_indices = get_nearest_graphics_indices(pos, graphics) - return np.asarray(graphics)[sort_indices] diff --git a/fastplotlib/utils/enums.py b/fastplotlib/utils/enums.py index 3901b082c..44601350d 100644 --- a/fastplotlib/utils/enums.py +++ b/fastplotlib/utils/enums.py @@ -1,4 +1,4 @@ -from enum import IntEnum +from enum import IntEnum, StrEnum class RenderQueue(IntEnum): @@ -13,3 +13,19 @@ class RenderQueue(IntEnum): # 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 + + +class ColorspacesRGB(StrEnum): + srgb = "srgb" + tex_srgb = "tex-srgb" + physical = "physical" + + +class ColorspacesYUV(StrEnum): + yuv420p = "yuv420p" + yuv444p = "yuv444p" + + +class ColorRange(StrEnum): + full = "full" + limited = "limited" diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index a839ed9d0..9b6c83c6c 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -6,6 +6,8 @@ from pygfx import Texture, Color +from .protocols import CudaArrayProtocol + cmap_catalog = cmap_lib.Catalog() @@ -405,9 +407,17 @@ def parse_cmap_values( return colors +def cuda_to_numpy(arr: CudaArrayProtocol) -> np.ndarray: + + data = np.from_dlpack(arr, device='cpu') + return data + + def subsample_array( - arr: np.ndarray, max_size: int = 1e6, ignore_dims: Sequence[int] | None = None -): + arr: CudaArrayProtocol, + max_size: int = 1e6, + ignore_dims: Sequence[int] | None = None, +) -> np.ndarray: """ Subsamples an input array while preserving its relative dimensional proportions. @@ -476,4 +486,44 @@ def subsample_array( slices = tuple(slices) - return np.asarray(arr[slices]) + arr_sliced = arr[slices] + + if isinstance(arr_sliced, CudaArrayProtocol): + return cuda_to_numpy(arr_sliced) + + return arr_sliced + + +def heatmap_to_positions(heatmap: np.ndarray, xvals: np.ndarray) -> np.ndarray: + """ + + Convert a heatmap of shape [n_rows, n_datapoints] to timeseries x-y data of shape [n_rows, n_datapoints, xy] + + Parameters + ---------- + heatmap: np.ndarray, shape [n_rows, n_datapoints] + timeseries data with a heatmap representation, where each column represents a timepoint. + + xvals: np.ndarray, shape: [n_datapoints,] + x-values for the columns in the heatmap + + Returns + ------- + np.ndarray, shape [n_rows, n_datapoints, 2] + timeseries data where the xy data are explicitly stored for every row + + """ + if heatmap.ndim != 2: + raise ValueError + + if xvals.ndim != 1: + raise ValueError + + if xvals.size != heatmap.shape[1]: + raise ValueError + + ts = np.empty((*heatmap.shape, 2), dtype=np.float32) + ts[..., 0] = xvals + ts[..., 1] = heatmap + + return ts diff --git a/fastplotlib/utils/protocols.py b/fastplotlib/utils/protocols.py new file mode 100644 index 000000000..a2bd6c1c0 --- /dev/null +++ b/fastplotlib/utils/protocols.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol, runtime_checkable + + +ARRAY_LIKE_ATTRS = [ + "dtype", + "shape", + "ndim", + "__getitem__", +] + + +@runtime_checkable +class ArrayProtocol(Protocol): + """an object that is sufficiently array-like for lazy loading""" + @property + def dtype(self) -> Any: ... + + @property + def ndim(self) -> int: ... + + @property + def shape(self) -> tuple[int, ...]: ... + + def __getitem__(self, key) -> ArrayProtocol: ... + + +@runtime_checkable +class CudaArrayProtocol(Protocol): + """an object that can be converted to a cupy array""" + + def __cuda_array_interface__(self) -> CudaArrayProtocol: ... + + +@runtime_checkable +class FutureProtocol(Protocol): + """An object that is sufficiently Future-like""" + + def cancel(self): ... + + def cancelled(self): ... + + def running(self): ... + + def done(self): ... + + def add_done_callback(self, fn: Callable): ... + + def result(self, timeout: float | None): ... + + def exception(self, timeout: float | None): ... + + def set_result(self, array: ArrayProtocol): ... + + def set_exception(self, exception): ... diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 766620ea6..c5caa3845 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,3 +1,12 @@ -from .image_widget import ImageWidget +from .nd_widget import ( + NDWidget, + NDProcessor, + NDGraphic, + NDPositionsProcessor, + NDPositions, + NDTimeseries, + NDImageProcessor, + NDImage, +) -__all__ = ["ImageWidget"] +__all__ = ["NDWidget"] diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 86a01b083..6d262678d 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -358,6 +358,11 @@ def __init__( passed to each ImageGraphic in the ImageWidget figure subplots """ + warn( + "`ImageWidget` is deprecated and will be removed in a" + " future release, please migrate to NDWidget", + DeprecationWarning + ) self._initialized = False if figure_kwargs is None: diff --git a/fastplotlib/widgets/nd_widget/__init__.py b/fastplotlib/widgets/nd_widget/__init__.py new file mode 100644 index 000000000..46245d62b --- /dev/null +++ b/fastplotlib/widgets/nd_widget/__init__.py @@ -0,0 +1,19 @@ +from ...layouts import IMGUI + + +if IMGUI: + from ._base import NDProcessor, NDGraphic + from ._nd_positions import NDPositions, NDPositionsProcessor, NDTimeseries, ndp_extras + from ._nd_image import NDImageProcessor, NDImage + from ._video import VideoProcessor + from ._nd_vectors import NDVectorsProcessor, NDVectors + from ._ndwidget import NDWidget + +else: + + class NDWidget: + def __init__(self, *args, **kwargs): + raise ModuleNotFoundError( + "NDWidget requires `imgui-bundle` to be installed.\n" + "pip install imgui-bundle" + ) diff --git a/fastplotlib/widgets/nd_widget/_async.py b/fastplotlib/widgets/nd_widget/_async.py new file mode 100644 index 000000000..2cd43b671 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_async.py @@ -0,0 +1,43 @@ +import asyncio +from concurrent.futures import Executor, Future, ThreadPoolExecutor +from typing import Any, Callable, Coroutine + +from rendercanvas.utils.asyncs import Event, detect_current_call_soon_threadsafe + + +async def wait_for_future(future: Future) -> Any: + """ + Await a ``concurrent.futures.Future`` from any rendercanvas-supported async + backend (asyncio for glfw/jupyter, the rendercanvas asyncadapter for qt/wx). + + ``asyncio.wrap_future`` cannot be used because the asyncadapter only + understands its own awaitables. We instead build the same + primitive on top of rendercanvas's cross-framework :class:`Event`, + signaled via the active loop's ``call_soon_threadsafe`` so the future's + done-callback (which runs on the executor thread) hands control back to + the event loop safely. + """ + event = Event() + call_soon_threadsafe = detect_current_call_soon_threadsafe() + future.add_done_callback(lambda f: call_soon_threadsafe(event.set)) + await event.wait() + return future.result() + + +async def run_in_thread_pool( + executor: Executor, fn: Callable, *args, **kwargs +) -> Any: + """Submit ``fn(*args, **kwargs)`` to ``executor`` and await the result.""" + return await wait_for_future(executor.submit(fn, *args, **kwargs)) + + +def run_sync(coro: Coroutine) -> Any: + """ + Drive an ``async def`` coroutine to completion synchronously, in a helper thread. + + Used by constructor calls (NDGraphic.__init__, data setter, other property setters). + ``asyncio.run`` is dispatched to a helper thread so this doesn't interfere with a + loop already running on the calling thread (the rendercanvas loop, jupyter, ipython etc.). + """ + with ThreadPoolExecutor(max_workers=1) as ex: + return ex.submit(asyncio.run, coro).result() diff --git a/fastplotlib/widgets/nd_widget/_base.py b/fastplotlib/widgets/nd_widget/_base.py new file mode 100644 index 000000000..ce17baef7 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_base.py @@ -0,0 +1,811 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +import inspect +from numbers import Real +from pprint import pformat +import textwrap +from typing import Any, TYPE_CHECKING + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol +from ...graphics import Graphic +from ._async import run_in_thread_pool, run_sync, wait_for_future + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot + +# must take arguments: array-like, `axis`: int, `keepdims`: bool +WindowFuncCallable = Callable[[ArrayLike, int, bool], ArrayLike] + + +def identity(index: int) -> int: + return round(index) + + +class NDProcessor: + def __init__( + self, + data: ArrayProtocol, + dims: Sequence[str], + spatial_dims: Sequence[str] | None, + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] = None, + window_order: tuple[str, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] | None = None, + ): + """ + Base class for managing n-dimensional data and producing array slices. + + Wraps array-like ``data`` and provides an interface for indexing slider dimensions, applying window functions, + spatial functions, and mapping reference-space values to local array indices. Subclasses must implement + :meth:`get`, which is called when the :class:`ReferenceIndex` updates. + + Subclasses can implement any type of data representation, they do not necessarily need to be array-like. + However their ``get()`` method must still return a data slice that corresponds to the graphical representation + they map to. + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + dimension. Each slider dim must have a ``ReferenceRange`` defined in the + ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct + a change in the ``ReferenceIndex`` and update the graphics. + + Parameters + ---------- + data: ArrayProtocol + data object that is managed, usually uses the ArrayProtocol. Custom subclasses can manage any kind of data + object but the corresponding :meth:`get` must return an array-like that maps to a graphical representation. + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + ``("time", "depth", "row", "col")`` + ``("channels", "time", "xy")`` + ``("keypoints", "time", "xyz")`` + + A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method + must operate as if these dimensions exist and return an array that matches the spatial dimensions. + + spatial_dims: Sequence[str] + Subset of ``dims`` that are spatial (rendered) dimensions **in display order**. All remaining dims are + treated as slider dims. See subclass for specific info. + + slider_dim_transforms: dict mapping dim_name -> Callable, an ArrayLike, or None + Per-slider-dim mapping from reference-space values to local array indices. + + You may also provide an array of reference values for the slider dims, ``searchsorted`` is then used + as the transform (ex: a timestamps array). + + If ``None`` and identity mapping is used, i.e. rounds the current reference index value to the nearest + integer for array indexing. + + If a transform is not provided for a dim then the identity mapping is used. + + window_funcs: dict[ + str, tuple[WindowFuncCallable | None, int | float | None] + ] + Per-slider-dim window functions applied around the current slider position. Ex: {"time": (np.mean, 2.5)}. + Each value is a ``(func, window_size)`` pair where: + + * *func* must accept ``axis: int`` and ``keepdims: bool`` kwargs + (ex: ``np.mean``, ``np.max``). The window function **must** return an array that has the same dimensions + as specified in the NDProcessor, therefore the size of any dim along which a window_func was applied + should reduce to ``1``. These dims must not be removed by the window_func. + + * *window_size* is in reference-space units (ex: 2.5 seconds). + + + window_order: tuple[str, ...] + Order in which window functions are applied across dims. Only dims listed + here have their window function applied. window_funcs are ignored for any + dims not specified in ``window_order`` + + spatial_func: + A function applied to the spatial slice *after* window_funcs right before rendering. + + """ + dims = tuple(dims) + if not all([isinstance(d, str) for d in dims]): + raise TypeError + + self._dims = dims + + self.data = data + self.spatial_dims = spatial_dims + + self.slider_dim_transforms = slider_dim_transforms + + self.window_funcs = window_funcs + self.window_order = window_order + self.spatial_func = spatial_func + + # window_funcs and spatial_func are dispatched with an executor so they don't block the rendercanvas loop. + # CUDA arrays run directly since they are inherently async already, the user is expected to provide CUDA + # functions if the data arrays are CUDA (ex: torch functions, not numpy functions) + self._executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix=f"ndp-{id(self):x}" + ) + + def close(self): + """Shut down the thread pool.""" + self._executor.shutdown(wait=False, cancel_futures=True) + + @property + def data(self) -> ArrayProtocol: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + # data can be set, but the dims must still match/have the same meaning + + if data is None: + # we allow data to be None, in this case no ndgraphic is rendered + # useful when we want to initialize an NDWidget with no traces for example + # and populate it as components/channels are selected + self._data = None + return + + if not isinstance(data, ArrayProtocol): + # check for general array-like requirements + raise TypeError("`data` must implement the ArrayProtocol") + + if data.ndim != len(self.dims): + raise IndexError("must specify a dim for every dimension in the data array") + + self._data = data + + @property + def shape(self) -> dict[str, int]: + """interpreted shape of the data""" + return {d: n for d, n in zip(self.dims, self.data.shape)} + + @property + def ndim(self) -> int: + """number of dims""" + return self.data.ndim + + @property + def dims(self) -> tuple[str, ...]: + """dim names, **ordered as laid out in the array**""" + # these are read-only and cannot be set after it's created + # the user should create a new NDGraphic if they need different dims + # I can't think of a use case where we'd want to change the dims, and + # I think that would be complicated and probably and anti-pattern + return self._dims + + @property + def spatial_dims(self) -> tuple[str, ...]: + """Spatial dims, **in display order**""" + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: Sequence[str]): + for dim in sdims: + if dim not in self.dims: + raise KeyError + + self._spatial_dims = tuple(sdims) + + @property + def spatial_dims_indices(self) -> tuple[int, ...]: + """ + The ordered spatial dim indices that correspond to the named spatial dims + """ + return tuple(self.spatial_dims.index(d) for d in self.dims if d in self.spatial_dims) + + @property + def tooltip(self) -> bool: + """ + whether or not a custom tooltip formatter method exists + """ + return False + + def tooltip_format(self, *args) -> str | None: + """ + Override in subclass to format custom tooltips + """ + return None + + @property + def slider_dims(self) -> set[str]: + """Slider dim names, ``set(dims) - set(spatial_dims), **unordered**""" + return set(self.dims) - set(self.spatial_dims) + + @property + def n_slider_dims(self): + """number of slider dims, i.e. len(slider_dims)""" + return len(self.slider_dims) + + @property + def window_funcs( + self, + ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: + """get or set window functions, see docstring for details""" + return self._window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: ( + dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] + | None + ), + ): + if window_funcs is None: + # tuple of (None, None) makes the checks easier in _apply_window_funcs + self._window_funcs = {d: (None, None) for d in self.slider_dims} + return + + for k in window_funcs.keys(): + if k not in self.slider_dims: + raise KeyError + + func = window_funcs[k][0] + size = window_funcs[k][1] + + if func is None: + pass + elif callable(func): + sig = inspect.signature(func) + + if "axis" not in sig.parameters or "keepdims" not in sig.parameters: + raise TypeError( + f"Each window function must take an `axis` and `keepdims` argument, " + f"you passed: {func} with the following function signature: {sig}" + ) + else: + raise TypeError( + f"`window_funcs` must be a dict mapping dim names to a tuple of the window function callable and " + f"window size, {'name': (func, size), ...}.\nYou have passed: {window_funcs}" + ) + + if size is None: + pass + + elif not isinstance(size, Real): + raise TypeError + + elif size < 0: + raise ValueError + + # fill in rest with None + for d in self.slider_dims: + if d not in window_funcs.keys(): + window_funcs[d] = (None, None) + + self._window_funcs = window_funcs + + @property + def window_order(self) -> tuple[str, ...]: + """get or set dimension order in which window functions are applied""" + return self._window_order + + @window_order.setter + def window_order(self, order: tuple[str] | None): + if order is None: + self._window_order = tuple() + return + + if not set(order).issubset(self.slider_dims): + raise ValueError( + f"each dimension in `window_order` must be a slider dim. You passed order: {order} " + f"and the slider dims are: {self.slider_dims}" + ) + + self._window_order = tuple(order) + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial function which is applied on the data slice after the window functions""" + return self._spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[ArrayProtocol], ArrayProtocol] + ) -> Callable | None: + if not callable(func) and func is not None: + raise TypeError + + self._spatial_func = func + + @property + def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: + """get or set the slider_dim_transforms, see docstring for details""" + return self._index_mappings + + @slider_dim_transforms.setter + def slider_dim_transforms( + self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None + ): + if maps is None: + self._index_mappings = {d: identity for d in self.dims} + return + + for d in maps.keys(): + if d not in self.dims: + raise KeyError( + f"`index_mapping` provided for non-existent dimension: {d}, existing dims are: {self.dims}" + ) + + if isinstance(maps[d], ArrayProtocol): + # create a searchsorted mapping function automatically + maps[d] = maps[d].searchsorted + + elif maps[d] is None: + # assign identity mapping + maps[d] = identity + + for d in self.dims: + # fill in any unspecified maps with identity + if d not in maps.keys(): + maps[d] = identity + + self._index_mappings = maps + + def _ref_index_to_array_index(self, dim: str, ref_index: Any) -> int: + # wraps slider_dim_transforms, clamps between 0 and the array size in this dim + + # ref-space -> local-array-index transform + index = self.slider_dim_transforms[dim](ref_index) + + # clamp between 0 and array size in this dim + return max(min(index, self.shape[dim] - 1), 0) + + def _get_slider_dims_indexer(self, indices: dict[str, Any]) -> dict[str, slice]: + """ + Creates an indexer dict mapping each slider_dim -> slice object. + + - If a window_func is defined for a dim and the dim appears in ``window_order``, + the slice is defined as: + start: index - half_window + stop: index + half_window + step: 1 + + It then applies the slider_dim_transform to the start and stop to map these values from reference-space to + the local array index, and then finally produces the slice object in local array indices. + + ex: if we have indices = {"time": 50.0}, a window size of 5.0s and the ``slider_dim_transform`` + for time is based on a sampling rate of 10Hz, the window in ref units is [45.0, 55.0], and the final + slice object would be ``slice(450, 550, 1)``. + + - If no window func is specified, the final slice just corresponds to that index as an int array-index. + + This exists separate from ``_apply_window_functions()`` because it is useful for debugging purposes. + + Parameters + ---------- + indices : dict[str, Any], {dim: ref_value} + Reference-space values for each slider dim. Must contain an entry + for every slider dim; raises ``IndexError`` otherwise. + ex: {"time": 46.397, "depth": 23.24} + + Returns + ------- + dict[str, slice] + Indexer compatible for ``xr.DataArray.isel()``, with one ``slice`` per + slider dim. These are array indices mapped from the reference space using + the given ``slider_dim_transform``. + + Raises + ------ + IndexError + If ``indices`` are not provided for every ``slider_dim`` + """ + + if set(indices.keys()) != set(self.slider_dims): + raise IndexError( + f"Must provide an index for all slider dims: {self.slider_dims}, you have provided: {indices.keys()}" + ) + + indexer = dict() + + # get only slider dims which are not also spatial dims (example: p dim for positional data) + # since `p` dim windowing is dealt with separately for positional data + slider_dims = set(self.slider_dims) - set(self.spatial_dims) + # go through each slider dim and accumulate slice objects + for dim in slider_dims: + # index for this dim in reference space + index_ref = indices[dim] + + if dim not in self.window_funcs.keys(): + wf, ws = None, None + else: + # get window func and size in reference units + wf, ws = self.window_funcs[dim] + + # if a window function exists for this dim, and it's specified in the window order + if (wf is not None) and (ws is not None) and (dim in self.window_order): + # half window in reference units + hw = ws / 2 + + # start in reference units + start_ref = index_ref - hw + # stop in ref units + stop_ref = index_ref + hw + + # map start and stop ref to array indices + start = self.slider_dim_transforms[dim](start_ref) + stop = self.slider_dim_transforms[dim](stop_ref) + + # clamp within array bounds + start = max(min(self.shape[dim] - 1, start), 0) + stop = max(min(self.shape[dim] - 1, stop), 0) + indexer[dim] = slice(start, stop, 1) + else: + # no window func for this dim, direct indexing + # index mapped to array index + index = self.slider_dim_transforms[dim](index_ref) + + # clamp within the bounds + start = max(min(self.shape[dim] - 1, index), 0) + + # stop index is just the start index + 1 + indexer[dim] = slice(start, start + 1, 1) + + return indexer + + async def _apply_window_functions( + self, windowed_array: ArrayProtocol + ) -> ArrayProtocol: + """ + apply window functions in the order specified by + ``window_order``. + + For numpy arrays each func is dispatched to the per-processor thread pool so it + does not block the rendercanvas event loop. CUDA arrays are run directly since + cuda functions (ex: torch) are already async. + + Parameters + ---------- + windowed_array: ArrayProtocol + array that has been sliced with the desired windows at an index + + Returns + ------- + ArrayProtocol + Data slice after windowed indexing and window function application, + with the same dims as the original data. Dims of size ``1`` are not + squeezed. + + """ + # apply window funcs in the specified order + for dim in self.window_order: + if self.window_funcs[dim] is None: + continue + + func, _ = self.window_funcs[dim] + axis = self.dims.index(dim) + # ``keepdims=True`` is critical, any "collapsed" dims will be of size ``1``. + # Ex: if `array` is of shape [10, 512, 512] and we applied the np.mean() window func on the first dim + # ``keepdims`` means the resultant shape is [1, 512, 512] and NOT [512, 512] + # this is necessary for applying window functions on multiple dims separately and so that the + # dims names correspond after all the window funcs are applied. + if isinstance(windowed_array, CudaArrayProtocol): + windowed_array = func(windowed_array, axis=axis, keepdims=True) + else: + windowed_array = await run_in_thread_pool( + self._executor, func, windowed_array, axis=axis, keepdims=True + ) + + return windowed_array + + async def get_window_output(self, indices: dict[str, Any]) -> ArrayProtocol: + """ + Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + + Parameters + ---------- + indices + + Returns + ------- + + """ + # windowed slice if user set any window funcs + windowed_slice = await self._get_raw_data_slice(indices) + + # convert to numpy array; CUDA arrays pass through and are converted at the end of the pipeline + if not isinstance(windowed_slice, CudaArrayProtocol): + windowed_slice = np.asarray(windowed_slice) + + # apply window funcs + if len(self.slider_dims) > 0: + windowed_slice = await self._apply_window_functions(windowed_slice) + + # squeeze out all slider dims which should now be size 1 + # set(dims) - set(spatial_dims) since some spatial dims can also be slider, so get only pure non-spatial dims + slider_dims_int = tuple( + self.dims.index(d) for d in set(self.dims) - set(self.spatial_dims) + ) + windowed_slice = windowed_slice.squeeze(axis=slider_dims_int) + + if windowed_slice.ndim != len(self.spatial_dims): + raise ValueError( + f"windowed_slice.ndim != len(self.spatial_dims): {windowed_slice.ndim} != {len(self.spatial_dims)}" + ) + + return windowed_slice + + async def _get_raw_data_slice(self, indices: dict[str, Any]) -> ArrayProtocol: + """ + Base implementation to get the raw data slice from the wrapped array. + + Awaits any ``FutureProtocol`` returned by the underlying loader. CUDA arrays + are returned as-is and converted to numpy at the end of the pipeline. + """ + if len(self.slider_dims) > 0: + indexer = self._get_slider_dims_indexer(indices) + # get the data slice w.r.t. the desired windows + index_tuple = tuple(indexer.get(dim, slice(None)) for dim in self.dims) + raw_slice = self.data[index_tuple] + + else: + # return everything directly + # request a slice of everything with [:] so that any data fetching, compute, etc. is actually done + raw_slice = self.data[:] + + if isinstance(raw_slice, FutureProtocol): + return await wait_for_future(raw_slice) + return raw_slice + + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: + raise NotImplementedError + + # TODO: html and pretty text repr # + # def _repr_html_(self) -> str: + # return ndp_fmt_html(self) + # + # def _repr_mimebundle_(self, **kwargs) -> dict: + # return { + # "text/plain": self._repr_text_(), + # "text/html": self._repr_html_(), + # } + + def _repr_text_(self): + if self.data is None: + return f"{self.__class__.__name__}\n" f"data is None, dims: {self.dims}" + tab = "\t" + + wf = {k: v for k, v in self.window_funcs.items() if v != (None, None)} + + r = ( + f"{self.__class__.__name__}\n" + f"shape:\n\t{self.shape}\n" + f"dims:\n\t{self.dims}\n" + f"spatial_dims:\n\t{self.spatial_dims}\n" + f"slider_dims:\n\t{self.slider_dims}\n" + f"slider_dim_transforms:\n{textwrap.indent(pformat(self.slider_dim_transforms, width=120), prefix=tab)}\n" + ) + + if len(wf) > 0: + r += ( + f"window_funcs:\n{textwrap.indent(pformat(wf, width=120), prefix=tab)}\n" + f"window_order:\n\t{self.window_order}\n" + ) + + if self.spatial_func is not None: + r += f"spatial_func:\n\t{self.spatial_func}\n" + + return r + + +class NDGraphic: + def __init__( + self, + nd_subplot: NDWSubplot, + name: str | None, + ): + self._nd_subplot = nd_subplot + self._name = name + self._graphic: Graphic | None = None + + # used to indicate that the NDGraphic should ignore any requests to update the indices. + # used by block_indices_ctx context manager, usecase is when the LinearSelector on timeseries + # NDGraphic changes the selection, it shouldn't change the graphic that it is on top of! Would + # also cause recursion. ReferenceIndex._render_indices checks this flag at scheduling time. + self._block_indices = False + + # user settable bool to make the graphic unresponsive to change in the ReferenceIndex + self._pause = False + + # the indices that current graphic data reflects + self._last_indices = None + + async def _create_graphic(self): + raise NotImplementedError + + @property + def pause(self) -> bool: + """if True, changes in the reference until it is set back to False""" + return self._pause + + @pause.setter + def pause(self, val: bool): + self._pause = bool(val) + + @property + def name(self) -> str | None: + """name given to the NDGraphic""" + return self._name + + @property + def processor(self) -> NDProcessor: + raise NotImplementedError + + @property + def graphic(self) -> Graphic: + raise NotImplementedError + + @property + def indices_displayed(self) -> dict[str, Any]: + """the indices that the graphic currently represents""" + return self._last_indices + + @property + def indices(self) -> dict[str, Any]: + raise NotImplementedError + + async def _set_indices_(self, indices: dict[str, Any] = None): + """ + Get the data slice for the index from the processor and write it to the graphic. + + If indices is None, it uses the latest indices from the ReferenceIndex. Otherwise it uses the + indices passed when the update was scheduled. + + Semi-private: only ``ReferenceIndex`` should call this. _create_graphic uses `run_sync` + to run it sync + """ + pass + + # aliases for easier access to processor properties + @property + def data(self) -> Any: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ + return self.processor.data + + @data.setter + def data(self, data: Any): + self.processor.data = data + # create a new graphic when data has changed + if self.graphic is not None: + # it is already None if NDGraphic was initialized with no data + self._nd_subplot.subplot.delete_graphic(self.graphic) + self._graphic = None + + run_sync(self._create_graphic()) + + # force a render + run_sync(self._set_indices_()) + + @property + def shape(self) -> dict[str, int]: + """interpreted shape of the data""" + return self.processor.shape + + @property + def ndim(self) -> int: + """number of dims""" + return self.processor.ndim + + @property + def dims(self) -> tuple[str, ...]: + """dim names""" + return self.processor.dims + + @property + def spatial_dims(self) -> tuple[str, ...]: + # number of spatial dims for positional data is always 3 + # for image is 2 or 3, so it must be implemented in subclass + raise NotImplementedError + + @property + def slider_dims(self) -> set[str]: + """the slider dims""" + return self.processor.slider_dims + + @property + def slider_dim_transforms(self) -> dict[str, Callable[[Any], int]]: + return self.processor.slider_dim_transforms + + @slider_dim_transforms.setter + def slider_dim_transforms( + self, maps: dict[str, Callable[[Any], int] | ArrayLike | None] | None + ): + """get or set the slider_dim_transforms, see docstring for details""" + self.processor.slider_dim_transforms = maps + # force a render + run_sync(self._set_indices_()) + + @property + def window_funcs( + self, + ) -> dict[str, tuple[WindowFuncCallable | None, int | float | None]]: + """get or set window functions, see docstring for details""" + return self.processor.window_funcs + + @window_funcs.setter + def window_funcs( + self, + window_funcs: ( + dict[str, tuple[WindowFuncCallable | None, int | float | None] | None] + | None + ), + ): + self.processor.window_funcs = window_funcs + # force a render + run_sync(self._set_indices_()) + + @property + def window_order(self) -> tuple[str, ...]: + """get or set dimension order in which window functions are applied""" + return self.processor.window_order + + @window_order.setter + def window_order(self, order: tuple[str] | None): + self.processor.window_order = order + # force a render + run_sync(self._set_indices_()) + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial_func, see docstring for details""" + return self.processor.spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[ArrayProtocol], ArrayProtocol] + ) -> Callable | None: + """get or set the spatial_func, see docstring for details""" + self.processor.spatial_func = func + # force a render + run_sync(self._set_indices_()) + + # def _repr_text_(self) -> str: + # return ndg_fmt_text(self) + # + # def _repr_html_(self) -> str: + # return ndg_fmt_html(self) + # + # def _repr_mimebundle_(self, **kwargs) -> dict: + # return { + # "text/plain": self._repr_text_(), + # "text/html": self._repr_html_(), + # } + + def _repr_text_(self): + return ( + f"graphic: {self.graphic.__class__.__name__}\n" + f"processor:\n{self.processor}" + ) + + +@contextmanager +def block_indices_ctx(*ndgraphics: NDGraphic): + """ + Context manager for pausing NDGraphics from updating indices + """ + for ndg in ndgraphics: + ndg._block_indices = True + + try: + yield + except Exception as e: + raise e from None # indices setter has raised, the line above and the lines below are probably more relevant! + finally: + for ndg in ndgraphics: + ndg._block_indices = False diff --git a/fastplotlib/widgets/nd_widget/_index.py b/fastplotlib/widgets/nd_widget/_index.py new file mode 100644 index 000000000..bba85fa6d --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_index.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +from collections import deque +from concurrent.futures import CancelledError +from dataclasses import dataclass +from numbers import Number +from typing import Sequence, Any, Callable, Iterator + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._ndwidget import NDWidget + from ._base import NDGraphic + +from ...utils import loop + + +class RangeContinuous: + """ + A continuous reference range for a single slider dimension. + + Stores the (start, stop, step) in scientific units (ex: seconds, micrometers, + Hz). The imgui slider for this dimension uses these values to determine its + minimum and maximum bounds. The step size is used for the "next" and "previous" buttons. + + Parameters + ---------- + start : int or float + Minimum value of the range, inclusive. + + stop : int or float + Maximum value of the range, exclusive upper bound. + + step : int or float + Step size used for imgui step next/previous buttons + + Raises + ------ + IndexError + If ``start >= stop``. + + Examples + -------- + A time axis sampled at 1 ms resolution over 10 seconds: + + RangeContinuous(start=0, stop=10_000, step=1) + + A depth axis in micrometers with 0.5 µm steps: + + RangeContinuous(start=0.0, stop=500.0, step=0.5) + """ + + def __init__(self, start: int | float, stop: int | float, step: int | float): + if start >= stop: + raise IndexError( + f"start must be less than stop, {self.start} !< {self.stop}" + ) + + self._start = start + self._stop = stop + self._step = step + self._throttle = 0.05 + + @property + def start(self) -> int | float: + """get or set the start boundary of the reference range""" + return self._start + + @start.setter + def start(self, val: int | float): + self._start = val + + @property + def stop(self) -> int | float: + """get or set the stop boundary of the reference range""" + return self._stop + + @stop.setter + def stop(self, val: int | float): + self._stop = val + + @property + def step(self) -> int | float: + """get or set the step size of the range, only used for UI elements""" + return self._step + + @property + def throttle(self) -> float: + """get or set the minimum time in seconds between slider-drag renders""" + return self._throttle + + @throttle.setter + def throttle(self, val: float): + if val < 0: + raise ValueError("throttle value must be >= 0.0") + self._throttle = val + + @property + def size(self) -> int | float: + """the size of the reference range""" + return self.stop - self.start + + def __getitem__(self, index: int): + """return the value at the index w.r.t. the step size""" + if index < 0: + raise ValueError("negative indexing not supported") + + val = self.start + (self.step * index) + if not self.start <= val <= self.stop: + raise IndexError( + f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop}]" + ) + + return val + + +class AutoRangeContinuous(RangeContinuous): + """ + A continuous reference range that was auto-generated for a slider dimension + which had no explicit ``RangeContinuous``. + """ + + +@dataclass +class RangeDiscrete: + # TODO: not implemented yet, placeholder until we have a clear usecase + options: Sequence[Any] + + def __getitem__(self, index: int): + if index > len(self.options): + raise IndexError + + return self.options[index] + + def __len__(self): + return len(self.options) + + +class ReferenceIndex: + def __init__( + self, + ref_ranges: dict[ + str, + tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, + ], + ): + """ + Manages the shared reference index for one or more ``NDWidget`` instances. + + Stores the current index for each named slider dimension in reference-space + units (ex: seconds, depth in µm, Hz). Whenever an index is updated, every + ``NDGraphic`` in the manged ``NDWidgets`` are requested to render data at + the new indices. + + Each key in ``ref_ranges`` defines a slider dimension. When adding an + ``NDGraphic``, every dimension listed in ``dims`` is either a spatial + dimension (listed in ``spatial_dims``) or a slider dimension. A slider + dim without a reference range gets an ``AutoRangeContinuous`` sized to the + data, so an explicit range is only needed when the slider should map + reference-space units to array indices rather than use a one-to-one + (identity) mapping. + + You can also define conceptually identical but *independent* reference spaces + by using distinct names, ex: ``"time-1"`` and ``"time-2"`` for two subsets of data + that should be sycned independently. Each ``NDGraphic`` then declares the + specific ``"time-n"`` space that corresponds to its data, so the widget keeps the + two timelines decoupled. + + Parameters + ---------- + ref_ranges : dict[str, tuple | RangeContinuous] + Mapping of dimension names to range specifications. A 3-tuple + ``(start, stop, step)`` creates a :class:`RangeContinuous`. A 1-tuple + ``(options,)`` creates a :class:`RangeDiscrete`. + + Attributes + ---------- + ref_ranges : dict[str, RangeContinuous | RangeDiscrete] + The reference range for each registered slider dimension. + + dims: set[str] + the set of "slider dims" + + Examples + -------- + Single shared time axis: + + ri = ReferenceIndex(ref_ranges={"time": (0, 1000, 1), "depth": (15, 35, 0.5)}) + ri.set_dim_index("time", 500) # update one dim and re-render + ri.set({"time": 500, "depth": 10}) # update several dims atomically + + Two independent time axes for data from two different recording sessions: + + ri = ReferenceIndex({ + "time-1": (0, 3600, 1), # session 1 — 1 h at 1 s resolution + "time-s": (0, 1800, 1), # session 2 — 30 min at 1 s resolution + }) + + Each ``NDGraphic`` declares matching names for slider dims to indicate that these should be + synced across graphics. + + ndw[0, 0].add_nd_image(data_s1, ("time-s1", "row", "col"), ("row", "col")) + ndw[0, 1].add_nd_image(data_s2, ("time-s2", "row", "col"), ("row", "col")) + + """ + self._ref_ranges = dict() + + # current index for each dim + self._indices: dict[str, int | float | Any] = dict() + + self._ndwidgets: list[NDWidget] = list() + + self.push_dims(ref_ranges) + + self._indices_changed_handlers = set() + + # per-NDGraphic fetch update revision. Bumped on every ``cancel_awaiting=True`` + # call (display only latest fetch, used during slider drag). A scheduled fetch + # carries the revision it was created under and skips setting graphic data + # if a newer revision has been requested + self._fetch_rev: dict[NDGraphic, int] = dict() + + # per-graphic queue of pending fetch requests for the serial + # path (i.e. ``cancel_awaiting=False``). Used for play, step, programmatic updates, + # and LinearSelector. Each entry is ``(indices, rev)``. Emptied by + # :meth:`_fetch_request` + self._fetch_request_queue: dict[ + NDGraphic, deque[tuple[dict[str, Any], int]] + ] = dict() + + # per-graphic flag, indicates whether :meth:`_fetch_request` is currently emptying + # ``_fetch_request_queue[ndg]``? Ensures only one coroutine is + # alive per graphic. Subsequent ``cancel_awaiting=False`` calls just + # append to the queue. + self._fetch_request_active: dict[NDGraphic, bool] = dict() + + @property + def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: + """current reference ranges""" + return self._ref_ranges + + @property + def dims(self) -> set[str]: + """reference dimensions""" + return set(self.ref_ranges.keys()) + + def _add_ndwidget_(self, ndw: NDWidget): + """add an NDWidget instance to be managed by this ReferenceIndex""" + from ._ndwidget import NDWidget + + if not isinstance(ndw, NDWidget): + raise TypeError + + self._ndwidgets.append(ndw) + + def set(self, indices: dict[str, Any], cancel_awaiting: bool = False): + """ + Set the index for each dimension in indices + + Parameters + ---------- + indices: dict[str, Any] + indices to set, {dim: index} + + cancel_awaiting: bool, default ``False`` + cancel in-progress fetches, i.e. only display the latest fetch request + + Returns + ------- + + """ + for dim, value in indices.items(): + self._indices[dim] = self._clamp(dim, value) + + self._fetch_indices(cancel_awaiting=cancel_awaiting) + self._indices_changed() + + @property + def ndgraphics(self) -> Iterator[NDGraphic]: + """All the NDGraphics that this ReferenceIndex instance manages""" + + for ndw in self._ndwidgets: + yield from ndw.ndgraphics + + def set_dim_index(self, dim: str, index: int | float, cancel_awaiting: bool = False): + """ + Set the index for a single dimension and trigger an update. + + Parameters + ---------- + dim : str + Dimension name. + + index : int or float + New reference-space value for this dimension. + + cancel_awaiting : bool, default False + If True, cancel any in-progress fetch tasks before scheduling a new one. + Used only for fast inputs, currently only for the imgui slider so every single + intermediate position during a slider drag isn't fetched & rendered. + All other methods of fetching data (play, step buttons, LinearSelector, + programmatic updates) use cancel_awaiting=False to display every data fetch. + + """ + + self._check_has_dim(dim) + self._indices[dim] = self._clamp(dim, index) + + for ndg in self.ndgraphics: + # set only for NDGraphics that have this dim + if dim in ndg.dims: + self._schedule_fetch(ndg, cancel_awaiting=cancel_awaiting) + + self._indices_changed() + + def _clamp(self, dim: str, value: int | float): + """clamp the given index value within the valid range for this dimension""" + + if isinstance(self.ref_ranges[dim], RangeContinuous): + return max( + min(value, self.ref_ranges[dim].stop - self.ref_ranges[dim].step), + self.ref_ranges[dim].start, + ) + + return value + + def _fetch_indices(self, cancel_awaiting: bool = False): + """ + Schedule a fetch for every NDGraphic. + """ + + for g in self.ndgraphics: + self._schedule_fetch(g, cancel_awaiting=cancel_awaiting) + + def _schedule_fetch(self, ndg: NDGraphic, cancel_awaiting: bool = False): + """ + Schedule fetch for an NDGraphic + + This entry point has 2 paths: + + * ``cancel_awaiting=True`` used for fast inputs, currently only for the imgui slider where + we don't want to fetch & render every intermediate position during a slider drag. Schedules a new + ``_set_indices_`` task via :meth:`_render_request_latest`. Any in-progress tasks skip + setting graphic data. ``_fetch_rev`` is used so only the latest revision is rendered. + + - ``cancel_awaiting=False`` used by play, step button, LinearSelector, programmatic updates. + Every request will fetch & render. Requests are queued per graphic and processed in sequence + by :meth:`_render_request`. + """ + + if ndg.data is None or ndg.pause or ndg._block_indices: + # skip fetch for this graphic + return + + task_name = f"ndw-fetch:{type(ndg).__name__}" + if ndg.name is not None: + task_name = f"{task_name}:{ndg.name}" + + if cancel_awaiting: + # bump revision so older in-progress fetches skip setting graphic data + self._fetch_rev[ndg] = self._fetch_rev.get(ndg, 0) + 1 + rev = self._fetch_rev[ndg] + + # add to rendercanvas scheduler + loop.add_task( + self._fetch_request_latest, ndg, rev, name=task_name + ) + else: + rev = self._fetch_rev.get(ndg, 0) + # provide index at schedule time so all data is played back sequentially + indices = {d: self._indices[d] for d in ndg.processor.slider_dims} + self._fetch_request_queue.setdefault(ndg, deque()).append( + (indices, rev) + ) + # one queue per graphic + # if one is already running the appended entry will be picked up by it + if not self._fetch_request_active.get(ndg, False): + self._fetch_request_active[ndg] = True + loop.add_task(self._fetch_request, ndg, name=task_name) + + async def _fetch_request(self, graphic: "NDGraphic"): + """ + Process ``_fetch_request_queue[graphic]`` one entry at a time. Each + ``_set_indices_`` is awaited fully before the next entry is popped, + so only one ``_set_indices_`` is in-progress per graphic from this + path. + A concurrent :meth:`_fetch_request_latest` for the same + graphic can still cancel an in-progress fetch; the resulting + :class:`CancelledError` is dropped. + """ + try: + queue = self._fetch_request_queue[graphic] + while queue: + indices, rev = queue.popleft() + if rev < self._fetch_rev.get(graphic, 0): + # a rapid-fire request superseded this queued entry; skip + continue + try: + await graphic._set_indices_(indices) + except CancelledError: + # concurrent _fetch_request_latest canceled our read on ``data`` + pass + del self._fetch_request_queue[graphic] + finally: + self._fetch_request_active[graphic] = False + + async def _fetch_request_latest( + self, graphic: "NDGraphic", rev: int + ): + """ + Schedule one ``_set_indices_`` task. Older still-running tasks skip + their graphic data write when ``rev < current``. + Some ``data`` objects cancel the + previous in-flight read when a new index is requested; the resulting + :class:`CancelledError` is dropped. + """ + if rev < self._fetch_rev.get(graphic, 0): + # a newer rapid-fire request superseded us; drop the write + return + try: + await graphic._set_indices_() + except CancelledError: + # ``data`` cancelled this read in favour of a newer one + pass + + def __getitem__(self, dim): + self._check_has_dim(dim) + return self._indices[dim] + + def _check_has_dim(self, dim): + if dim not in self.dims: + raise KeyError( + f"provided dimension: {dim} has no associated ReferenceRange in this ReferenceIndex, valid dims in this ReferenceIndex are: {self.dims}" + ) + + def pop_dim(self): + pass + + def push_dims( + self, + ref_ranges: dict[ + str, + tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, + ], + ): + + for name, r in ref_ranges.items(): + if isinstance(r, (RangeContinuous, RangeDiscrete)): + self._ref_ranges[name] = r + + elif len(r) == 3: + # assume start, stop, step + self._ref_ranges[name] = RangeContinuous(*r) + + elif len(r) == 1: + # assume just options + self._ref_ranges[name] = RangeDiscrete(*r) + + else: + raise ValueError( + f"ref_ranges must be a mapping of dimension names to range specifications, " + f"see the docstring, you have passed: {ref_ranges}" + ) + + rr = self._ref_ranges[name] + if isinstance(rr, AutoRangeContinuous): + self._indices[name] = 0 + elif isinstance(rr, RangeContinuous): + self._indices[name] = rr.start + elif isinstance(rr, RangeDiscrete): + # start at the first option + self._indices[name] = rr.options[0] + + # set imgui UI for each NDWidget window + for ndw in self._ndwidgets: + ndw._sliders_ui.push_dim(name) + + def add_event_handler(self, handler: Callable, event: str = "indices"): + """ + Register an event handler that is called whenever the indices change. + + Parameters + ---------- + handler: Callable + callback function, must take a tuple of int as the only argument. This tuple will be the `indices` + + event: str, "indices" + the only supported valid is "indices" + + Example + ------- + + .. code-block:: py + + def my_handler(indices): + print(indices) + # example prints: {"t": 100, "z": 15} if the index has 2 reference spaces "t" and "z" + + # create an NDWidget + ndw = NDWidget(...) + + # add event handler + ndw.indices.add_event_handler(my_handler) + + """ + if event != "indices": + raise ValueError("`indices` is the only event supported by `GlobalIndex`") + + self._indices_changed_handlers.add(handler) + + def remove_event_handler(self, handler: Callable): + """Remove a registered event handler""" + self._indices_changed_handlers.remove(handler) + + def clear_event_handlers(self): + """Clear all registered event handlers""" + self._indices_changed_handlers.clear() + + def _indices_changed(self): + # calls indices changed handlers + for f in self._indices_changed_handlers: + f(self._indices) + + def __iter__(self): + for index in self._indices.items(): + yield index + + def __len__(self): + return len(self._indices) + + def __eq__(self, other): + return self._indices == other + + def __repr__(self): + return f"Global Index: {self._indices}" + + def __str__(self): + return str(self._indices) + + +# TODO: Not sure if we'll actually do this here, just a placeholder for now +class SelectionVector: + @property + def selection(self): + pass + + @property + def graphics(self): + pass + + def add_graphic(self): + pass + + def remove_graphic(self): + pass diff --git a/fastplotlib/widgets/nd_widget/_nd_image.py b/fastplotlib/widgets/nd_widget/_nd_image.py new file mode 100644 index 000000000..40dd510f9 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_image.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Callable, Any, Literal, TYPE_CHECKING + +import numpy as np +from numpy.typing import ArrayLike + +from ...utils import ( + subsample_array, + ARRAY_LIKE_ATTRS, + ArrayProtocol, + CudaArrayProtocol, + cuda_to_numpy, + enums, +) +from ...graphics import ImageGraphic, ImageYUVGraphic, ImageVolumeGraphic +from ...ui import ImguiColorbar +from ._base import ( + NDProcessor, + NDGraphic, + WindowFuncCallable, +) +from ._index import ReferenceIndex +from ._async import run_in_thread_pool, run_sync + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot + + +class NDImageProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: ( + tuple[str, str] | tuple[str, str, str] + ), # must be in order! [rows, cols] | [z, rows, cols] + rgb_dim: str | None = None, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, + slider_dim_transforms=None, + ): + """ + ``NDProcessor`` subclass for n-dimensional image data. + + Produces 2-D or 3-D spatial slices for an ``ImageGraphic`` or ``ImageVolumeGraphic``. + + Parameters + ---------- + data: ArrayProtocol + array-like data, must have 2 or more dimensions + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + ``("time", "depth", "row", "col")`` + ``("channels", "time", "xy")`` + ``("keypoints", "time", "xyz")`` + + A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method + must operate as if these dimensions exist and return an array that matches the spatial dimensions. + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + ``("time", "depth", "row", "col")`` + ``("row", "col")`` + ``("other_dim", "depth", "time", "row", "col")`` + + dims in the array do not need to be in the order that you want to display them, for example you can have a + weird array where the dims are interpreted as: + ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + The 2 or 3 spatial dimensions **in display order**: ``(rows, cols)`` or ``(z, rows, cols)``. + This also determines whether an ``ImageGraphic`` or ``ImageVolumeGraphic`` is used for rendering. + The ordering determines how the Image/Volume is rendered. For example, if + you specify ``spatial_dims = ("rows", "cols")`` and then change it to ``("cols", "rows")``, it will display + the transpose. + + rgb_dim : str, optional + Name of an RGB(A) dimension, if present. + + compute_histogram: bool, default True + Compute a histogram of the data, disable if random-access of data is not blazing-fast (ex: data that uses + video codecs), or if histograms are not useful for this data. + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + See Also + -------- + NDProcessor : Base class with full parameter documentation. + NDImage : The ``NDGraphic`` that wraps this processor. + """ + + # set as False until data, window funcs stuff and spatial func is all set + self._compute_histogram = False + + # make sure rgb dim is size 3 or 4 + if rgb_dim is not None: + dim_index = dims.index(rgb_dim) + if data.shape[dim_index] not in (3, 4): + raise IndexError( + f"The size of the RGB(A) dim must be 3 | 4. You have specified an array of shape: {data.shape}, " + f"with dims: {dims}, and specified the ``rgb_dim`` name as: {rgb_dim} which has size " + f"{data.shape[dim_index]} != 3 | 4" + ) + + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + slider_dim_transforms=slider_dim_transforms, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + ) + + self.rgb_dim = rgb_dim + self._compute_histogram = compute_histogram + self._recompute_histogram() + + @property + def data(self) -> ArrayProtocol | None: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + # check that it's generally array-like + raise TypeError( + f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" + f"{ARRAY_LIKE_ATTRS}, or they must be `None`" + ) + + if data.ndim < 2: + # ndim < 2 makes no sense for image data + raise IndexError( + f"Image data must have a minimum of 2 dimensions, you have passed an array of shape: {data.shape}" + ) + + self._data = data + self._recompute_histogram() + + @property + def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + """ + Spatial dims, **in display order**. + + [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] + """ + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str] | tuple[str, str, str]): + for dim in sdims: + if dim not in self.dims: + raise KeyError + + if len(sdims) not in (2, 3): + raise ValueError( + f"There must be 2 or 3 spatial dims for images indicating [row_dim, col_dim] or " + f"[row_dims, col_dim, rgb(a) dim]. You passed: {sdims}" + ) + + self._spatial_dims = tuple(sdims) + + @property + def rgb_dim(self) -> str | None: + """ + get or set the RGB(A) dim name, ``None`` if no RGB(A) dim exists + """ + return self._rgb + + @rgb_dim.setter + def rgb_dim(self, rgb: str | None): + if rgb is not None: + if rgb not in self.dims: + raise KeyError + + self._rgb = rgb + + @property + def compute_histogram(self) -> bool: + """get or set whether or not to compute the histogram""" + return self._compute_histogram + + @compute_histogram.setter + def compute_histogram(self, compute: bool): + if compute: + if not self._compute_histogram: + # compute a histogram + self._recompute_histogram() + self._compute_histogram = True + else: + self._compute_histogram = False + self._histogram = None + + @property + def histogram(self) -> tuple[np.ndarray, np.ndarray] | None: + """ + an estimate of the histogram of the data, (histogram_values, bin_edges). + + returns `None` if `compute_histogram` is `False` + """ + return self._histogram + + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: + """ + Get the data at the given index, process data through the window functions. + + Note that we do not use __getitem__ here since the index is a tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + + Parameters + ---------- + indices: tuple[int, ...] + Get the processed data at this index. Must provide a value for each dimension. + Example: get((100, 5)) + + """ + # this will be squeezed output, with dims in the order of the user set spatial dims + window_output = await self.get_window_output(indices) + + # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool + if self.spatial_func is not None: + if isinstance(window_output, CudaArrayProtocol): + window_output = self._spatial_func(window_output) + else: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + if window_output.ndim != len(self.spatial_dims): + raise ValueError + + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(window_output, CudaArrayProtocol): + window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) + + return window_output.transpose(*self.spatial_dims_indices) + + def _recompute_histogram(self): + """ + + Returns + ------- + (histogram_values, bin_edges) + + """ + if not self._compute_histogram or self.data is None: + self._histogram = None + return + + if self.spatial_func is not None: + # don't subsample spatial dims if a spatial function is used + # spatial functions often operate on the spatial dims, ex: a gaussian kernel + # so their results require the full spatial resolution, the histogram of a + # spatially subsampled image will be very different + ignore_dims = [self.dims.index(dim) for dim in self.spatial_dims] + else: + ignore_dims = None + + # TODO: account for window funcs + + sub = subsample_array(self.data, ignore_dims=ignore_dims) + + sub_real = sub[~(np.isnan(sub) | np.isinf(sub))] + + self._histogram = np.histogram(sub_real, bins=100) + + +class NDImage(NDGraphic): + def __init__( + self, + ref_index: ReferenceIndex, + nd_subplot: NDWSubplot, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: ( + tuple[str, str] | tuple[str, str, str] + ), # must be in order! [rows, cols] | [z, rows, cols] + rgb_dim: str | None = None, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + compute_histogram: bool = True, + slider_dim_transforms=None, + processor_type: type[NDImageProcessor] = NDImageProcessor, + colorspace: Literal[ + "srgb", "tex-srgb", "physical", "yuv420p", "yuv444p" + ] = "srgb", + colorrange: Literal["full", "limited"] = "full", + name: str = None, + ): + """ + ``NDGraphic`` subclass for n-dimensional image rendering. + + Wraps an :class:`NDImageProcessor` and manages either an ``ImageGraphic`` or``ImageVolumeGraphic``. + swaps automatically when :attr:`spatial_dims` is reassigned at runtime. Also + owns an ``ImguiColorbar`` for interactive vmin, vmax adjustment. + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + dimension. Each slider dim must have a ``ReferenceRange`` defined in the + ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct + a change in the ``ReferenceIndex`` and update the graphics. + + Parameters + ---------- + ref_index : ReferenceIndex + The shared reference index that delivers slider updates to this graphic. + + nd_subplot : NDWSubplot + parent NDWSubplot the NDGraphic is in + + data : array-like or None + n-dimension image data array + + dims : sequence of hashable + Name for every dimension of ``data``, in order. Non-spatial dims must + match keys in ``ref_index``. + + ex: ``("time", "depth", "row", "col")`` — ``"time"`` and ``"depth"`` must + be present in ``ref_index``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + Spatial dimensions **in order**: ``(rows, cols)`` for 2-D images or + ``(z, rows, cols)`` for volumes. Controls whether an ``ImageGraphic`` or + ``ImageVolumeGraphic`` is used. + + rgb_dim : str, optional + Name of the RGB or channel dimension, if present. + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + compute_histogram : bool, default ``True`` + Whether to initialize the ``ImguiColorbar``. + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + name : str, optional + Name for the underlying graphic. + + See Also + -------- + NDImageProcessor : The processor that backs this graphic. + + """ + + if not (set(dims) - set(spatial_dims)).issubset(ref_index.dims): + raise IndexError( + f"all specified `dims` must either be a spatial dim or a slider dim " + f"specified in the NDWidget ref_ranges, provided dims: {dims}, " + f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" + ) + + super().__init__(nd_subplot, name) + + self._ref_index = ref_index + + self._processor = processor_type( + data, + dims=dims, + spatial_dims=spatial_dims, + rgb_dim=rgb_dim, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + compute_histogram=compute_histogram, + slider_dim_transforms=slider_dim_transforms, + ) + + self._colorspace = colorspace + self._colorrange = colorrange + + self._graphic: ImageGraphic | ImageYUVGraphic | None = None + self._histogram_widget: ImguiColorbar | None = None + + # create a graphic + run_sync(self._create_graphic()) + + @property + def processor(self) -> NDImageProcessor: + """NDProcessor that manages the data and produces data slices to display""" + return self._processor + + @property + def graphic( + self, + ) -> ImageGraphic | ImageYUVGraphic | ImageVolumeGraphic: + """Underlying Graphic object used to display the current data slice""" + return self._graphic + + async def _create_graphic(self): + # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, + # adds it to the subplot, and resets the camera and histogram. + + if self.processor.data is None: + # no graphic if data is None, useful for initializing in null states when we want to set data later + return + + kwargs = { + "colorspace": self._colorspace, + } + + if self._colorspace in {cs.value for cs in enums.ColorspacesYUV}: + cls = ImageYUVGraphic + kwargs["colorrange"] = self._colorrange + else: + # determine if we need a 2d image or 3d volume + # remove RGB spatial dim, ex: if we have an RGBA image of shape [512, 512, 4] we want to interpet this as + # 2D for images + # [30, 512, 512, 4] with an rgb dim is an RGBA volume which is also supported + match len(self.processor.spatial_dims) - int(bool(self.processor.rgb_dim)): + case 2: + cls = ImageGraphic + case 3: + cls = ImageVolumeGraphic + + # get the data slice for this index + # this will only have the dims specified by ``spatial_dims`` + data_slice = await self.processor.get(self.indices) + + # create the new graphic + new_graphic = cls( + data_slice, + # cpu_buffer=False, # faster, we usually don't need a cpu buffer for NDWidget use cases + **kwargs, + ) + + old_graphic = self._graphic + # check if we are replacing a graphic + # ex: swapping from 2D <-> 3D representation after ``spatial_dims`` was changed + if old_graphic is not None: + # carry over some attributes from old graphic + attrs = dict.fromkeys(["cmap", "interpolation", "cmap_interpolation"]) + for k in attrs: + attrs[k] = getattr(old_graphic, k) + + # delete the old graphic + self._nd_subplot.subplot.delete_graphic(old_graphic) + + # set any attributes that we're carrying over like cmap + for attr, val in attrs.items(): + setattr(new_graphic, attr, val) + + self._graphic = new_graphic + + self._nd_subplot.subplot.add_graphic(self._graphic) + + self._reset_camera() + self._reset_histogram() + + def _reset_histogram(self): + # reset histogram + if self.graphic is None: + return + + subplot = self._nd_subplot.subplot + + if not self.processor.compute_histogram: + # remove the colorbar from the right edge if a histogram is not desired + if self._histogram_widget is not None: + subplot.remove_imgui_window("right") + self._histogram_widget = None + return + + if self.processor.histogram: + if self._histogram_widget is not None: + # colorbar widget exists, update it and rebind to the current graphic + self._histogram_widget.histogram = self.processor.histogram + self._histogram_widget.images = self.graphic + else: + # make the colorbar, it reserves space on the subplot's right edge + self._histogram_widget = ImguiColorbar( + images=self.graphic, + histogram=self.processor.histogram, + ) + subplot.add_imgui_window( + self._histogram_widget, location="right", size=100 + ) + + self.graphic.reset_vmin_vmax() + + def _reset_camera(self): + # set camera to a nice position based on whether it's a 2D ImageGraphic or 3D ImageVolumeGraphic + if isinstance(self._graphic, (ImageGraphic, ImageYUVGraphic)): + # set camera orthogonal to the xy plane, flip y axis + self._nd_subplot.subplot.camera.set_state( + { + "position": [0, 0, -1], + "rotation": [0, 0, 0, 1], + "scale": [1, -1, 1], + "reference_up": [0, 1, 0], + "fov": 0, # orthographic projection + "depth_range": None, + } + ) + + self._nd_subplot.controller = "panzoom" + self._nd_subplot.subplot.axes.intersection = None + self._nd_subplot.subplot.auto_scale() + + else: + # It's not an ImageGraphic, set perspective projection + self._nd_subplot.subplot.camera.fov = 50 + self._nd_subplot.controller = "orbit" + + # set all 3D dimension camera scales to positive since positive scales + # are typically used for looking at volumes + for dim in ["x", "y", "z"]: + if getattr(self._nd_subplot.subplot.camera.local, f"scale_{dim}") < 0: + setattr(self._nd_subplot.subplot.camera.local, f"scale_{dim}", 1) + + self._nd_subplot.subplot.auto_scale() + + @property + def spatial_dims(self) -> tuple[str, str] | tuple[str, str, str]: + """ + get or set the spatial dims **in order** + + [row_dim, col_dim] or [row_dim, col_dim, rgb(a) dim] + """ + return self.processor.spatial_dims + + @spatial_dims.setter + def spatial_dims(self, dims: tuple[str, str] | tuple[str, str, str]): + self.processor.spatial_dims = dims + + # shape has probably changed, recreate graphic + run_sync(self._create_graphic()) + + @property + def indices(self) -> dict[str, Any]: + """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" + return {d: self._ref_index[d] for d in self.processor.slider_dims} + + async def _set_indices_(self, indices: dict[str, Any] = None): + if indices is None: + # current indices, else use the indices passed at schedule time + indices = self.indices + + self.graphic.data = await self.processor.get(indices) + self._last_indices = indices + + @property + def compute_histogram(self) -> bool: + """whether or not to compute the histogram and display the ImguiColorbar""" + return self.processor.compute_histogram + + @compute_histogram.setter + def compute_histogram(self, v: bool): + self.processor.compute_histogram = v + self._reset_histogram() + + @property + def histogram_widget(self) -> ImguiColorbar: + """The colorbar associated with this NDGraphic""" + return self._histogram_widget + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial_func, see docstring for details""" + # this is here even though it's the same in the base class since we can't create the image specific setter + # without also defining the property in this subclass. + return self.processor.spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[ArrayProtocol], ArrayProtocol] + ) -> Callable | None: + self.processor.spatial_func = func + self.processor._recompute_histogram() + self._reset_histogram() + + def _tooltip_handler(self, graphic, pick_info): + # TODO: need to do this better + # get graphic within the collection + n_index = np.argwhere(self.graphic.graphics == graphic).item() + p_index = pick_info["vertex_index"] + return self.processor.tooltip_format(n_index, p_index) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py new file mode 100644 index 000000000..978a082c6 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/__init__.py @@ -0,0 +1,24 @@ +import importlib + +from ._nd_positions import NDPositions, NDPositionsProcessor +from ._nd_timeseries import NDTimeseries + +class Extras: + pass + +ndp_extras = Extras() + + +for optional in ["pandas", "zarr"]: + try: + importlib.import_module(optional) + except ImportError: + pass + else: + module = importlib.import_module(f"._{optional}", "fastplotlib.widgets.nd_widget._nd_positions") + cls = getattr(module, f"NDPP_{optional.capitalize()}") + setattr( + ndp_extras, + f"NDPP_{optional.capitalize()}", + cls + ) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py new file mode 100644 index 000000000..9ca5eee93 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py @@ -0,0 +1,1073 @@ +from __future__ import annotations + +from collections.abc import Callable, Hashable, Sequence +from functools import partial +from typing import Any, Type, TYPE_CHECKING +from warnings import warn + +import numpy as np +from numpy.lib.stride_tricks import sliding_window_view +from numpy.typing import ArrayLike + +from ....graphics import ( + LineGraphic, + LineStack, + LineCollection, + ScatterGraphic, + ScatterCollection, + ScatterStack, +) +from ....graphics.features.utils import parse_colors +from .._base import ( + NDProcessor, + NDGraphic, + WindowFuncCallable, +) +from ....utils import ArrayProtocol, CudaArrayProtocol, cuda_to_numpy +from .._index import ReferenceIndex +from .._async import run_in_thread_pool, run_sync + +if TYPE_CHECKING: + from .._ndw_subplot import NDWSubplot + +# types for the other features +FeatureCallable = Callable[[np.ndarray, slice], np.ndarray] +ColorsType = np.ndarray | FeatureCallable | None +MarkersType = Sequence[str] | np.ndarray | FeatureCallable | None +SizesType = Sequence[float] | np.ndarray | FeatureCallable | None + + +def default_cmap_transform_each(p: int, data_slice: np.ndarray, s: slice): + # create a cmap transform based on the `p` dim size + n_displayed = data_slice.shape[1] + + # linspace that's just normalized 0 - 1 within `p` dim size + return np.linspace( + start=s.start / p, + stop=s.stop / p, + num=n_displayed, + endpoint=False, # since we use a slice object for the displayed data, the last point isn't included + ) + + +class NDPositionsProcessor(NDProcessor): + _other_features = ["colors", "markers", "cmap_transform_each", "sizes"] + + def __init__( + self, + data: Any, + dims: Sequence[str], + # TODO: allow stack_dim to be None and auto-add new dim of size 1 in get logic + spatial_dims: tuple[ + str | None, str, str + ], # [stack_dim, n_datapoints, spatial_dim], IN ORDER!! + slider_dim_transforms: dict[str, Callable[[Any], int] | ArrayLike] = None, + display_window: int | float | None = 100, # window for n_datapoints dim only + max_display_datapoints: int = 1_000, + datapoints_window_func: tuple[Callable, str, int | float] | None = None, + colors: ColorsType = None, + markers: MarkersType = None, + cmap_transform_each: np.ndarray = None, + sizes: SizesType = None, + **kwargs, + ): + """ + ``NDProcessor`` subclass for n-dimensional positional and timeseries data. + + + The *datapoints* dimension is + simultaneously a slider dim and a spatial dim and is handled by a dedicated + :attr:`datapoints_window_func` rather than the general ``window_funcs`` + mechanism. + + + Parameters + ---------- + data + dims + spatial_dims + slider_dim_transforms + display_window + max_display_datapoints: int, default 1_000 + this is approximate since floor division is used to determine the step size of the current display window slice + datapoints_window_func: + Important note: if used, display_window is approximate and not exact due to padding from the window size + kwargs + """ + self._display_window = display_window + self._max_display_datapoints = max_display_datapoints + + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + slider_dim_transforms=slider_dim_transforms, + **kwargs, + ) + + self._datapoints_window_func = datapoints_window_func + + self.colors = colors + self.markers = markers + self.cmap_transform_each = cmap_transform_each + self.sizes = sizes + + + def _check_shape_feature( + self, prop: str, check_shape: tuple[int, int] + ) -> tuple[int, int]: + # this function exists because it's used repeatedly for colors, markers, etc. + # shape for [l, p] dims must match, or l must be 1 + shape = tuple([self.shape[dim] for dim in self.spatial_dims[:2]]) + + if check_shape[1] != shape[1]: + raise IndexError( + f"shape of first two dims of {prop} must must be [l, p] or [1, p].\n" + f"required `p` dim shape is: {shape[1]}, {check_shape[1]} was provided" + ) + + if check_shape[0] != 1 and check_shape[0] != shape[0]: + raise IndexError( + f"shape of first two dims of {prop} must must be [l, p] or [1, p]\n" + f"required `l` dim shape is {shape[0]} | 1, {check_shape[0]} was provided" + ) + + return shape + + @property + def colors(self) -> ColorsType: + """ + A callable that dynamically creates colors for the current display window, or array of colors per-datapoint. + + Array must be of shape [l, p, 4] for unique colors per line/scatter, or [1, p, 4] for identical colors per + line/scatter. + + Callable must return an array of shape [l, pw, 4] or [1, pw, 4], where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ + return self._colors + + @colors.setter + def colors(self, new): + if callable(new): + # custom callable that creates the colors + self._colors = new + return + + if new is None: + self._colors = None + return + + # as array so we can check shape + new = np.asarray(new) + if new.ndim == 2: + # only [p, 4] provided, broadcast to [1, p, 4] + new = new[None] + + shape = self._check_shape_feature("colors", new.shape[:2]) + + if new.shape[0] == 1: + # same colors across all graphical elements + self._colors = parse_colors(new[0], n_colors=shape[1])[None] + + else: + # colors specified for each individual line/scatter + new_ = np.zeros(shape=(*self.data.shape[:2], 4), dtype=np.float32) + for i in range(shape[0]): + new_[i] = parse_colors(new[i], n_colors=shape[1]) + + self._colors = new_ + + @property + def markers(self) -> MarkersType: + """ + A callable that dynamically creates markers for the current display window, or array of markers per-datapoint. + + Array must be of shape [l, p] for unique markers per line/scatter, or [p,] or [1, p] for identical markers per + line/scatter. + + Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ + return self._markers + + @markers.setter + def markers(self, new: MarkersType): + if callable(new): + # custom callable that creates the markers dynamically + self._markers = new + return + + if new is None: + self._markers = None + return + + # as array so we can check shape + new = np.asarray(new) + + # if 1-dim, assume it's specifying markers over `p` dim, so set `l` dim to 1 + if new.ndim == 1: + new = new[None] + + self._check_shape_feature("markers", new.shape[:2]) + + self._markers = np.asarray(new) + + @property + def cmap_transform_each(self) -> np.ndarray | FeatureCallable | None: + return self._cmap_transform_each + + @cmap_transform_each.setter + def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): + """ + A callable that dynamically creates cmap transforms for the current display window, or array + of transforms per-datapoint. + + Array must be of shape [l, p] for unique transforms per line/scatter, or [p,] or [1, p] for identical markers + per line/scatter. + + Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ + if callable(new): + self._cmap_transform_each = new + return + + if new is None: + self._cmap_transform_each = None + return + + new = np.asarray(new) + + # if 1-dim, assume it's specifying sizes over `p` dim, set `l` dim to 1 + if new.ndim == 1: + new = new[None] + + self._check_shape_feature("cmap_transform_each", new.shape) + + self._cmap_transform_each = new + + @property + def sizes(self) -> SizesType: + return self._sizes + + @sizes.setter + def sizes(self, new: SizesType): + """ + A callable that dynamically creates sizes for the current display window, or array of sizes per-datapoint. + + Array must be of shape [l, p] for unique sizes per line/scatter, or [p,] or [1, p] for identical markers per + line/scatter. + + Callable must return an array of shape [l, pw], [1, pw], or [pw,] where pw is the number of currently displayed + datapoints given the current display window. The callable receives the current data slice array, as well as the + slice object that corresponds to the current display window. + """ + if callable(new): + # custom callable + self._sizes = new + return + + if new is None: + self._sizes = None + return + + new = np.array(new) + # if 1-dim, assume it's specifying sizes over `p` dim, set `l` dim to 1 + if new.ndim == 1: + new = new[None] + + self._check_shape_feature("sizes", new.shape) + + self._sizes = new + + @property + def spatial_dims(self) -> tuple[str, str, str]: + """get or set the spatial dims, **in display order**""" + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str, str]): + if len(sdims) != 3: + raise IndexError + + if not all([d in self.dims for d in sdims]): + raise KeyError + + self._spatial_dims = tuple(sdims) + + @property + def slider_dims(self) -> set[Hashable]: + # append `p` dim to slider dims + return tuple([*super().slider_dims, self.spatial_dims[1]]) + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self._display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + if dw is None: + self._display_window = None + + elif not isinstance(dw, (int, float)): + raise TypeError + + self._display_window = dw + + @property + def max_display_datapoints(self) -> int: + return self._max_display_datapoints + + @max_display_datapoints.setter + def max_display_datapoints(self, n: int): + if not isinstance(n, (int, np.integer)): + raise TypeError + if n < 2: + raise ValueError + + self._max_display_datapoints = n + + # TODO: validation for datapoints_window_func and size + @property + def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: + """ + Callable, str indicating which dims to apply window function along, window_size in reference space: + 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' + '""" + return self._datapoints_window_func + + @datapoints_window_func.setter + def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): + if len(funcs) != 3: + raise TypeError + + self._datapoints_window_func = tuple(funcs) + + def _get_dw_slice(self, indices: dict[str, Any]) -> slice: + # given indices, return slice required to obtain display window + + # n_datapoints dim name + # display_window acts on this dim + p_dim = self.spatial_dims[1] + + if self.display_window is None: + # just return everything + return slice(0, self.shape[p_dim]) + + if self.display_window == 0: + # just map p dimension at this index and return + index = self._ref_index_to_array_index(p_dim, indices[p_dim]) + return slice(index, index + 1) + + # half window size, in reference units + hw = self.display_window / 2 + + if self.datapoints_window_func is not None: + # add half datapoints_window_func size here, assumes the reference space is somewhat continuous + # and the display_window and datapoints window size map to their actual size values + hw += self.datapoints_window_func[2] / 2 + + # display window is in reference units, apply display window and then map to array indices + # start in reference units + start_ref = indices[p_dim] - hw + # stop in reference units + stop_ref = indices[p_dim] + hw + + # map to array indices + start = self._ref_index_to_array_index(p_dim, start_ref) + stop = self._ref_index_to_array_index(p_dim, stop_ref) + + if start >= stop: + stop = start + 1 + + w = stop - start + + # get step size + step = max(1, w // self.max_display_datapoints) + + return slice(start, stop, step) + + def _apply_dw_window_func(self, array: ArrayProtocol) -> ArrayProtocol: + """ + Takes array where display window has already been applied and applies window functions on the `p` dim. + + Parameters + ---------- + array: ArrayProtocol + array of shape: [l, display_window, 2 | 3] + + Returns + ------- + ArrayProtocol + array with window functions applied along `p` dim + """ + if self.display_window == 0: + # can't apply window func when there is only 1 datapoint + return array + + p_dim = self.spatial_dims[1] + + # display window in array index space + if self.display_window is not None: + dw = self.slider_dim_transforms[p_dim](self.display_window) + + # step size based on max number of datapoints to render + step = max(1, dw // self.max_display_datapoints) + + # apply window function on the `p` n_datapoints dim + if ( + self.datapoints_window_func is not None + # if there are too many points to efficiently compute the window func, skip + # applying a window func also requires making a copy so that's a further performance hit + and (dw < self.max_display_datapoints * 2) + ): + # get windows + + # graphic_data will be of shape: [n, p, 2 | 3] + # where: + # n - number of lines, scatters, heatmap rows + # p - number of datapoints/samples + + # ws is in ref units + wf, apply_dims, ws = self.datapoints_window_func + + # map ws in ref units to array index + # min window size is 3 + ws = max(self._ref_index_to_array_index(p_dim, ws), 3) + + if ws % 2 == 0: + # odd size windows are easier to handle + ws += 1 + + hw = ws // 2 + start, stop = hw, array.shape[1] - hw + + # apply user's window func + # result will be of shape [n, p, 2 | 3] + if apply_dims == "all": + # windows will be of shape [n, p, 1 | 2 | 3, ws] + windows = sliding_window_view(array, ws, axis=-2) + return wf(windows, axis=-1)[:, ::step] + + # map user dims str to tuple of numerical dims + coor_dims = tuple(map({"x": 0, "y": 1, "z": 2}.get, apply_dims)) + + # windows will be of shape [n, (p - ws + 1), 1 | 2 | 3, ws] + windows = sliding_window_view( + array[..., coor_dims], ws, axis=-2 + ).squeeze() + + # make a copy because we need to modify it + array = array[:, start:stop].copy() + + # this reshape is required to reshape wf outputs of shape [n, p] -> [n, p, 1] only when necessary + array[..., coor_dims] = wf(windows, axis=-1).reshape( + *array.shape[:-1], len(coor_dims) + ) + + return array[:, ::step] + + step = max(1, array.shape[1] // self.max_display_datapoints) + + return array[:, ::step] + + def _apply_spatial_func(self, array: ArrayProtocol) -> ArrayProtocol: + if self.spatial_func is not None: + return self.spatial_func(array) + + return array + + def _finalize(self, array: ArrayProtocol) -> ArrayProtocol: + return self._apply_spatial_func(self._apply_dw_window_func(array)) + + def _get_other_features( + self, data_slice: ArrayProtocol, dw_slice: slice + ) -> dict[str, ArrayProtocol]: + other = dict.fromkeys(self._other_features) + for attr in self._other_features: + val = getattr(self, attr) + + if val is None: + continue + + if callable(val): + # if it's a callable, give it the data and display window slice, it must return the appropriate + # type of array for that graphic feature + val_sliced = val(data_slice, dw_slice) + + else: + # if no l dim, broadcast to [1, p] + if val.ndim == 1: + val = val[None] + + # apply current display window slice + val_sliced = val[:, dw_slice] + + # check if l dim size is 1 + if val_sliced.shape[0] == 1: + # broadcast across all graphical elements + n_graphics = self.shape[self.spatial_dims[0]] + val_sliced = np.broadcast_to( + val_sliced, shape=(n_graphics, *val_sliced.shape[1:]) + ) + + other[attr] = val_sliced + + return other + + async def get(self, indices: dict[str, Any]) -> dict[str, ArrayProtocol]: + """ + slices through all slider dims and outputs an array that can be used to set graphic data + + Note that we do not use __getitem__ here since the index is a tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + """ + # already squeezed and in the correct spatial_dims order + window_output = await self.get_window_output(indices) + + # get slice obj for display window + dw_slice = self._get_dw_slice(indices) + + # data that will be used for the graphical representation + # slice the datapoints to be displayed in the graphic using the display window slice + # data are already squeezed & transposed w.r.t the spatial_dims order after get_window_output() + # p_dims is dim 1 + graphic_data = window_output[:, dw_slice] + + # _finalize runs the user's datapoints_window_func and spatial_func. + if isinstance(graphic_data, CudaArrayProtocol): + # the datapoints_window_func and spatial_func should be direct on-cuda functions + # ex: torch functions that can take cuda arrays directly + data = self._finalize(graphic_data) + else: + # run CPU functions, probably numpy-based, in a thread pool + data = await run_in_thread_pool( + self._executor, self._finalize, graphic_data + ) + + other = self._get_other_features(data, dw_slice) + + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(data, CudaArrayProtocol): + data = await run_in_thread_pool(self._executor, cuda_to_numpy, data) + + data = data.transpose(*self.spatial_dims_indices) + + return { + "data": data, + **other, + } + + +class NDPositions(NDGraphic): + def __init__( + self, + ref_index: ReferenceIndex, + nd_subplot: NDWSubplot, + data: Any, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + graphic_type: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + ], + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + colors: ( + Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] + ) = None, + # TODO: cleanup how this cmap stuff works, require a cmap to be set per-graphic + # before allowing cmaps_transform, validate that stuff makes sense etc. + cmap: str = None, # across the line/scatter collection + cmap_each: Sequence[str] = None, # for each individual line/scatter + cmap_transform_each: np.ndarray = None, # for each individual line/scatter + markers: np.ndarray = None, # across the scatter collection, shape [l,] + markers_each: Sequence[str] = None, # for each individual scatter, shape [l, p] + sizes: np.ndarray = None, # across the scatter collection, shape [l,] + sizes_each: Sequence[float] = None, # for each individual scatter, shape [l, p] + thickness: np.ndarray = None, # for each line, shape [l,] + name: str = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ): + """ + Wraps an :class:`NDPositionsProcessor` and supports four interchangeable + graphical representations: ``LineStack``, ``LineCollection``, ``ScatterStack``, + and ``ScatterCollection``. + + Parameters + ---------- + ref_index + nd_subplot + data + dims + spatial_dims + args + graphic_type + processor + display_window + window_funcs + slider_dim_transforms + max_display_datapoints + colors + cmap + cmap_each + cmap_transform_each + markers + markers_each + sizes + sizes_each + thickness + name + graphic_kwargs + processor_kwargs + """ + + super().__init__(nd_subplot, name) + + self.init( + ref_index, + data, + dims, + spatial_dims, + *args, + graphic_type=graphic_type, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + colors=colors, + cmap=cmap, + cmap_each=cmap_each, + cmap_transform_each=cmap_transform_each, + markers=markers, + markers_each=markers_each, + sizes=sizes, + sizes_each=sizes_each, + thickness=thickness, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, + ) + + run_sync(self._create_graphic()) + + def init( + self, + ref_index: ReferenceIndex, + data: Any, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + graphic_type: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + ], + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + colors: ( + Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] + ) = None, + cmap: str = None, + cmap_each: Sequence[str] = None, + cmap_transform_each: np.ndarray = None, + markers: np.ndarray = None, + markers_each: Sequence[str] = None, + sizes: np.ndarray = None, + sizes_each: Sequence[float] = None, + thickness: np.ndarray = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ): + """ + Set up the processor and per-graphic state, i.e. everything except creating the graphic. + + Separated from ``__init__`` so ``NDTimeseries`` can run its own one-time setup + between this and graphic creation. + """ + self._ref_index = ref_index + + if processor_kwargs is None: + processor_kwargs = dict() + + if graphic_kwargs is None: + self._graphic_kwargs = dict() + else: + self._graphic_kwargs = graphic_kwargs + + self._processor = processor( + data, + dims, + spatial_dims, + *args, + display_window=display_window, + max_display_datapoints=max_display_datapoints, + window_funcs=window_funcs, + slider_dim_transforms=slider_dim_transforms, + colors=colors, + markers=markers_each, + cmap_transform_each=cmap_transform_each, + sizes=sizes_each, + **processor_kwargs, + ) + + self._cmap = cmap + self._sizes = sizes + self._markers = markers + self._thickness = thickness + + self.cmap_each = cmap_each + self.cmap_transform_each = cmap_transform_each + + self._graphic_type = graphic_type + + @property + def processor(self) -> NDPositionsProcessor: + return self._processor + + @property + def graphic( + self, + ) -> ( + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + | None + ): + return self._graphic + + @property + def graphic_type( + self, + ) -> Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + ]: + return self._graphic_type + + @graphic_type.setter + def graphic_type(self, graphic_type): + if type(self.graphic) is graphic_type: + return + + self._nd_subplot.subplot.delete_graphic(self._graphic) + self._graphic_type = graphic_type + run_sync(self._create_graphic()) + + @property + def spatial_dims(self) -> tuple[str, str, str]: + return self.processor.spatial_dims + + @spatial_dims.setter + def spatial_dims(self, dims: tuple[str, str, str]): + self.processor.spatial_dims = dims + # force re-render + run_sync(self._set_indices_()) + + @property + def indices(self) -> dict[Hashable, Any]: + return {d: self._ref_index[d] for d in self.processor.slider_dims} + + async def _get_data_slice(self, indices: dict[str, Any]) -> dict[str, Any]: + return await self.processor.get(indices) + + async def _set_indices_(self, indices: dict[str, Any] = None): + if self.data is None: + return + + if indices is None: + # fetch the latest indices from the ReferenceIndex + # else use passed indices from schedule time + indices = self.indices + + new_features = await self._get_data_slice(indices) + self._update_graphic(new_features, indices) + self._last_indices = indices + + def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + data_slice = new_features["data"] + + if isinstance(self.graphic, (LineGraphic, ScatterGraphic)): + self.graphic.data[:, : data_slice.shape[-1]] = data_slice + + elif isinstance(self.graphic, (LineCollection, ScatterCollection)): + for l, g in enumerate(self.graphic.graphics): + new_data = data_slice[l] + if g.data.value.shape[0] != new_data.shape[0]: + # will replace buffer internally + g.data = new_data + else: + # if data are only xy, set only xy + g.data[:, : new_data.shape[1]] = new_data + + for feature in ["colors", "sizes", "markers"]: + value = new_features.get(feature, None) + + match value: + case None: + pass + case _: + if feature == "colors": + g.color_mode = "vertex" + + setattr(g, feature, value[l]) + + if self.cmap_each is not None: + match new_features["cmap_transform_each"]: + case None: + pass + case _: + setattr( + getattr(g, "cmap"), # ind_graphic.cmap + "transform", + new_features["cmap_transform_each"], + ) + + def _tooltip_handler(self, graphic, pick_info): + if isinstance(self.graphic, (LineCollection, ScatterCollection)): + # get graphic within the collection + n_index = np.argwhere(self.graphic.graphics == graphic).item() + p_index = pick_info["vertex_index"] + return self.processor.tooltip_format(n_index, p_index) + + async def _create_graphic(self): + if self.data is None: + return + + new_features = await self._get_data_slice(self.indices) + self._setup_graphic(new_features, self.indices) + + def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + """Build, configure, and add the graphic for the current slice.""" + data_slice = new_features["data"] + + # store any cmap, sizes, thickness, etc. to assign to new graphic + graphic_attrs = dict() + for attr in ["cmap", "markers", "sizes", "thickness"]: + if attr in new_features.keys(): + if new_features[attr] is not None: + # markers and sizes defined for each line via processor takes priority + continue + + val = getattr(self, attr) + if val is not None: + graphic_attrs[attr] = val + + if issubclass(self._graphic_type, (LineStack, ScatterStack)): + kwargs = {"separation": 0.0, **self._graphic_kwargs} + else: + kwargs = self._graphic_kwargs + self._graphic = self._graphic_type(data_slice, **kwargs) + + for attr in graphic_attrs.keys(): + if hasattr(self._graphic, attr): + setattr(self._graphic, attr, graphic_attrs[attr]) + + if isinstance(self._graphic, (LineCollection, ScatterCollection)): + for l, g in enumerate(self.graphic.graphics): + for feature in ["colors", "sizes", "markers"]: + value = new_features.get(feature, None) + + match value: + case None: + pass + case _: + if feature == "colors": + g.color_mode = "vertex" + + setattr(g, feature, value[l]) + + if self.cmap_each is not None: + g.color_mode = "vertex" + g.cmap = self.cmap_each[l] + match new_features["cmap_transform_each"]: + case None: + pass + case _: + setattr( + getattr(g, "cmap"), # indv_graphic.cmap + "transform", + new_features["cmap_transform_each"], + ) + + if self.processor.tooltip: + if isinstance(self._graphic, (LineCollection, ScatterCollection)): + for g in self._graphic.graphics: + g.tooltip_format = partial(self._tooltip_handler, g) + + self._nd_subplot.subplot.add_graphic(self._graphic) + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + self.processor.display_window = dw + # force re-render + run_sync(self._set_indices_()) + + @property + def datapoints_window_func(self) -> tuple[Callable, str, int | float] | None: + """ + Callable, str indicating which dims to apply window function along, window_size in reference space: + 'all', 'x', 'y', 'z', 'xyz', 'xy', 'xz', 'yz' + '""" + return self.processor.datapoints_window_func + + @datapoints_window_func.setter + def datapoints_window_func(self, funcs: tuple[Callable, str, int | float]): + self.processor.datapoints_window_func = funcs + + @property + def cmap(self) -> str | None: + return self._cmap + + @cmap.setter + def cmap(self, new: str | None): + if new is None: + # just set a default + if isinstance(self.graphic, (LineCollection, ScatterCollection)): + self.graphic.colors = "w" + else: + self.graphic.cmap = "plasma" + + self._cmap = None + return + + self._graphic.cmap = new + self._cmap = new + # force a re-render + run_sync(self._set_indices_()) + + @property + def cmap_each(self) -> np.ndarray[str] | None: + # per-line/scatter + return self._cmap_each + + @cmap_each.setter + def cmap_each(self, new: Sequence[str] | None): + if new is None: + self._cmap_each = None + return + + if isinstance(new, str): + new = [new] + + new = np.asarray(new) + + if new.ndim != 1: + raise ValueError + + l_dim_size = self.processor.shape[self.processor.spatial_dims[0]] + # same cmap for all if size == 1, or specific cmap for each in `l` dim + if new.size != 1 and new.size != l_dim_size: + raise ValueError + + self._cmap_each = np.broadcast_to(new, shape=(l_dim_size,)) + + @property + def cmap_transform_each(self) -> np.ndarray | None: + # PER line/scatter, only allowed after `cmaps` is set. + return self.processor.cmap_transform_each + + @cmap_transform_each.setter + def cmap_transform_each(self, new: np.ndarray | FeatureCallable | None): + if new is None: + self.processor.cmap_transform_each = None + + if self.cmap_each is None: + self.processor.cmap_transform_each = None + warn("must set `cmap_each` before `cmap_transform_each`") + return + + if new is None and self.cmap_each is not None: + # default transform is just a transform based on the `p` dim size + new = partial(default_cmap_transform_each, self.shape[self.spatial_dims[1]]) + + self.processor.cmap_transform_each = new + + @property + def markers(self) -> str | Sequence[str] | None: + return self._markers + + @markers.setter + def markers(self, new: str | None): + if not isinstance(self.graphic, ScatterCollection): + self._markers = None + return + + if new is None: + # just set a default + new = "circle" + + self.graphic.markers = new + self._markers = new + # force a re-render + run_sync(self._set_indices_()) + + @property + def sizes(self) -> float | Sequence[float] | None: + return self._sizes + + @sizes.setter + def sizes(self, new: float | Sequence[float] | None): + if not isinstance(self.graphic, ScatterCollection): + self._sizes = None + return + + if new is None: + # just set a default + new = 5.0 + + self.graphic.sizes = new + self._sizes = new + # force a re-render + run_sync(self._set_indices_()) + + @property + def thickness(self) -> float | Sequence[float] | None: + return self._thickness + + @thickness.setter + def thickness(self, new: float | Sequence[float] | None): + if not isinstance(self.graphic, LineCollection): + self._thickness = None + return + + if new is None: + # just set a default + new = 2.0 + + self.graphic.thickness = new + self._thickness = new + # force a re-render + run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py new file mode 100644 index 000000000..875474174 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_nd_timeseries.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Literal, Any, Type, TYPE_CHECKING + +import numpy as np + +from ....graphics import ( + ImageGraphic, + LineGraphic, + LineStack, + LineCollection, + ScatterGraphic, + ScatterCollection, + ScatterStack, +) +from ....graphics.utils import pause_events +from ....graphics.selectors import LinearSelector +from .._base import NDGraphic, WindowFuncCallable, block_indices_ctx +from .._index import ReferenceIndex +from .._async import run_sync +from ._nd_positions import NDPositions, NDPositionsProcessor + +if TYPE_CHECKING: + from .._ndw_subplot import NDWSubplot + + +class NDTimeseries(NDPositions): + def __init__( + self, + ref_index: ReferenceIndex, + nd_subplot: NDWSubplot, + data: Any, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + *args, + graphic_type: Type[ + LineGraphic + | LineCollection + | LineStack + | ScatterGraphic + | ScatterCollection + | ScatterStack + | ImageGraphic + ] = LineStack, + processor: type[NDPositionsProcessor] = NDPositionsProcessor, + display_window: int = 10, + window_funcs: tuple[WindowFuncCallable | None] | None = None, + slider_dim_transforms: tuple[Callable[[Any], int] | None] | None = None, + max_display_datapoints: int = 1_000, + linear_selector: bool = False, + x_range_mode: Literal["fixed", "auto"] | None = None, + colors: ( + Sequence[str] | np.ndarray | Callable[[slice, np.ndarray], np.ndarray] + ) = None, + cmap: str = None, + cmap_each: Sequence[str] = None, + cmap_transform_each: np.ndarray = None, + markers: np.ndarray = None, + markers_each: Sequence[str] = None, + sizes: np.ndarray = None, + sizes_each: Sequence[float] = None, + thickness: np.ndarray = None, + name: str = None, + graphic_kwargs: dict = None, + processor_kwargs: dict = None, + ): + """ + ``NDPositions`` for timeseries data, where the datapoints dim is a time-like x-axis. + + Supports the same ``LineStack`` / ``LineCollection`` / ``ScatterStack`` / + ``ScatterCollection`` representations plus a heatmap (``ImageGraphic``) view, and + additionally manages a linear selector and couples the camera x-range to the current + datapoints position via :attr:`x_range_mode`. + + Parameters are the same as :class:`NDPositions`, plus ``linear_selector`` and + ``x_range_mode``. + """ + # NDGraphic base init, then the shared positional setup. We deliberately do not call + # NDPositions.__init__, since it would create the graphic before the timeseries state + # (linear selector, x_range_mode) exists. + NDGraphic.__init__(self, nd_subplot, name) + + self.init( + ref_index, + data, + dims, + spatial_dims, + *args, + graphic_type=graphic_type, + processor=processor, + display_window=display_window, + window_funcs=window_funcs, + slider_dim_transforms=slider_dim_transforms, + max_display_datapoints=max_display_datapoints, + colors=colors, + cmap=cmap, + cmap_each=cmap_each, + cmap_transform_each=cmap_transform_each, + markers=markers, + markers_each=markers_each, + sizes=sizes, + sizes_each=sizes_each, + thickness=thickness, + graphic_kwargs=graphic_kwargs, + processor_kwargs=processor_kwargs, + ) + + # makes some assumptions about positional data that apply only to timeseries representations + # probably don't want to maintain aspect + self._nd_subplot.subplot.camera.maintain_aspect = False + + # determine a min display_window for x_range_mode = "auto" + # determines required world space range for 3 datapoints + p_dim = self.processor.spatial_dims[1] + p_range = self._ref_index.ref_ranges[p_dim] + p_map = self.processor.slider_dim_transforms[p_dim] + p_span = p_range.stop - p_range.start + p_mid = p_range.start + p_span / 2 + i = p_map(p_mid) + i_increment = p_map(p_mid + p_range.step) + delta_p = p_range.step / max(1, i_increment - i) + self._min_display_window = 3 * delta_p + + # display_window = None overrides x_range_mode + if self.processor.display_window is None: + x_range_mode = None + + self._x_range_mode = None + self._last_x_range: tuple[float, float] | None = None + self.x_range_mode = x_range_mode + + # make a linear selector only if one does not already exist in this subplot + if linear_selector and "__ndw_manged_linear_selector" not in self._nd_subplot.subplot: + self._linear_selector = LinearSelector( + 0, limits=(-np.inf, np.inf), edge_color="cyan", name="__ndw_manged_linear_selector" + ) + self._linear_selector.add_event_handler( + self._linear_selector_handler, "selection" + ) + self._nd_subplot.subplot.add_graphic(self._linear_selector) + else: + self._linear_selector = None + + run_sync(self._create_graphic()) + + def _update_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + if isinstance(self.graphic, ImageGraphic): + data_slice = new_features["data"] + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self.graphic.data = image_data + self.graphic.offset = (x0, *self.graphic.offset[1:]) + self.graphic.scale = (x_scale, *self.graphic.scale[1:]) + else: + super()._update_graphic(new_features, indices) + + self._update_view(indices, new_features["data"]) + + def _setup_graphic(self, new_features: dict[str, Any], indices: dict[str, Any]): + if issubclass(self._graphic_type, ImageGraphic): + data_slice = new_features["data"] + # `d` dim must only have xy data to be interpreted as a heatmap, xyz can't become a timeseries heatmap + if self.processor.shape[self.processor.spatial_dims[-1]] != 2: + raise ValueError + + image_data, x0, x_scale = self._create_heatmap_data(data_slice) + self._graphic = self._graphic_type( + image_data, offset=(x0, 0, -1), scale=(x_scale, 1, 1) + ) + if self._cmap is not None: + self._graphic.cmap = self._cmap + self._nd_subplot.subplot.add_graphic(self._graphic) + else: + super()._setup_graphic(new_features, indices) + + self._update_view(indices, new_features["data"]) + + def _update_view(self, indices: dict[str, Any], data_slice: np.ndarray): + """update the camera x-range and linear selector to the current datapoints position.""" + + p_dim = self.processor.spatial_dims[1] + + if self.x_range_mode is not None: + # set x_range directly from the display_window, NOT from the data_slice x-range, + # this way it doesn't fight with the _update_from_view_range() polling + hw = self.processor.display_window / 2 + center = indices[p_dim] + self._nd_subplot.subplot.x_range = center - hw, center + hw + # store new x_range so the auto-polling does not trigger + # an x_range update and yet another view update resulting in jitter + self._last_x_range = self._nd_subplot.subplot.x_range + + if self._linear_selector is not None: + # x range of the data + xr_data = data_slice[0, 0, 0], data_slice[0, -1, 0] + with pause_events( + self._linear_selector + ): # we don't want the linear selector change to update the indices + self._linear_selector.limits = xr_data + # linear selector acts on `p` dim + self._linear_selector.selection = indices[p_dim] + + def _linear_selector_handler(self, ev): + with block_indices_ctx(*self._nd_subplot.nd_graphics): + # block index change in all NDGraphics that are not in the same subplot + self._ref_index.set_dim_index( + self.processor.spatial_dims[1], ev.info["value"] + ) + + def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]: + """return [n_rows, n_cols] shape data from [n_timeseries, n_timepoints, xy] data""" + # assumes x vals in every row is the same, otherwise a heatmap representation makes no sense + # data slice is of shape [n_timeseries, n_timepoints, xy], where xy is x-y coordinates of each timeseries + x = data_slice[0, :, 0] # get x from just the first row + + # check if we need to interpolate + norm = np.linalg.norm(np.diff(np.diff(x))) / x.size + + if norm > 1e-6: + # x is not uniform upto float32 precision, must interpolate + x_uniform = np.linspace(x[0], x[-1], num=x.size) + y_interp = np.empty(shape=data_slice[..., 1].shape, dtype=np.float32) + + # this for loop is actually slightly faster than numpy.apply_along_axis() + for i in range(data_slice.shape[0]): + y_interp[i] = np.interp(x_uniform, x, data_slice[i, :, 1]) + + else: + # x is sufficiently uniform + y_interp = data_slice[..., 1] + + x0 = data_slice[0, 0, 0] + + # assume all x values are the same across all lines + # otherwise a heatmap representation makes no sense anyways + x_stop = x[-1] + x_scale = (x_stop - x0) / data_slice.shape[1] + + return y_interp, x0, x_scale + + @property + def display_window(self) -> int | float | None: + """display window in the reference units for the n_datapoints dim""" + return self.processor.display_window + + @display_window.setter + def display_window(self, dw: int | float | None): + self.processor.display_window = dw + if dw is None: + self.x_range_mode = None + + # force re-render + run_sync(self._set_indices_()) + + @property + def x_range_mode(self) -> Literal["fixed", "auto"] | None: + """x-range using a fixed window from the display window, or by polling the camera (auto)""" + return self._x_range_mode + + @x_range_mode.setter + def x_range_mode(self, mode: Literal[None, "fixed", "auto"]): + if mode not in (None, "fixed", "auto"): + raise ValueError( + f"x_range_mode must be None, 'fixed', or 'auto', got: {mode!r}" + ) + if mode == self._x_range_mode: + return + + if self._x_range_mode == "auto": + # old mode was auto + self._nd_subplot.subplot.remove_animation(self._update_from_view_range) + self._last_x_range = None + + if mode == "auto": + # seed so the first tick does not fire spuriously + self._last_x_range = self._nd_subplot.subplot.x_range + self._nd_subplot.subplot.add_animations(self._update_from_view_range) + + self._x_range_mode = mode + + def _update_from_view_range(self): + # update from current x_range if it has changed + if self._graphic is None: + return + + xr = self._nd_subplot.subplot.x_range + if xr == self._last_x_range: + # x_range hasn't changed + return + + self._last_x_range = xr + + new_width = abs(xr[1] - xr[0]) + # make sure width is sufficient for >= 3 datapoints + if new_width < self._min_display_window: + new_width = self._min_display_window + + new_index = (xr[0] + xr[1]) / 2 + + self.processor.display_window = new_width + + # block scheduling an additional async _set_indices_ for ndgraphics in this subplot + with block_indices_ctx(*self._nd_subplot.nd_graphics): + p_dim = self.processor.spatial_dims[1] + self._ref_index.set_dim_index(p_dim, new_index) + + # run this ndgraphic update immediately so graphic data and linear selector are in sync with the + # camera, otherwise you get laggy movement + run_sync(self._set_indices_()) diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py new file mode 100644 index 000000000..fc15277a0 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_pandas.py @@ -0,0 +1,106 @@ +from typing import Any + +import numpy as np +import pandas as pd + +from ._nd_positions import NDPositionsProcessor + + +class NDPP_Pandas(NDPositionsProcessor): + def __init__( + self, + data: pd.DataFrame, + spatial_dims: tuple[str, str, str], # [l, p, d] dims in order + columns: list[tuple[str, str] | tuple[str, str, str]], + tooltip_columns: list[str] = None, + **kwargs, + ): + self._columns = columns + + if tooltip_columns is not None: + if len(tooltip_columns) != len(self.columns): + raise ValueError + self._tooltip_columns = tooltip_columns + self._tooltip = True + else: + self._tooltip_columns = None + self._tooltip = False + + super().__init__( + data=data, + dims=spatial_dims, + spatial_dims=spatial_dims, + **kwargs, + ) + + self._dw_slice = None + + @property + def data(self) -> pd.DataFrame: + return self._data + + @data.setter + def data(self, data: pd.DataFrame): + if not isinstance(data, pd.DataFrame): + raise TypeError + + self._data = data + + @property + def columns(self) -> list[tuple[str, str] | tuple[str, str, str]]: + return self._columns + + @property + def dims(self) -> tuple[str, str, str]: + return self._dims + + @property + def shape(self) -> dict[str, int]: + # n_graphical_elements, n_timepoints, 2 + return {self.dims[0]: len(self.columns), self.dims[1]: self.data.index.size, self.dims[2]: 2} + + @property + def ndim(self) -> int: + return len(self.shape) + + @property + def tooltip(self) -> bool: + return self._tooltip + + def tooltip_format(self, n: int, p: int): + # datapoint index w.r.t. full data + p += self._dw_slice.start + return str(self.data[self._tooltip_columns[n]][p]) + + async def get(self, indices: dict[str, Any]) -> dict[str, np.ndarray]: + # TODO: LOD by using a step size according to max_p + # TODO: Also what to do if display_window is None and data + # hasn't changed when indices keeps getting set, cache? + + # assume no additional slider dims + self._dw_slice = self._get_dw_slice(indices) + + column_stacks = [ + np.column_stack( + [self.data[c][self._dw_slice] for c in col] + ) for col in self.columns + ] + if len(column_stacks) > 0: + n_samples = column_stacks[0].shape[0] + else: + n_samples = 0 + + gdata_shape = len(self.columns), n_samples, 3 + + graphic_data = np.zeros(shape=gdata_shape, dtype=np.float32) + + for i, (col, column_stack) in enumerate(zip(self.columns, column_stacks)): + graphic_data[i, :, :len(col)] = column_stack + + data = self._finalize(graphic_data) + other = self._get_other_features(data, self._dw_slice) + + return { + "data": data, + **other, + } diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py new file mode 100644 index 000000000..fb3bb7015 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_positions/_zarr.py @@ -0,0 +1,4 @@ +# placeholder + +class NDPP_Zarr: + pass diff --git a/fastplotlib/widgets/nd_widget/_nd_positions/utils.py b/fastplotlib/widgets/nd_widget/_nd_positions/utils.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/widgets/nd_widget/_nd_vectors.py b/fastplotlib/widgets/nd_widget/_nd_vectors.py new file mode 100644 index 000000000..1a4d1b8e5 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_nd_vectors.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from collections.abc import Sequence, Callable +from typing import Any, TYPE_CHECKING + +from numpy.typing import ArrayLike + +from ...utils import ( + ARRAY_LIKE_ATTRS, + ArrayProtocol, + CudaArrayProtocol, + cuda_to_numpy, +) +from ...graphics import VectorsGraphic +from ._base import ( + NDProcessor, + NDGraphic, + WindowFuncCallable, +) +from ._index import ReferenceIndex +from ._async import run_in_thread_pool, run_sync + +if TYPE_CHECKING: + from ._ndw_subplot import NDWSubplot + + +class NDVectorsProcessor(NDProcessor): + def __init__( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], # must be in order, last dim must be 4 + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayLike], ArrayLike] = None, + slider_dim_transforms=None, + ): + """ + ``NDProcessor`` subclass for n-dimensional vector data + + Produces (num_vectors, 2, [2 or 3]) slices for a ``VectorsGraphic``. The last two dimensions describe the + position/direction and the 2D/3D spatial coordinate, respectively. + + Parameters + ---------- + data: ArrayProtocol + Shape [..., num_vectors, 2, 2] or [..., num_vectors, 2, 3]. data[..., 0, :] gives the positions, data[..., 1, :] gives directions + + dims: Sequence[str] + names for each dimension in ``data``. Dimensions not listed in + ``spatial_dims`` are treated as slider dimensions and **must** appear as + keys in the parent ``NDWidget``'s ``ref_ranges`` + Examples:: + + A custom subclass's ``data`` object doesn't necessarily need to have these dims, but the ``get()`` method + must operate as if these dimensions exist and return an array that matches the spatial dimensions. + + + dims in the array do not need to be in the order that you want to display them, for example you can have a + weird array where the dims are interpreted as: + ``("col", "depth", "row", "time")``, and then specify spatial_dims as ``("row", "col")``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + The dim names that indicate [n_vectors, positions & directions, xy(z)], **in that order** + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + See Also + -------- + NDProcessor : Base class with full parameter documentation. + NDVectors : The ``NDGraphic`` that uses this processor by default. + """ + + super().__init__( + data=data, + dims=dims, + spatial_dims=spatial_dims, + slider_dim_transforms=slider_dim_transforms, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + ) + + @property + def data(self) -> ArrayProtocol | None: + """ + get or set managed data. If setting with new data, the new data is interpreted + to have the same dims (i.e. same dim names and ordering of dims). + """ + return self._data + + @data.setter + def data(self, data: ArrayProtocol): + if not isinstance(data, ArrayProtocol): + # check that it's generally array-like + raise TypeError( + f"`data` arrays must have all of the following attributes to be sufficiently array-like:\n" + f"{ARRAY_LIKE_ATTRS}, or they must be `None`" + ) + + if data.ndim < 3: + raise ValueError( + f"Shape must be (..., num_vecs, 2, [2 or 3]) you passed an array of shape {data.shape}" + ) + + self._data = data + + @property + def spatial_dims(self) -> tuple[str, str]: + """ + Spatial dims, **in order** + Dimensions in order are num_vectors, position/direction, xy[z], so the shape is [num_vectors, 2, 2 or 3] + """ + return self._spatial_dims + + @spatial_dims.setter + def spatial_dims(self, sdims: tuple[str, str, str]): + for dim in sdims: + if dim not in self.dims: + raise KeyError + + if len(sdims) != 3: + raise ValueError( + f"There must be exactly 3 spatial dims for vectors indicating [num_vectors, 2, 2] or [num_vectors, 2, 3] " + ) + + self._spatial_dims = tuple(sdims) + + if self.shape[self.spatial_dims[-2]] != 2 or self.shape[ + self.spatial_dims[-1] + ] not in (2, 3): + raise ValueError( + f"Spatial dimensions must haves shape (num_vecs, 2, [2 or 3]) you passed {sdims}" + ) + + async def get(self, indices: dict[str, Any]) -> ArrayProtocol: + """ + Get the data at the given index, process data through the window functions. + + Note that we do not use __getitem__ here since the index is a tuple specifying a single integer + index for each dimension. Slices are not allowed, therefore __getitem__ is not suitable here. + + Parameters + ---------- + indices: tuple[int, ...] + Get the processed data at this index. Must provide a value for each dimension. + Example: get((100, 5)) + + """ + # this will be squeezed output, with dims in the order of self.dims + window_output = await self.get_window_output(indices) + + # apply spatial_func; CUDA arrays run inline, numpy goes through the thread pool + if self.spatial_func is not None: + if isinstance(window_output, CudaArrayProtocol): + window_output = self._spatial_func(window_output) + else: + window_output = await run_in_thread_pool( + self._executor, self._spatial_func, window_output + ) + if window_output.ndim != len(self.spatial_dims): + raise ValueError + + # final CUDA -> numpy conversion at the end of the pipeline + if isinstance(window_output, CudaArrayProtocol): + window_output = await run_in_thread_pool(self._executor, cuda_to_numpy, window_output) + + return window_output.transpose(*self.spatial_dims_indices) + + +class NDVectors(NDGraphic): + def __init__( + self, + ref_index: ReferenceIndex, + nd_subplot: NDWSubplot, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[ + str, str, str + ], # must be in order! + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms=None, + name: str = None, + graphic_kwargs: dict = None, + ): + """ + ``NDGraphic`` subclass for n-dimensional vector rendering + + Wraps an :class:`VectorGraphic` + + Every dimension that is *not* listed in ``spatial_dims`` becomes a slider + dimension. Each slider dim must have a ``ReferenceRange`` defined in the + ``ReferenceIndex`` of the parent ``NDWidget``. The widget uses this to direct + a change in the ``ReferenceIndex`` and update the graphics. + + Parameters + ---------- + ref_index : ReferenceIndex + The shared reference index that delivers slider updates to this graphic. + + nd_subplot : NDWSubplot + parent ndsubplot the NDGraphic is in + + data : array-like or None + Shape [num_vectors, 2, 2] or [num_vectors, 3, 2]. data[:, :, 0] gives the positions, data[:, :, 1] gives directions + n-dimension image data array + + dims : sequence of hashable + Name for every dimension of ``data``, in order. Non-spatial dims must + match keys in ``ref_index``. + + ex: ``("time", "depth", "row", "col")`` — ``"time"`` and ``"depth"`` must + be present in ``ref_index``. + + spatial_dims : tuple[str, str] | tuple[str, str, str] + Spatial dimensions **in order**: These dims are either [n_vectors, 2, 2] or [n_vectors, 2, 3], indicating [n_vectors, positions & directions, xy(z)] + + window_funcs : dict, optional + See :class:`NDProcessor`. + + window_order : tuple, optional + See :class:`NDProcessor`. + + spatial_func : callable, optional + See :class:`NDProcessor`. + + slider_dim_transforms : dict, optional + See :class:`NDProcessor`. + + name : str, optional + Name for the underlying graphic. + + See Also + -------- + NDImageProcessor : The processor that backs this graphic. + + """ + + if not (set(dims) - set(spatial_dims)).issubset(ref_index.dims): + raise IndexError( + f"all specified `dims` must either be a spatial dim or a slider dim " + f"specified in the NDWidget ref_ranges, provided dims: {dims}, " + f"spatial_dims: {spatial_dims}. Specified NDWidget ref_ranges: {ref_index.dims}" + ) + + super().__init__(nd_subplot, name) + + self._ref_index = ref_index + + self._processor = NDVectorsProcessor( + data, + dims=dims, + spatial_dims=spatial_dims, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + ) + + self._graphic: VectorsGraphic | None = None + + if graphic_kwargs is None: + self._graphic_kwargs = dict() + else: + self._graphic_kwargs = graphic_kwargs + + # create a graphic + run_sync(self._create_graphic()) + + @property + def processor(self) -> NDVectorsProcessor: + """NDProcessor that manages the data and produces data slices to display""" + return self._processor + + @property + def graphic( + self, + ) -> VectorsGraphic: + """Underlying Graphic object used to display the current data slice""" + return self._graphic + + async def _create_graphic(self): + # Creates an ``ImageGraphic`` or ``ImageVolumeGraphic`` based on the number of spatial dims, + # adds it to the subplot, and resets the camera and histogram. + + if self.processor.data is None: + # no graphic if data is None, useful for initializing in null states when we want to set data later + return + + # get the data slice for this index + # this will only have the dims specified by ``spatial_dims`` + data_slice = await self.processor.get(self.indices) + + old_graphic = self._graphic + # check if we are replacing a graphic + if old_graphic is not None: + # delete the old graphic + self._nd_subplot.subplot.delete_graphic(old_graphic) + + # create the new graphic + self._graphic = VectorsGraphic( + positions=data_slice[:, 0], + directions=data_slice[:, 1], + **self._graphic_kwargs + ) + + self._nd_subplot.subplot.add_graphic(self._graphic) + + @property + def spatial_dims(self) -> tuple[str, str, str]: + """ + get or set the spatial dims **in order**. + Spatial dim shape here is [num_vectors, position/dimension (2), xy[z] (2 or 3)] + """ + return self.processor.spatial_dims + + @spatial_dims.setter + def spatial_dims(self, dims: tuple[str, str, str]): + self.processor.spatial_dims = dims + + # shape has probably changed, recreate graphic + run_sync(self._create_graphic()) + + @property + def indices(self) -> dict[str, Any]: + """get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually""" + return {d: self._ref_index[d] for d in self.processor.slider_dims} + + async def _set_indices_(self, indices: dict[str, Any] = None): + if indices is None: + # use latest indices if None, else use passed indices from schedule time + indices = self.indices + + data_slice = await self.processor.get(indices) + self.graphic.positions = data_slice[:, 0] + self.graphic.directions = data_slice[:, 1] + + self._last_indices = indices + + @property + def spatial_func(self) -> Callable[[ArrayProtocol], ArrayProtocol] | None: + """get or set the spatial_func, see docstring for details""" + # this is here even though it's the same in the base class since we can't create the image specific setter + # without also defining the property in this subclass. + return self.processor.spatial_func + + @spatial_func.setter + def spatial_func( + self, func: Callable[[ArrayProtocol], ArrayProtocol] + ) -> Callable | None: + self.processor.spatial_func = func diff --git a/fastplotlib/widgets/nd_widget/_ndw_subplot.py b/fastplotlib/widgets/nd_widget/_ndw_subplot.py new file mode 100644 index 000000000..da3473698 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_ndw_subplot.py @@ -0,0 +1,252 @@ +import warnings +from collections.abc import Callable +from typing import Literal, Sequence, Hashable + +import numpy as np + +from ... import ( + ScatterCollection, + ScatterStack, + LineCollection, + LineStack, + ImageGraphic, +) +from ...layouts import Subplot +from ...utils import ArrayProtocol, enums +from . import NDImageProcessor, NDImage, NDPositions, NDTimeseries, NDVectors +from ._index import AutoRangeContinuous +from ._video import VideoProcessor +from ._base import NDGraphic, WindowFuncCallable + + +class NDWSubplot: + """ + Entry point for adding ``NDGraphic`` objects to a subplot of an ``NDWidget``. + + Accessed via ``ndw[row, col]`` or ``ndw["subplot_name"]``. + Each ``add_nd_<...>`` method constructs the appropriate ``NDGraphic``, registers it with the parent + ``ReferenceIndex``, appends it to this subplot and returns the ``NDGraphic`` instance to the user. + + Note: ``NDWSubplot`` is not meant to be constructed directly, it only exists as part of an ``NDWidget`` + """ + + def __init__(self, ndw, subplot: Subplot): + self.ndw = ndw + self._subplot = subplot + + self._nd_graphics = list() + + @property + def subplot(self) -> Subplot: + return self._subplot + + @property + def nd_graphics(self) -> tuple[NDGraphic]: + """all the NDGraphic instance in this subplot""" + return tuple(self._nd_graphics) + + def __getitem__(self, key): + # get a specific NDGraphic by index or name + if isinstance(key, (int, np.integer)): + return self.nd_graphics[key] + + for g in self.nd_graphics: + if g.name == key: + return g + + else: + raise KeyError(f"NDGraphc with given key not found: {key}") + + def _check_slider_dims( + self, + dims: Sequence[Hashable], + spatial_dims: Sequence[Hashable], + data: ArrayProtocol | None, + positions: bool = False, + ): + """ + Make sure every slider (non-spatial) dim of a graphic being added has a + reference range. A dim without one gets an ``AutoRangeContinuous`` sized to + the data, an existing ``AutoRangeContinuous`` is grown to fit, and an + explicit range is left untouched. + """ + if data is None: + # size is unknown, an explicit range is still required + return + + dims = tuple(dims) + slider_dims = set(dims) - set(spatial_dims) + if positions: + # the datapoints `p` axis is a spatial dim that also needs a reference range + slider_dims.add(spatial_dims[1]) + + for dim in slider_dims: + size = data.shape[dims.index(dim)] + + if dim not in self.ndw.indices.dims: + warnings.warn( + f"No reference range specified for non-spatial dim '{dim}', " + f"auto-generating an `AutoRangeContinuous(0, {size}, 1)`." + ) + self.ndw.indices.push_dims({dim: AutoRangeContinuous(0, size, 1)}) + + elif isinstance(self.ndw.indices.ref_ranges[dim], AutoRangeContinuous): + # grow the existing auto range to fit this array + self.ndw.indices.ref_ranges[dim].stop = max( + self.ndw.indices.ref_ranges[dim].stop, size + ) + + def add_nd_image( + self, + data: ArrayProtocol | None, + dims: Sequence[Hashable], + spatial_dims: ( + tuple[str, str] | tuple[str, str, str] + ), # must be in order! [rows, cols] | [z, rows, cols] + rgb_dim: str | None = None, + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + compute_histogram: bool = True, + slider_dim_transforms=None, + name: str = None, + **kwargs, + ): + self._check_slider_dims(dims, spatial_dims, data) + + nd = NDImage( + self.ndw.indices, + nd_subplot=self, + data=data, + dims=dims, + spatial_dims=spatial_dims, + rgb_dim=rgb_dim, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + compute_histogram=compute_histogram, + slider_dim_transforms=slider_dim_transforms, + name=name, + **kwargs, + ) + + self._nd_graphics.append(nd) + return nd + + def add_video( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str] | tuple[str, str, str], + rgb_dim: str | None = None, + colorspace: enums.ColorspacesYUV | enums.ColorspacesRGB = "yuv420p", + colorrange: enums.ColorRange = "limited", + processor_type: NDImageProcessor = VideoProcessor, + **kwargs, + ): + return self.add_nd_image( + data=data, + dims=dims, + spatial_dims=spatial_dims, + rgb_dim=rgb_dim, + colorspace=colorspace, + colorrange=colorrange, + processor_type=processor_type, + **kwargs, + ) + + def add_nd_vectors( + self, + data: ArrayProtocol | None, + dims: Sequence[str], + spatial_dims: tuple[str, str, str], + window_funcs: tuple[WindowFuncCallable | None, ...] | WindowFuncCallable = None, + window_order: tuple[int, ...] = None, + spatial_func: Callable[[ArrayProtocol], ArrayProtocol] = None, + slider_dim_transforms=None, + name: str = None, + **kwargs + ) -> NDVectors: + self._check_slider_dims(dims, spatial_dims, data) + + nd = NDVectors( + self.ndw.indices, + nd_subplot=self, + data=data, + dims=dims, + spatial_dims=spatial_dims, + window_funcs=window_funcs, + window_order=window_order, + spatial_func=spatial_func, + slider_dim_transforms=slider_dim_transforms, + name=name, + **kwargs + ) + + self._nd_graphics.append(nd) + return nd + + def add_nd_scatter(self, data, dims, spatial_dims, *args, **kwargs): + # TODO: better func signature here, send all kwargs to processor_kwargs + self._check_slider_dims(dims, spatial_dims, data, positions=True) + + nd = NDPositions( + self.ndw.indices, + self, + data, + dims, + spatial_dims, + *args, + graphic_type=ScatterCollection, + **kwargs, + ) + + self._nd_graphics.append(nd) + return nd + + def add_nd_timeseries( + self, + data, + dims, + spatial_dims, + *args, + graphic_type: type[ + LineCollection | LineStack | ScatterStack | ImageGraphic + ] = LineStack, + x_range_mode: Literal["fixed", "auto"] | None = "auto", + **kwargs, + ): + self._check_slider_dims(dims, spatial_dims, data, positions=True) + + nd = NDTimeseries( + self.ndw.indices, + self, + data, + dims, + spatial_dims, + *args, + graphic_type=graphic_type, + linear_selector=True, + x_range_mode=x_range_mode, + **kwargs, + ) + + self._nd_graphics.append(nd) + return nd + + def add_nd_lines(self, data, dims, spatial_dims, *args, **kwargs): + self._check_slider_dims(dims, spatial_dims, data, positions=True) + + nd = NDPositions( + self.ndw.indices, + self, + data, + dims, + spatial_dims, + *args, + graphic_type=LineCollection, + **kwargs, + ) + + self._nd_graphics.append(nd) + return nd diff --git a/fastplotlib/widgets/nd_widget/_ndwidget.py b/fastplotlib/widgets/nd_widget/_ndwidget.py new file mode 100644 index 000000000..c5b6f58c0 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_ndwidget.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any, Optional + +from ._index import RangeContinuous, RangeDiscrete, ReferenceIndex +from ._ndw_subplot import NDWSubplot +from ._ui import NDWidgetUI, RightClickMenu +from ...layouts import ImguiFigure, Subplot + + +class NDWidget: + def __init__(self, ref_ranges: dict[str, tuple] = None, ref_index: Optional[ReferenceIndex] = None, **kwargs): + if ref_index is None: + if ref_ranges is None: + ref_ranges = dict() + self._indices = ReferenceIndex(ref_ranges) + else: + self._indices = ref_index + + self._indices._add_ndwidget_(self) + + self._figure = ImguiFigure(**kwargs) + self._figure.set_imgui_right_click(RightClickMenu(self)) + + self._subplots_nd: dict[Subplot, NDWSubplot] = dict() + for subplot in self.figure: + self._subplots_nd[subplot] = NDWSubplot(self, subplot) + + # hard code the expected height so that the first render looks right in tests, docs etc. + ui_size = 57 + (50 * len(self.indices)) + + self._sliders_ui = NDWidgetUI(self) + self.figure.add_imgui_window( + self._sliders_ui, location="bottom", size=ui_size, title="NDWidget controls" + ) + + @property + def figure(self) -> ImguiFigure: + return self._figure + + @property + def indices(self) -> ReferenceIndex: + return self._indices + + @indices.setter + def indices(self, new_indices: dict[str, int | float | Any]): + self._indices.set(new_indices) + + @property + def ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: + return self._indices.ref_ranges + + @property + def ndgraphics(self): + gs = list() + for subplot in self._subplots_nd.values(): + gs.extend(subplot.nd_graphics) + + return tuple(gs) + + def __getitem__(self, key: str | tuple[int, int] | Subplot): + if not isinstance(key, Subplot): + key = self.figure[key] + return self._subplots_nd[key] + + def show(self, **kwargs): + return self.figure.show(**kwargs) + + def close(self): + self.figure.close() diff --git a/fastplotlib/widgets/nd_widget/_repr_formatter.py b/fastplotlib/widgets/nd_widget/_repr_formatter.py new file mode 100644 index 000000000..0569f1004 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_repr_formatter.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import html +from collections.abc import Callable +from typing import Any + + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" + +_C = { + "title": "\033[38;5;75m", # sky-blue + "spatial": "\033[38;5;114m", # sage-green + "slider": "\033[38;5;215m", # soft-orange + "label": "\033[38;5;246m", # mid-grey + "value": "\033[38;5;252m", # near-white + "section": "\033[38;5;68m", # steel-blue + "muted": "\033[38;5;240m", # dark-grey + "warn": "\033[38;5;222m", # amber +} + + +def _c(key: str, text: str) -> str: + return f"{_C[key]}{text}{_RESET}" + + +def _callable_name(f: Callable | None) -> str: + if f is None: + return "—" + module = getattr(f, "__module__", "") or "" + qname = getattr(f, "__qualname__", None) or getattr(f, "__name__", repr(f)) + if module and not module.startswith("__"): + short = module.split(".")[-1] + return f"{short}.{qname}" + return qname + + +def ndprocessor_fmt_txt(processor) -> str: + """ + Returns a colored, ascii box + """ + lines: list[str] = [] + + cls = type(processor).__name__ + lines.append(_c("title", _BOLD + cls) + _RESET) + lines.append(_c("muted", "─" * 72)) + + lines.append(_c("section", " Dimensions")) + + header = ( + f" {'dim':<14}{'size':>6} {'role':<10} {'window_func size':<26} index_mapping" + ) + lines.append(_c("label", header)) + lines.append(_c("muted", " " + "─" * 70)) + + for dim in processor.dims: + size = processor.shape[dim] + is_sp = dim in processor.spatial_dims + role_s = (_c("spatial", f"{'spatial':<10}") if is_sp + else _c("slider", f"{'slider':<10}")) + + # window_func - size column + if not is_sp: + wf, ws = processor.window_funcs.get(dim, (None, None)) + if wf is not None and ws is not None: + win_s = _c("value", f"{_callable_name(wf)}") + _c("muted", f" - {ws}") + else: + win_s = _c("muted", "—") + else: + win_s = "" + + # index_mapping column (slider dims only; skip identity) + if not is_sp: + imap = processor.index_mappings.get(dim) + iname = getattr(imap, "__name__", "") if imap is not None else "" + if iname != "identity" and imap is not None: + idx_s = _c("value", _callable_name(imap)) + else: + idx_s = _c("muted", "—") + else: + idx_s = "" + + # pad win_s to fixed visible width (strip ANSI for measuring) + import re + _ansi_re = re.compile(r"\033\[[^m]*m") + win_visible = len(_ansi_re.sub("", win_s)) + win_pad = win_s + " " * max(0, 26 - win_visible) + + line = ( + f" {_c('value', f'{str(dim):<14}')}" + f"{_c('label', f'{size:>6}')} " + f"{role_s} {win_pad} {idx_s}" + ) + lines.append(line) + + # window order + if processor.window_order: + lines.append("") + order_s = " → ".join(str(d) for d in processor.window_order) + lines.append(f" {_c('section', 'Window order')} {_c('value', order_s)}") + + # spatial func + if processor.spatial_func is not None: + lines.append("") + lines.append( + f" {_c('section', 'Spatial func')} " + f"{_c('value', _callable_name(processor.spatial_func))}" + ) + + lines.append(_c("muted", "─" * 72)) + return "\n".join(lines) + + +def ndgraphic_fmt_txt(ndg) -> str: + """Text repr for NDGraphic.""" + cls = type(ndg).__name__ + gcls = type(ndg.graphic).__name__ if ndg.graphic is not None else "—" + name = ndg.name or "—" + + header = ( + f"{_c('title', _BOLD + cls)}{_RESET} " + f"{_c('muted', '·')} " + f"{_c('section', 'graphic')} {_c('value', gcls)} " + f"{_c('muted', '·')} " + f"{_c('section', 'name')} {_c('value', name)}\n" + ) + + proc_block = ndprocessor_fmt_txt(ndg.processor) + # indent processor block + indented = "\n".join(" " + l for l in proc_block.splitlines()) + return header + indented + +_CSS = """ + +""" + + +def _h(s: Any) -> str: + """html-escape a stringified value""" + return html.escape(str(s)) + + +def _badge(role: str) -> str: + cls = "fpl-badge-spatial" if role == "spatial" else "fpl-badge-slider" + return f'{role}' + + +def _code(s: str) -> str: + return f"{_h(s)}" + + +def _section(title: str, content_html: str, count: str = "", open_: bool = True) -> str: + open_attr = " open" if open_ else "" + count_badge = ( + f'{_h(count)}' if count else "" + ) + return ( + f'
' + f'' + f'{_h(title)}' + f'{count_badge}' + f'' + f'{content_html}' + f'
' + ) + + +def _dim_rows_html(proc) -> str: + rows = [] + for dim in proc.dims: + size = proc.shape[dim] + is_sp = dim in proc.spatial_dims + badge = _badge("spatial" if is_sp else "slider") + + # window_func - size column + if not is_sp: + wf, ws = proc.window_funcs.get(dim, (None, None)) + if wf is not None and ws is not None: + win_td = ( + f'' + f'{_code(_callable_name(wf))}' + f'-' + f'{_code(str(ws))}' + f'' + ) + else: + win_td = '—' + else: + win_td = '' + + # index_mapping column (slider dims only; hide identity) + if not is_sp: + imap = proc.index_mappings.get(dim) + if imap is not None: + idx_td = f'{_code(_callable_name(imap))}' + else: + idx_td = '—' + else: + idx_td = '' + + rows.append( + f'' + f'{_h(str(dim))}' + f'{size:,}' + f'{badge}' + f'{win_td}' + f'{idx_td}' + f'' + ) + + # column header row + header = ( + f'' + f'dim' + f'size' + f'role' + f'window_func - size' + f'index_mapping' + f'' + ) + + table = ( + '' + '' + '' + '' + '' + + header + + "".join(rows) + + "
" + ) + return table + + +def _footer_kv(pairs: list[tuple[str, str]]) -> str: + """Always-visible key/value rows rendered below the dim table.""" + inner = "" + for k, v in pairs: + inner += ( + f'' + f'' + ) + return f'' + + +def _kv_list_html(pairs: list[tuple[str, str]]) -> str: + inner = "" + for k, v in pairs: + inner += ( + f'
{_h(k)}
' + f'
{v}
' + ) + return f'
{inner}
' + + +def _html_processor(proc) -> str: + cls = _h(type(proc).__name__) + + # header + ndim_pill = ( + f'' + f'{proc.ndim}D' + ) + header = ( + f'
' + f'{cls}' + f'{ndim_pill}' + f'
' + ) + + # dims section (always open) + dim_content = _dim_rows_html(proc) + sections = _section("Dimensions", dim_content, + count=str(proc.ndim), open_=True) + + # always-visible footer rows + footer_pairs: list[tuple[str, str]] = [] + + if proc.window_order: + chain = " → ".join( + f'{_h(str(d))}' + if i > 0 else _h(str(d)) + for i, d in enumerate(proc.window_order) + ) + footer_pairs.append(("window order", f'{chain}')) + + if proc.spatial_func is not None: + footer_pairs.append(("spatial func", _code(_callable_name(proc.spatial_func)))) + + if footer_pairs: + sections += _footer_kv(footer_pairs) + + body = f'
{sections}
' + return f'{_CSS}
{header}{body}
' + + +def ndgraphic_fmt_html(ndg) -> str: + cls = _h(type(ndg).__name__) + gcls = _h(type(ndg.graphic).__name__) if ndg.graphic is not None else "—" + name = _h(ndg.name or "—") + + graphic_pill = f'graphic: {gcls}' + name_pill = f'name: {name}' + + header = ( + f'
' + f'{cls}' + f'·' + f'{graphic_pill}{name_pill}' + f'
' + ) + + # embed processor repr (without its own outer box) inside a section + proc_inner = _dim_rows_html(ndg.processor) + sections = _section("Processor · Dimensions", proc_inner, open_=True) + + footer_pairs: list[tuple[str, str]] = [] + + if ndg.processor.window_order: + chain = " → ".join( + f'{_h(str(d))}' + if i > 0 else _h(str(d)) + for i, d in enumerate(ndg.processor.window_order) + ) + footer_pairs.append(("window order", f'{chain}')) + + if ndg.processor.spatial_func is not None: + footer_pairs.append(("spatial func", _code(_callable_name(ndg.processor.spatial_func)))) + + if footer_pairs: + sections += _footer_kv(footer_pairs) + + body = f'
{sections}
' + return f'{_CSS}
{header}{body}
' + +class ReprMixin: + """ + Mixin that provides: + • __repr__ → coloured ANSI text (terminal / plain REPL) + • _repr_html_ → rich HTML (Jupyter) + • _repr_mimebundle_ → both, so Jupyter picks the richest format + + Subclasses must implement _repr_text_() and _repr_html_() themselves OR + rely on the dispatch below which checks the concrete type. + """ + + def _repr_text_(self) -> str: + # lazy import avoids circular; swap for a direct call in your module + if _is_ndgraphic(self): + return ndgraphic_fmt_txt(self) + return ndprocessor_fmt_txt(self) + + def _repr_html_(self) -> str: + return ndgraphic_fmt_html(self) + return _html_processor(self) + + def __repr__(self) -> str: + return self._repr_text_() + + def _repr_mimebundle_(self, **kwargs) -> dict: + return { + "text/plain": self._repr_text_(), + "text/html": self._repr_html_(), + } + + +def _is_ndgraphic(obj) -> bool: + """duck-type check: does this object have a .graphic and .processor?""" + return hasattr(obj, "graphic") and hasattr(obj, "processor") \ No newline at end of file diff --git a/fastplotlib/widgets/nd_widget/_ui.py b/fastplotlib/widgets/nd_widget/_ui.py new file mode 100644 index 000000000..3a54d327a --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_ui.py @@ -0,0 +1,311 @@ +import os +from time import perf_counter + +import numpy as np +from imgui_bundle import imgui, imgui_ctx, icons_fontawesome_6 as fa + +from ...graphics import ( + ScatterCollection, + ScatterStack, + LineCollection, + LineStack, + ImageGraphic, + ImageVolumeGraphic, +) +from ...utils import quick_min_max +from ...layouts import Subplot +from ...ui import ImguiWindow, StandardRightClickMenu +from ._index import RangeContinuous +from ._base import NDGraphic +from ._nd_positions import NDPositions, NDTimeseries +from ._nd_image import NDImage + +position_graphic_types = [ScatterCollection, ScatterStack, LineCollection, LineStack] + + +class NDWidgetUI(ImguiWindow): + def __init__(self, ndwidget): + super().__init__() + self._ndwidget = ndwidget + + # whether or not a dimension is in play mode + self._playing = dict() + + # approximate framerate for playing + self._fps = dict() + + # framerate converted to frame time + self._frame_time = dict() + + # last timepoint that a frame was displayed from a given dimension + self._last_frame_time = dict() + + # loop playback + self._loop = dict() + + # last time the slider was moved per dim, used for time-based throttling + self._last_slider_movement: dict[str, float] = dict() + + for dim in self._ndwidget.ranges: + self.push_dim(dim) + + # auto-plays the ImageWidget's left-most dimension in docs galleries + if "DOCS_BUILD" in os.environ.keys(): + if os.environ["DOCS_BUILD"] == "1": + self._playing[0] = True + self._loop = True + + self._max_display_windows: dict[NDGraphic, float | int] = dict() + + def push_dim(self, dim): + """initialize the playback & slider UI state for a newly added dim""" + self._playing[dim] = False + self._fps[dim] = 20 + self._frame_time[dim] = 1 / 20 + self._last_frame_time[dim] = perf_counter() + self._loop[dim] = False + self._last_slider_movement[dim] = 0.0 + + def pop_dim(self, dim): + """remove the playback & slider UI state for a removed dim""" + self._playing.pop(dim) + self._fps.pop(dim) + self._frame_time.pop(dim) + self._last_frame_time.pop(dim) + self._loop.pop(dim) + self._last_slider_movement.pop(dim) + + def _set_index(self, dim, index): + if index >= self._ndwidget.ranges[dim].stop: + if self._loop[dim]: + index = self._ndwidget.ranges[dim].start + else: + index = self._ndwidget.ranges[dim].stop + self._playing[dim] = False + + self._ndwidget.indices.set_dim_index(dim, index) + + def update(self): + now = perf_counter() + + for dim, current_index in self._ndwidget.indices: + # push id since we have the same buttons for each dim + imgui.push_id(f"{self._id_counter}_{dim}") + + rr = self._ndwidget.ranges[dim] + + if self._playing[dim]: + # show pause button if playing + if imgui.button(label=fa.ICON_FA_PAUSE): + # if pause button clicked, then set playing to false + self._playing[dim] = False + + # if in play mode and enough time has elapsed w.r.t. the desired framerate, increment the index + if now - self._last_frame_time[dim] >= self._frame_time[dim]: + self._set_index(dim, current_index + rr.step) + self._last_frame_time[dim] = now + + else: + # we are not playing, so display play button + if imgui.button(label=fa.ICON_FA_PLAY): + # if play button is clicked, set last frame time to 0 so that index increments on next render + self._last_frame_time[dim] = 0 + # set playing to True since play button was clicked + self._playing[dim] = True + + imgui.same_line() + # step back one frame button + if imgui.button(label=fa.ICON_FA_BACKWARD_STEP) and not self._playing[dim]: + self._set_index(dim, current_index - rr.step) + + imgui.same_line() + # step forward one frame button + if imgui.button(label=fa.ICON_FA_FORWARD_STEP) and not self._playing[dim]: + self._set_index(dim, current_index + rr.step) + + imgui.same_line() + # stop button + if imgui.button(label=fa.ICON_FA_STOP): + self._playing[dim] = False + self._last_frame_time[dim] = 0 + self._ndwidget.indices.set_dim_index(dim, rr.start) + + imgui.same_line() + # loop checkbox + _, self._loop[dim] = imgui.checkbox( + label=fa.ICON_FA_ROTATE, v=self._loop[dim] + ) + if imgui.is_item_hovered(0): + imgui.set_tooltip("loop playback") + + imgui.same_line() + imgui.text("framerate :") + imgui.same_line() + imgui.set_next_item_width(100) + # framerate int entry + fps_changed, value = imgui.input_int( + label="fps", v=self._fps[dim], step_fast=5 + ) + if imgui.is_item_hovered(0): + imgui.set_tooltip( + "framerate is approximate and less reliable as it approaches your monitor refresh rate" + ) + if fps_changed: + if value < 1: + value = 1 + if value > 100: + value = 100 + self._fps[dim] = value + self._frame_time[dim] = 1 / value + + imgui.text(str(dim)) + imgui.same_line() + # so that slider occupies full width + imgui.set_next_item_width(self.width * 0.85) + + if isinstance(rr, RangeContinuous): + changed, new_index = imgui.slider_float( + v=current_index, + v_min=rr.start, + v_max=rr.stop - rr.step, + label=f"##{dim}", + ) + + if changed: + if now - self._last_slider_movement[dim] > rr.throttle: + self._ndwidget.indices.set_dim_index(dim, new_index, cancel_awaiting=True) + self._last_slider_movement[dim] = now + + elif imgui.is_item_hovered(): + if imgui.is_key_pressed(imgui.Key.right_arrow): + self._set_index(dim, current_index + rr.step) + + elif imgui.is_key_pressed(imgui.Key.left_arrow): + self._set_index(dim, current_index - rr.step) + + imgui.pop_id() + + # auto set imgui window height + if not self._collapsed: + height = round( + imgui.get_cursor_screen_pos().y - self.y + imgui.get_style().window_padding.y + ) + if height != self.size: + self.size = height + + +class RightClickMenu(StandardRightClickMenu): + def __init__(self, ndwidget): + super().__init__() + + self._ndwidget = ndwidget + self._ndgraphic_windows = set() + + def update(self): + super().update() + + if imgui.begin_menu("ND Graphics"): + for ndg in self._ndwidget[self.subplot].nd_graphics: + name = ndg.name if ndg.name is not None else hex(id(ndg)) + if imgui.menu_item( + f"{name}", "", False + )[0]: + self._ndgraphic_windows.add(ndg) + + imgui.end_menu() + + def draw(self): + super().draw() + + # the ND graphic windows are not part of the popup, they stay open after the popup closes + for ndg in list(self._ndgraphic_windows): # set -> list so we can change size during iteration + name = ndg.name if ndg.name is not None else hex(id(ndg)) + subplot = ndg.graphic._plot_area + imgui.set_next_window_size((0, 0)) + _, open = imgui.begin(f"subplot: {subplot.name}, {name}", True) + + if isinstance(ndg, NDPositions): + self._draw_nd_pos_ui(subplot, ndg) + + elif isinstance(ndg, NDImage): + self._draw_nd_image_ui(subplot, ndg) + + _, ndg.pause = imgui.checkbox("pause", ndg.pause) + + if not open: + self._ndgraphic_windows.remove(ndg) + + imgui.end() + + def _draw_nd_image_ui(self, subplot, nd_image: NDImage): + _min, _max = quick_min_max(nd_image.graphic.data.value) + changed, vmin = imgui.slider_float( + "vmin", nd_image.graphic.vmin, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmin = vmin + + changed, vmax = imgui.slider_float( + "vmax", nd_image.graphic.vmax, v_min=_min, v_max=_max + ) + if changed: + nd_image.graphic.vmax = vmax + + changed, new_gamma = imgui.slider_float( + "gamma", nd_image.graphic._material.gamma, 0.01, 5 + ) + if changed: + nd_image.graphic._material.gamma = new_gamma + + def _draw_nd_pos_ui(self, subplot: Subplot, nd_graphic: NDPositions): + graphic_types = position_graphic_types + if isinstance(nd_graphic, NDTimeseries): + # heatmap only makes sense for timeseries data + graphic_types = position_graphic_types + [ImageGraphic] + for i, cls in enumerate(graphic_types): + if imgui.radio_button(cls.__name__, type(nd_graphic.graphic) is cls): + nd_graphic.graphic_type = cls + subplot.auto_scale() + + changed, val = imgui.checkbox( + "use display window", nd_graphic.display_window is not None + ) + + p_dim = nd_graphic.processor.spatial_dims[1] + + if changed: + if not val: + nd_graphic.display_window = None + else: + # pick a value 10% of the reference range + nd_graphic.display_window = self._ndwidget.ranges[p_dim].size * 0.1 + + if nd_graphic.display_window is not None: + if isinstance(nd_graphic.display_window, (int, np.integer)): + slider = imgui.slider_int + input_ = imgui.input_int + type_ = int + else: + slider = imgui.slider_float + input_ = imgui.input_float + type_ = float + + changed, new = slider( + "display window", + v=nd_graphic.display_window, + v_min=type_(0), + v_max=type_(self._ndwidget.ranges[p_dim].stop * 0.1), + ) + + if changed: + nd_graphic.display_window = new + + if isinstance(nd_graphic, NDTimeseries): + options = [None, "fixed", "auto"] + changed, option = imgui.combo( + "x-range mode", + options.index(nd_graphic.x_range_mode), + [str(o) for o in options], + ) + if changed: + nd_graphic.x_range_mode = options[option] diff --git a/fastplotlib/widgets/nd_widget/_video.py b/fastplotlib/widgets/nd_widget/_video.py new file mode 100644 index 000000000..23e8cd6e9 --- /dev/null +++ b/fastplotlib/widgets/nd_widget/_video.py @@ -0,0 +1,27 @@ +from ._nd_image import NDImageProcessor, NDImage +from typing import Callable, Any, Literal + +import numpy as np + + +class VideoProcessor(NDImageProcessor): + async def get_window_output(self, indices: dict[str, Any]): + """ + Applies any window functions and returns squeezed sliced array transposed in the order of the given spatial dims + + Parameters + ---------- + indices + + Returns + ------- + + """ + # windowed slice if user set any window funcs + windowed_slice = await self._get_raw_data_slice(indices) + + if isinstance(windowed_slice, (tuple, list)): + return tuple(a.squeeze() for a in windowed_slice) + + # convert to numpy array + return np.asarray(windowed_slice).squeeze() diff --git a/pyproject.toml b/pyproject.toml index 73dfd7ee3..9c914bd79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,8 @@ keywords = [ ] requires-python = ">= 3.10" dependencies = [ - "numpy>=1.23.0", - "pygfx==0.15.3", + "numpy>=2.1.0", + "pygfx==0.16.0", "wgpu", # Let pygfx constrain the wgpu version "cmap>=0.1.3", # (this comment keeps this list multiline in VSCode) @@ -46,6 +46,7 @@ notebook = [ "jupyter-rfb>=0.5.1", "ipywidgets>=8.0.0,<9", "sidecar", + "simplejpeg", ] tests = [ "pytest", diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 865eab27f..aba780ac8 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -32,10 +32,14 @@ def generate_add_graphics_methods(): f.write("# This is an auto-generated file and should not be modified directly\n\n") f.write("from typing import *\n\n") - f.write("import numpy\n\n") + f.write("import numpy\n") + f.write("from numpy.typing import NDArray\n\n") f.write("import pygfx\n\n") f.write("from ..graphics import *\n") - f.write("from ..graphics._base import Graphic\n\n") + f.write("from ..graphics._base import Graphic\n") + f.write("from ..utils import enums\n") + f.write("import typing\n") + f.write("import fastplotlib\n\n") f.write("\nclass GraphicMethodsMixin:\n") @@ -52,11 +56,14 @@ def generate_add_graphics_methods(): f.write(" self.add_graphic(graphic, center=center)\n\n") f.write(" return graphic\n\n") + # from https://stackoverflow.com/a/1176023 + camel_to_snake = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") + for m in modules: cls = m cls_name = cls.__name__.replace("Graphic", "") - # from https://stackoverflow.com/a/1176023 - method_name = re.sub(r"(?*"), size=orig_datapoints), + "uniform_marker": False, + "sizes": np.abs(ys), + "uniform_size": False, + # TODO: skipping edge_colors for now since that causes a WGPU bind group error that we will figure out later + # anyways I think changing buffer sizes in combination with per-vertex edge colors is a literal edge-case + "point_rotations": zs * 180, + "point_rotation_mode": "vertex", + } + else: + kwargs = dict() + + # add a line or scatter graphic + graphic = adder(data=data, colors=np.random.rand(orig_datapoints, 4), **kwargs) + + fig.show() + + # weakrefs to the original buffers + # these should raise a ReferenceError when the corresponding feature is replaced with data of a different shape + orig_data_buffer = weakref.proxy(graphic.data._fpl_buffer) + orig_colors_buffer = weakref.proxy(graphic.colors._fpl_buffer) + + buffers = [orig_data_buffer, orig_colors_buffer] + + # extra buffers for the scatters + if graphic_type == "scatter": + for attr in ["markers", "sizes", "point_rotations"]: + buffers.append(weakref.proxy(getattr(graphic, attr)._fpl_buffer)) + + # create some new data that requires a different buffer shape + xs = np.linspace(0, 15 * np.pi, new_buffer_size) + ys = np.sin(xs) + zs = np.cos(xs) + + new_data = np.column_stack([xs, ys, zs]) + + # set data that requires a larger buffer and check that old buffer is no longer referenced + graphic.data = new_data + graphic.colors = np.random.rand(new_buffer_size, 4) + + if graphic_type == "scatter": + # changes values so that new larger buffers must be allocated + graphic.markers = np.random.choice(list("osD+x^v<>*"), size=new_buffer_size) + graphic.sizes = np.abs(zs) + graphic.point_rotations = ys * 180 + + # make sure old original buffers are de-referenced + for i in range(len(buffers)): + with pytest.raises(ReferenceError) as fail: + buffers[i] + pytest.fail( + f"GC failed for buffer: {buffers[i]}, " + f"with referrers: {gc.get_referrers(buffers[i].__repr__.__self__)}" + ) + + +# test all combination of dims that require TextureArrays of shapes 1x1, 1x2, 1x3, 2x3, 3x3 etc. +@pytest.mark.parametrize( + "new_buffer_size", list(product(*[[(500, 1), (1200, 2), (2200, 3)]] * 2)) +) +def test_replace_image_buffer(new_buffer_size): + # make an image with some starting shape + orig_size = (1_500, 1_500) + + data = np.random.rand(*orig_size) + + fig = fpl.Figure() + image = fig[0, 0].add_image(data) + + # the original Texture buffers that represent the individual image tiles + orig_buffers = [ + weakref.proxy(image.data.buffer.ravel()[i]) + for i in range(image.data.buffer.size) + ] + orig_shape = image.data.buffer.shape + + fig.show() + + # dimensions for a new image + new_dims = [v[0] for v in new_buffer_size] + + # the number of tiles required in each dim/shape of the TextureArray + new_shape = tuple(v[1] for v in new_buffer_size) + + # make the new data and set the image + new_data = np.random.rand(*new_dims) + image.data = new_data + + # test that old Texture buffers are de-referenced + for i in range(len(orig_buffers)): + with pytest.raises(ReferenceError) as fail: + orig_buffers[i] + pytest.fail( + f"GC failed for buffer: {orig_buffers[i]}, of shape: {orig_shape}" + f"with referrers: {gc.get_referrers(orig_buffers[i].__repr__.__self__)}" + ) + + # check new texture array + check_texture_array( + data=new_data, + ta=image.data, + buffer_size=np.prod(new_shape), + buffer_shape=new_shape, + row_indices_size=new_shape[0], + col_indices_size=new_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (new_data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (new_data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), + ) + + # check that new image tiles are arranged correctly + check_image_graphic(image.data, image) diff --git a/tests/test_scatter_graphic.py b/tests/test_scatter_graphic.py index a61681f24..930d8c495 100644 --- a/tests/test_scatter_graphic.py +++ b/tests/test_scatter_graphic.py @@ -133,7 +133,7 @@ def test_edge_colors(edge_colors): npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH) assert ( - scatter.edge_colors.buffer is scatter.world_object.geometry.edge_colors + scatter.edge_colors._fpl_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 diff --git a/tests/test_texture_array.py b/tests/test_texture_array.py index 6220f2fe5..01abb9a97 100644 --- a/tests/test_texture_array.py +++ b/tests/test_texture_array.py @@ -2,14 +2,9 @@ from numpy import testing as npt import pytest -import pygfx - import fastplotlib as fpl from fastplotlib.graphics.features import TextureArray -from fastplotlib.graphics.image import _ImageTile - - -MAX_TEXTURE_SIZE = 1024 +from .utils_textures import MAX_TEXTURE_SIZE, check_texture_array, check_image_graphic def make_data(n_rows: int, n_cols: int) -> np.ndarray: @@ -25,50 +20,6 @@ def make_data(n_rows: int, n_cols: int) -> np.ndarray: return np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) -def check_texture_array( - data: np.ndarray, - ta: TextureArray, - buffer_size: int, - buffer_shape: tuple[int, int], - row_indices_size: int, - col_indices_size: int, - row_indices_values: np.ndarray, - col_indices_values: np.ndarray, -): - - npt.assert_almost_equal(ta.value, data) - - assert ta.buffer.size == buffer_size - assert ta.buffer.shape == buffer_shape - - assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) - - assert ta.row_indices.size == row_indices_size - assert ta.col_indices.size == col_indices_size - npt.assert_array_equal(ta.row_indices, row_indices_values) - npt.assert_array_equal(ta.col_indices, col_indices_values) - - # make sure chunking is correct - for texture, chunk_index, data_slice in ta: - assert ta.buffer[chunk_index] is texture - chunk_row, chunk_col = chunk_index - - data_row_start_index = chunk_row * MAX_TEXTURE_SIZE - data_col_start_index = chunk_col * MAX_TEXTURE_SIZE - - data_row_stop_index = min( - data.shape[0], data_row_start_index + MAX_TEXTURE_SIZE - ) - data_col_stop_index = min( - data.shape[1], data_col_start_index + MAX_TEXTURE_SIZE - ) - - row_slice = slice(data_row_start_index, data_row_stop_index) - col_slice = slice(data_col_start_index, data_col_stop_index) - - assert data_slice == (row_slice, col_slice) - - def check_set_slice(data, ta, row_slice, col_slice): ta[row_slice, col_slice] = 1 npt.assert_almost_equal(ta[row_slice, col_slice], 1) @@ -85,17 +36,6 @@ def make_image_graphic(data) -> fpl.ImageGraphic: return fig[0, 0].add_image(data) -def check_image_graphic(texture_array, graphic): - # make sure each ImageTile has the right texture - for (texture, chunk_index, data_slice), img in zip( - texture_array, graphic.world_object.children - ): - assert isinstance(img, _ImageTile) - assert img.geometry.grid is texture - assert img.world.x == data_slice[1].start - assert img.world.y == data_slice[0].start - - @pytest.mark.parametrize("test_graphic", [False, True]) def test_small_texture(test_graphic): # tests TextureArray with dims that requires only 1 texture @@ -162,15 +102,27 @@ def test_wide(test_graphic): else: ta = TextureArray(data) + ta_shape = (2, 3) + check_texture_array( data, ta=ta, - buffer_size=6, - buffer_shape=(2, 3), - row_indices_size=2, - col_indices_size=3, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: @@ -189,15 +141,27 @@ def test_tall(test_graphic): else: ta = TextureArray(data) + ta_shape = (3, 2) + check_texture_array( data, ta=ta, - buffer_size=6, - buffer_shape=(3, 2), - row_indices_size=3, - col_indices_size=2, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: @@ -216,15 +180,27 @@ def test_square(test_graphic): else: ta = TextureArray(data) + ta_shape = (3, 3) + check_texture_array( data, ta=ta, - buffer_size=9, - buffer_shape=(3, 3), - row_indices_size=3, - col_indices_size=3, - row_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), - col_indices_values=np.array([0, MAX_TEXTURE_SIZE, 2 * MAX_TEXTURE_SIZE]), + buffer_size=np.prod(ta_shape), + buffer_shape=ta_shape, + row_indices_size=ta_shape[0], + col_indices_size=ta_shape[1], + row_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[0] - 1) // MAX_TEXTURE_SIZE) + ] + ), + col_indices_values=np.array( + [ + i * MAX_TEXTURE_SIZE + for i in range(0, 1 + (data.shape[1] - 1) // MAX_TEXTURE_SIZE) + ] + ), ) if test_graphic: diff --git a/tests/utils_textures.py b/tests/utils_textures.py new file mode 100644 index 000000000..f40a7371c --- /dev/null +++ b/tests/utils_textures.py @@ -0,0 +1,64 @@ +import numpy as np +import pygfx +from numpy import testing as npt + +from fastplotlib.graphics.features import TextureArray +from fastplotlib.graphics.image import _ImageTile + + +MAX_TEXTURE_SIZE = 1024 + + +def check_texture_array( + data: np.ndarray, + ta: TextureArray, + buffer_size: int, + buffer_shape: tuple[int, int], + row_indices_size: int, + col_indices_size: int, + row_indices_values: np.ndarray, + col_indices_values: np.ndarray, +): + + npt.assert_almost_equal(ta.value, data) + + assert ta.buffer.size == buffer_size + assert ta.buffer.shape == buffer_shape + + assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) + + assert ta.row_indices.size == row_indices_size + assert ta.col_indices.size == col_indices_size + npt.assert_array_equal(ta.row_indices, row_indices_values) + npt.assert_array_equal(ta.col_indices, col_indices_values) + + # make sure chunking is correct + for texture, chunk_index, data_slice in ta: + assert ta.buffer[chunk_index] is texture + chunk_row, chunk_col = chunk_index + + data_row_start_index = chunk_row * MAX_TEXTURE_SIZE + data_col_start_index = chunk_col * MAX_TEXTURE_SIZE + + data_row_stop_index = min( + data.shape[0], data_row_start_index + MAX_TEXTURE_SIZE + ) + data_col_stop_index = min( + data.shape[1], data_col_start_index + MAX_TEXTURE_SIZE + ) + + row_slice = slice(data_row_start_index, data_row_stop_index) + col_slice = slice(data_col_start_index, data_col_stop_index) + + assert data_slice == (row_slice, col_slice) + + +def check_image_graphic(texture_array, graphic): + # make sure each ImageTile has the right texture + for (texture, chunk_index, data_slice), img in zip( + texture_array, graphic.world_object.children + ): + assert isinstance(img, _ImageTile) + assert img.geometry.grid is texture + assert img.world.x == data_slice[1].start + assert img.world.y == data_slice[0].start