diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index bcb2d2b33..bec47fdc5 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,12 +1,24 @@ name: Lint -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review jobs: lint: runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} steps: - uses: actions/checkout@v4 - uses: psf/black@stable with: - src: "./fastplotlib" \ No newline at end of file + src: "./fastplotlib" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7db694d01..9adb67f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: docs-build: name: Docs runs-on: bigmem + if: ${{ !github.event.pull_request.draft }} strategy: fail-fast: false steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe9c90242..0786596b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,35 +77,37 @@ keeps a *private* global dictionary of all `WorldObject` instances and users are This is due to garbage collection. This may be quite complicated for beginners, for more details see this PR: https://github.com/fastplotlib/fastplotlib/pull/160 . If you are curious or have more questions on garbage collection in fastplotlib you're welcome to post an issue :D. -#### Graphic Features +#### Graphic properties -There is one important thing that `fastplotlib` uses which we call "graphic features". +Graphic properties are all evented, and internally we called these "graphic features". They are the various +aspects of a graphic that the user can change. The "graphic features" subpackage can be found at `fastplotlib/graphics/_features`. As we can see this -is a private subpackage and never meant to be accessible to users. In `fastplotlib` "graphic features" are the various -aspects of a graphic that the user can change. Users can also run callbacks whenever a graphic feature changes. +is a private subpackage and never meant to be accessible to users.. ##### LineGraphic For example let's look at `LineGraphic` in `fastplotlib/graphics/line.py`. Every graphic has a class variable called -`feature_events` which is a set of all graphic features. It has the following graphic features: "data", "colors", "cmap", "thickness", "present". +`_features` which is a set of all graphic properties that are evented. It has the following evented properties: +`"data", "colors", "cmap", "thickness"` in addition to properties common to all graphics, such as `"name", "offset", "rotation", and "visible"` -Now look at the constructor for `LineGraphic`, it first creates an instance of `PointsDataFeature`. This is basically a -class that wraps the positions buffer, the vertex positions that define the line, and provides additional useful functionality. -For example, every time that the `data` is changed event handlers will be called (if any event handlers are registered). +Now look at the constructor for the `LineGraphic` base class `PositionsGraphic`, it first creates an instance of `VertexPositions`. +This is a class that manages vertex positions buffer. It defines the line, and provides additional useful functionality. +For example, every time that the `data` is changed, the new data will be marked for upload to the GPU before the next draw. +In addition, event handlers will be called if any event handlers are registered. -`ColorFeature`behaves similarly, but it can perform additional parsing that can create the colors buffer from different forms of user input. For example if a user runs: -`line_graphic.colors = "blue"`, then `ColorFeature.__setitem__()` will create a buffer that corresponds to what `pygfx.Color` thinks is "blue". -Users can also take advantage of fancy indexing, ex: `line_graphics.colors[bool_array] = "red"` :smile: +`VertexColors`behaves similarly, but it can perform additional parsing that can create the colors buffer from different +forms of user input. For example if a user runs: `line_graphic.colors = "blue"`, then `VertexColors.__setitem__()` will +create a buffer that corresponds to what `pygfx.Color` thinks is "blue". Users can also take advantage of fancy indexing, +ex: `line_graphics.colors[bool_array] = "red"` :smile: -`LineGraphic` also has a `CmapFeature`, this is a subclass of `ColorFeature` which can parse colormaps, for example: +`LineGraphic` also has a `VertexCmap`, this manages the line `VertexColors` instance to parse colormaps, for example: `line_graphic.cmap = "jet"` or even `line_graphic.cmap[50:] = "viridis"`. -`LineGraphic` also has `ThicknessFeature` which is pretty simple, `PresentFeature` which indicates if a graphic is -currently in the scene, and `DeletedFeature` which is useful if you need callbacks to indicate that the graphic has been -deleted (for example, removing references to a graphic from a legend). +`LineGraphic` also has a `thickness` property which is pretty simple, and `DeletedFeature` which is useful if you need +callbacks to indicate that the graphic has been deleted (for example, removing references to a graphic from a legend). -Other graphics have graphic features that are relevant to them, for example `ImageGraphic` has a `cmap` feature which is -unique to images or heatmaps. +Other graphics have properties that are relevant to them, for example `ImageGraphic` has `cmap`, `vmin`, `vmax`, +properties unique to images. #### Selectors @@ -192,9 +194,10 @@ the subplots. All subplots within a `Figure` share the same canvas and use diffe ## Tests in detail -The CI pipeline for a plotting library that is supposed to produce things that "look visually correct". Each example -within the `examples` dir is run and an image of the canvas is taken and compared with a ground-truth -screenshot that we have manually inspected. Ground-truth image are stored using `git-lfs`. +Backend tests are in `tests/`, in addition as a plotting library CI pipeline produces things that +"look visually correct". Each example within the `examples` dir is run and an image of the canvas +is taken and compared with a ground-truth screenshot that we have manually inspected. +Ground-truth image are stored using `git-lfs`. The ground-truth images are in: diff --git a/docs/source/api/gpu.rst b/docs/source/api/gpu.rst index 62ffd5797..6f94aff23 100644 --- a/docs/source/api/gpu.rst +++ b/docs/source/api/gpu.rst @@ -1,5 +1,6 @@ -fastplotlib.utils -***************** +fastplotlib.utils.gpu +********************* -.. automodule:: fastplotlib.utils.gpu +.. currentmodule:: fastplotlib.utils.gpu +.. automodule:: fastplotlib :members: diff --git a/docs/source/api/graphic_features/CmapFeature.rst b/docs/source/api/graphic_features/CmapFeature.rst deleted file mode 100644 index 7cc2f681f..000000000 --- a/docs/source/api/graphic_features/CmapFeature.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _api.CmapFeature: - -CmapFeature -*********** - -=========== -CmapFeature -=========== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: CmapFeature_api - - CmapFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: CmapFeature_api - - CmapFeature.buffer - CmapFeature.name - CmapFeature.values - -Methods -~~~~~~~ -.. autosummary:: - :toctree: CmapFeature_api - - CmapFeature.add_event_handler - CmapFeature.block_events - CmapFeature.clear_event_handlers - CmapFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/ColorFeature.rst b/docs/source/api/graphic_features/ColorFeature.rst deleted file mode 100644 index 3ed84cd70..000000000 --- a/docs/source/api/graphic_features/ColorFeature.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _api.ColorFeature: - -ColorFeature -************ - -============ -ColorFeature -============ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ColorFeature_api - - ColorFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ColorFeature_api - - ColorFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ColorFeature_api - - ColorFeature.add_event_handler - ColorFeature.block_events - ColorFeature.clear_event_handlers - ColorFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/Deleted.rst b/docs/source/api/graphic_features/Deleted.rst index 998e94588..09131c4a7 100644 --- a/docs/source/api/graphic_features/Deleted.rst +++ b/docs/source/api/graphic_features/Deleted.rst @@ -20,6 +20,7 @@ Properties .. autosummary:: :toctree: Deleted_api + Deleted.value Methods ~~~~~~~ @@ -30,4 +31,5 @@ Methods Deleted.block_events Deleted.clear_event_handlers Deleted.remove_event_handler + Deleted.set_value diff --git a/docs/source/api/graphic_features/FeatureEvent.rst b/docs/source/api/graphic_features/FeatureEvent.rst deleted file mode 100644 index f22ee3ef4..000000000 --- a/docs/source/api/graphic_features/FeatureEvent.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. _api.FeatureEvent: - -FeatureEvent -************ - -============ -FeatureEvent -============ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: FeatureEvent_api - - FeatureEvent - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: FeatureEvent_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: FeatureEvent_api - - diff --git a/docs/source/api/graphic_features/FontSize.rst b/docs/source/api/graphic_features/FontSize.rst new file mode 100644 index 000000000..4b8df9826 --- /dev/null +++ b/docs/source/api/graphic_features/FontSize.rst @@ -0,0 +1,35 @@ +.. _api.FontSize: + +FontSize +******** + +======== +FontSize +======== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: FontSize_api + + FontSize + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: FontSize_api + + FontSize.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: FontSize_api + + FontSize.add_event_handler + FontSize.block_events + FontSize.clear_event_handlers + FontSize.remove_event_handler + FontSize.set_value + diff --git a/docs/source/api/graphic_features/GraphicFeature.rst b/docs/source/api/graphic_features/GraphicFeature.rst deleted file mode 100644 index 7abc3e6b2..000000000 --- a/docs/source/api/graphic_features/GraphicFeature.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.GraphicFeature: - -GraphicFeature -************** - -============== -GraphicFeature -============== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeature_api - - GraphicFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeature_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: GraphicFeature_api - - GraphicFeature.add_event_handler - GraphicFeature.block_events - GraphicFeature.clear_event_handlers - GraphicFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/GraphicFeatureIndexable.rst b/docs/source/api/graphic_features/GraphicFeatureIndexable.rst deleted file mode 100644 index 7bd1383bc..000000000 --- a/docs/source/api/graphic_features/GraphicFeatureIndexable.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _api.GraphicFeatureIndexable: - -GraphicFeatureIndexable -*********************** - -======================= -GraphicFeatureIndexable -======================= -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeatureIndexable_api - - GraphicFeatureIndexable - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: GraphicFeatureIndexable_api - - GraphicFeatureIndexable.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: GraphicFeatureIndexable_api - - GraphicFeatureIndexable.add_event_handler - GraphicFeatureIndexable.block_events - GraphicFeatureIndexable.clear_event_handlers - GraphicFeatureIndexable.remove_event_handler - diff --git a/docs/source/api/graphic_features/HeatmapCmapFeature.rst b/docs/source/api/graphic_features/HeatmapCmapFeature.rst deleted file mode 100644 index bac43c9b9..000000000 --- a/docs/source/api/graphic_features/HeatmapCmapFeature.rst +++ /dev/null @@ -1,37 +0,0 @@ -.. _api.HeatmapCmapFeature: - -HeatmapCmapFeature -****************** - -================== -HeatmapCmapFeature -================== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapCmapFeature_api - - HeatmapCmapFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapCmapFeature_api - - HeatmapCmapFeature.name - HeatmapCmapFeature.vmax - HeatmapCmapFeature.vmin - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HeatmapCmapFeature_api - - HeatmapCmapFeature.add_event_handler - HeatmapCmapFeature.block_events - HeatmapCmapFeature.clear_event_handlers - HeatmapCmapFeature.remove_event_handler - HeatmapCmapFeature.reset_vmin_vmax - diff --git a/docs/source/api/graphic_features/HeatmapDataFeature.rst b/docs/source/api/graphic_features/HeatmapDataFeature.rst deleted file mode 100644 index 029f0e199..000000000 --- a/docs/source/api/graphic_features/HeatmapDataFeature.rst +++ /dev/null @@ -1,35 +0,0 @@ -.. _api.HeatmapDataFeature: - -HeatmapDataFeature -****************** - -================== -HeatmapDataFeature -================== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapDataFeature_api - - HeatmapDataFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapDataFeature_api - - HeatmapDataFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HeatmapDataFeature_api - - HeatmapDataFeature.add_event_handler - HeatmapDataFeature.block_events - HeatmapDataFeature.clear_event_handlers - HeatmapDataFeature.remove_event_handler - HeatmapDataFeature.update_gpu - diff --git a/docs/source/api/graphic_features/ImageCmap.rst b/docs/source/api/graphic_features/ImageCmap.rst new file mode 100644 index 000000000..23d16a4a2 --- /dev/null +++ b/docs/source/api/graphic_features/ImageCmap.rst @@ -0,0 +1,35 @@ +.. _api.ImageCmap: + +ImageCmap +********* + +========= +ImageCmap +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmap_api + + ImageCmap + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmap_api + + ImageCmap.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageCmap_api + + ImageCmap.add_event_handler + ImageCmap.block_events + ImageCmap.clear_event_handlers + ImageCmap.remove_event_handler + ImageCmap.set_value + diff --git a/docs/source/api/graphic_features/ImageCmapFeature.rst b/docs/source/api/graphic_features/ImageCmapFeature.rst deleted file mode 100644 index ae65744c7..000000000 --- a/docs/source/api/graphic_features/ImageCmapFeature.rst +++ /dev/null @@ -1,37 +0,0 @@ -.. _api.ImageCmapFeature: - -ImageCmapFeature -**************** - -================ -ImageCmapFeature -================ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageCmapFeature_api - - ImageCmapFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageCmapFeature_api - - ImageCmapFeature.name - ImageCmapFeature.vmax - ImageCmapFeature.vmin - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageCmapFeature_api - - ImageCmapFeature.add_event_handler - ImageCmapFeature.block_events - ImageCmapFeature.clear_event_handlers - ImageCmapFeature.remove_event_handler - ImageCmapFeature.reset_vmin_vmax - diff --git a/docs/source/api/graphic_features/ImageCmapInterpolation.rst b/docs/source/api/graphic_features/ImageCmapInterpolation.rst new file mode 100644 index 000000000..7e04ec788 --- /dev/null +++ b/docs/source/api/graphic_features/ImageCmapInterpolation.rst @@ -0,0 +1,35 @@ +.. _api.ImageCmapInterpolation: + +ImageCmapInterpolation +********************** + +====================== +ImageCmapInterpolation +====================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmapInterpolation_api + + ImageCmapInterpolation + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageCmapInterpolation_api + + ImageCmapInterpolation.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageCmapInterpolation_api + + ImageCmapInterpolation.add_event_handler + ImageCmapInterpolation.block_events + ImageCmapInterpolation.clear_event_handlers + ImageCmapInterpolation.remove_event_handler + ImageCmapInterpolation.set_value + diff --git a/docs/source/api/graphic_features/ImageDataFeature.rst b/docs/source/api/graphic_features/ImageDataFeature.rst deleted file mode 100644 index 35fe74cf7..000000000 --- a/docs/source/api/graphic_features/ImageDataFeature.rst +++ /dev/null @@ -1,35 +0,0 @@ -.. _api.ImageDataFeature: - -ImageDataFeature -**************** - -================ -ImageDataFeature -================ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ImageDataFeature_api - - ImageDataFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ImageDataFeature_api - - ImageDataFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ImageDataFeature_api - - ImageDataFeature.add_event_handler - ImageDataFeature.block_events - ImageDataFeature.clear_event_handlers - ImageDataFeature.remove_event_handler - ImageDataFeature.update_gpu - diff --git a/docs/source/api/graphic_features/ImageInterpolation.rst b/docs/source/api/graphic_features/ImageInterpolation.rst new file mode 100644 index 000000000..866e76333 --- /dev/null +++ b/docs/source/api/graphic_features/ImageInterpolation.rst @@ -0,0 +1,35 @@ +.. _api.ImageInterpolation: + +ImageInterpolation +****************** + +================== +ImageInterpolation +================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageInterpolation_api + + ImageInterpolation + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageInterpolation_api + + ImageInterpolation.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageInterpolation_api + + ImageInterpolation.add_event_handler + ImageInterpolation.block_events + ImageInterpolation.clear_event_handlers + ImageInterpolation.remove_event_handler + ImageInterpolation.set_value + diff --git a/docs/source/api/graphic_features/ImageVmax.rst b/docs/source/api/graphic_features/ImageVmax.rst new file mode 100644 index 000000000..b7dfe7e2d --- /dev/null +++ b/docs/source/api/graphic_features/ImageVmax.rst @@ -0,0 +1,35 @@ +.. _api.ImageVmax: + +ImageVmax +********* + +========= +ImageVmax +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmax_api + + ImageVmax + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmax_api + + ImageVmax.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVmax_api + + ImageVmax.add_event_handler + ImageVmax.block_events + ImageVmax.clear_event_handlers + ImageVmax.remove_event_handler + ImageVmax.set_value + diff --git a/docs/source/api/graphic_features/ImageVmin.rst b/docs/source/api/graphic_features/ImageVmin.rst new file mode 100644 index 000000000..0d4634894 --- /dev/null +++ b/docs/source/api/graphic_features/ImageVmin.rst @@ -0,0 +1,35 @@ +.. _api.ImageVmin: + +ImageVmin +********* + +========= +ImageVmin +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmin_api + + ImageVmin + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: ImageVmin_api + + ImageVmin.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: ImageVmin_api + + ImageVmin.add_event_handler + ImageVmin.block_events + ImageVmin.clear_event_handlers + ImageVmin.remove_event_handler + ImageVmin.set_value + diff --git a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst index a15825530..b8958c86b 100644 --- a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst +++ b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst @@ -21,6 +21,7 @@ Properties :toctree: LinearRegionSelectionFeature_api LinearRegionSelectionFeature.axis + LinearRegionSelectionFeature.value Methods ~~~~~~~ @@ -31,4 +32,5 @@ Methods LinearRegionSelectionFeature.block_events LinearRegionSelectionFeature.clear_event_handlers LinearRegionSelectionFeature.remove_event_handler + LinearRegionSelectionFeature.set_value diff --git a/docs/source/api/graphic_features/LinearSelectionFeature.rst b/docs/source/api/graphic_features/LinearSelectionFeature.rst index aeb1ca66b..ad7b8645a 100644 --- a/docs/source/api/graphic_features/LinearSelectionFeature.rst +++ b/docs/source/api/graphic_features/LinearSelectionFeature.rst @@ -20,6 +20,7 @@ Properties .. autosummary:: :toctree: LinearSelectionFeature_api + LinearSelectionFeature.value Methods ~~~~~~~ @@ -30,4 +31,5 @@ Methods LinearSelectionFeature.block_events LinearSelectionFeature.clear_event_handlers LinearSelectionFeature.remove_event_handler + LinearSelectionFeature.set_value diff --git a/docs/source/api/graphic_features/Name.rst b/docs/source/api/graphic_features/Name.rst new file mode 100644 index 000000000..288fcfc22 --- /dev/null +++ b/docs/source/api/graphic_features/Name.rst @@ -0,0 +1,35 @@ +.. _api.Name: + +Name +**** + +==== +Name +==== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Name_api + + Name + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Name_api + + Name.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Name_api + + Name.add_event_handler + Name.block_events + Name.clear_event_handlers + Name.remove_event_handler + Name.set_value + diff --git a/docs/source/api/graphic_features/Offset.rst b/docs/source/api/graphic_features/Offset.rst new file mode 100644 index 000000000..683aaf763 --- /dev/null +++ b/docs/source/api/graphic_features/Offset.rst @@ -0,0 +1,35 @@ +.. _api.Offset: + +Offset +****** + +====== +Offset +====== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Offset_api + + Offset + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Offset_api + + Offset.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Offset_api + + Offset.add_event_handler + Offset.block_events + Offset.clear_event_handlers + Offset.remove_event_handler + Offset.set_value + diff --git a/docs/source/api/graphic_features/PointsDataFeature.rst b/docs/source/api/graphic_features/PointsDataFeature.rst deleted file mode 100644 index 078b1c535..000000000 --- a/docs/source/api/graphic_features/PointsDataFeature.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _api.PointsDataFeature: - -PointsDataFeature -***************** - -================= -PointsDataFeature -================= -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PointsDataFeature_api - - PointsDataFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PointsDataFeature_api - - PointsDataFeature.buffer - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PointsDataFeature_api - - PointsDataFeature.add_event_handler - PointsDataFeature.block_events - PointsDataFeature.clear_event_handlers - PointsDataFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst index 7915cb09d..3dcc4eeb2 100644 --- a/docs/source/api/graphic_features/PointsSizesFeature.rst +++ b/docs/source/api/graphic_features/PointsSizesFeature.rst @@ -21,6 +21,8 @@ Properties :toctree: PointsSizesFeature_api PointsSizesFeature.buffer + PointsSizesFeature.shared + PointsSizesFeature.value Methods ~~~~~~~ @@ -31,4 +33,5 @@ Methods PointsSizesFeature.block_events PointsSizesFeature.clear_event_handlers PointsSizesFeature.remove_event_handler + PointsSizesFeature.set_value diff --git a/docs/source/api/graphic_features/PresentFeature.rst b/docs/source/api/graphic_features/PresentFeature.rst deleted file mode 100644 index 1ddbf1ec4..000000000 --- a/docs/source/api/graphic_features/PresentFeature.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.PresentFeature: - -PresentFeature -************** - -============== -PresentFeature -============== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PresentFeature_api - - PresentFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PresentFeature_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PresentFeature_api - - PresentFeature.add_event_handler - PresentFeature.block_events - PresentFeature.clear_event_handlers - PresentFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/Rotation.rst b/docs/source/api/graphic_features/Rotation.rst new file mode 100644 index 000000000..f8963b0fd --- /dev/null +++ b/docs/source/api/graphic_features/Rotation.rst @@ -0,0 +1,35 @@ +.. _api.Rotation: + +Rotation +******** + +======== +Rotation +======== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Rotation_api + + Rotation + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Rotation_api + + Rotation.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Rotation_api + + Rotation.add_event_handler + Rotation.block_events + Rotation.clear_event_handlers + Rotation.remove_event_handler + Rotation.set_value + diff --git a/docs/source/api/graphic_features/TextData.rst b/docs/source/api/graphic_features/TextData.rst new file mode 100644 index 000000000..1c27b6e48 --- /dev/null +++ b/docs/source/api/graphic_features/TextData.rst @@ -0,0 +1,35 @@ +.. _api.TextData: + +TextData +******** + +======== +TextData +======== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextData_api + + TextData + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextData_api + + TextData.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextData_api + + TextData.add_event_handler + TextData.block_events + TextData.clear_event_handlers + TextData.remove_event_handler + TextData.set_value + diff --git a/docs/source/api/graphic_features/TextFaceColor.rst b/docs/source/api/graphic_features/TextFaceColor.rst new file mode 100644 index 000000000..5dae54192 --- /dev/null +++ b/docs/source/api/graphic_features/TextFaceColor.rst @@ -0,0 +1,35 @@ +.. _api.TextFaceColor: + +TextFaceColor +************* + +============= +TextFaceColor +============= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextFaceColor_api + + TextFaceColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextFaceColor_api + + TextFaceColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextFaceColor_api + + TextFaceColor.add_event_handler + TextFaceColor.block_events + TextFaceColor.clear_event_handlers + TextFaceColor.remove_event_handler + TextFaceColor.set_value + diff --git a/docs/source/api/graphic_features/TextOutlineColor.rst b/docs/source/api/graphic_features/TextOutlineColor.rst new file mode 100644 index 000000000..f7831b0df --- /dev/null +++ b/docs/source/api/graphic_features/TextOutlineColor.rst @@ -0,0 +1,35 @@ +.. _api.TextOutlineColor: + +TextOutlineColor +**************** + +================ +TextOutlineColor +================ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineColor_api + + TextOutlineColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineColor_api + + TextOutlineColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextOutlineColor_api + + TextOutlineColor.add_event_handler + TextOutlineColor.block_events + TextOutlineColor.clear_event_handlers + TextOutlineColor.remove_event_handler + TextOutlineColor.set_value + diff --git a/docs/source/api/graphic_features/TextOutlineThickness.rst b/docs/source/api/graphic_features/TextOutlineThickness.rst new file mode 100644 index 000000000..75d485781 --- /dev/null +++ b/docs/source/api/graphic_features/TextOutlineThickness.rst @@ -0,0 +1,35 @@ +.. _api.TextOutlineThickness: + +TextOutlineThickness +******************** + +==================== +TextOutlineThickness +==================== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineThickness_api + + TextOutlineThickness + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextOutlineThickness_api + + TextOutlineThickness.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextOutlineThickness_api + + TextOutlineThickness.add_event_handler + TextOutlineThickness.block_events + TextOutlineThickness.clear_event_handlers + TextOutlineThickness.remove_event_handler + TextOutlineThickness.set_value + diff --git a/docs/source/api/graphic_features/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst new file mode 100644 index 000000000..79707c453 --- /dev/null +++ b/docs/source/api/graphic_features/TextureArray.rst @@ -0,0 +1,39 @@ +.. _api.TextureArray: + +TextureArray +************ + +============ +TextureArray +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextureArray_api + + TextureArray + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextureArray_api + + TextureArray.buffer + TextureArray.col_indices + TextureArray.row_indices + TextureArray.shared + TextureArray.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextureArray_api + + TextureArray.add_event_handler + TextureArray.block_events + TextureArray.clear_event_handlers + TextureArray.remove_event_handler + TextureArray.set_value + diff --git a/docs/source/api/graphic_features/Thickness.rst b/docs/source/api/graphic_features/Thickness.rst new file mode 100644 index 000000000..061f96fe8 --- /dev/null +++ b/docs/source/api/graphic_features/Thickness.rst @@ -0,0 +1,35 @@ +.. _api.Thickness: + +Thickness +********* + +========= +Thickness +========= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Thickness_api + + Thickness + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Thickness_api + + Thickness.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Thickness_api + + Thickness.add_event_handler + Thickness.block_events + Thickness.clear_event_handlers + Thickness.remove_event_handler + Thickness.set_value + diff --git a/docs/source/api/graphic_features/ThicknessFeature.rst b/docs/source/api/graphic_features/ThicknessFeature.rst deleted file mode 100644 index 80219a2cd..000000000 --- a/docs/source/api/graphic_features/ThicknessFeature.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.ThicknessFeature: - -ThicknessFeature -**************** - -================ -ThicknessFeature -================ -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: ThicknessFeature_api - - ThicknessFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: ThicknessFeature_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: ThicknessFeature_api - - ThicknessFeature.add_event_handler - ThicknessFeature.block_events - ThicknessFeature.clear_event_handlers - ThicknessFeature.remove_event_handler - diff --git a/docs/source/api/graphic_features/UniformColor.rst b/docs/source/api/graphic_features/UniformColor.rst new file mode 100644 index 000000000..7370589b7 --- /dev/null +++ b/docs/source/api/graphic_features/UniformColor.rst @@ -0,0 +1,35 @@ +.. _api.UniformColor: + +UniformColor +************ + +============ +UniformColor +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformColor_api + + UniformColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformColor_api + + UniformColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformColor_api + + UniformColor.add_event_handler + UniformColor.block_events + UniformColor.clear_event_handlers + UniformColor.remove_event_handler + UniformColor.set_value + diff --git a/docs/source/api/graphic_features/UniformSize.rst b/docs/source/api/graphic_features/UniformSize.rst new file mode 100644 index 000000000..e342d6a70 --- /dev/null +++ b/docs/source/api/graphic_features/UniformSize.rst @@ -0,0 +1,35 @@ +.. _api.UniformSize: + +UniformSize +*********** + +=========== +UniformSize +=========== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformSize_api + + UniformSize + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformSize_api + + UniformSize.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformSize_api + + UniformSize.add_event_handler + UniformSize.block_events + UniformSize.clear_event_handlers + UniformSize.remove_event_handler + UniformSize.set_value + diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst new file mode 100644 index 000000000..a3311d6e6 --- /dev/null +++ b/docs/source/api/graphic_features/VertexCmap.rst @@ -0,0 +1,40 @@ +.. _api.VertexCmap: + +VertexCmap +********** + +========== +VertexCmap +========== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmap_api + + VertexCmap + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexCmap_api + + VertexCmap.alpha + VertexCmap.buffer + VertexCmap.name + VertexCmap.shared + VertexCmap.transform + VertexCmap.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexCmap_api + + VertexCmap.add_event_handler + VertexCmap.block_events + VertexCmap.clear_event_handlers + VertexCmap.remove_event_handler + VertexCmap.set_value + diff --git a/docs/source/api/graphic_features/VertexColors.rst b/docs/source/api/graphic_features/VertexColors.rst new file mode 100644 index 000000000..3c2089a78 --- /dev/null +++ b/docs/source/api/graphic_features/VertexColors.rst @@ -0,0 +1,37 @@ +.. _api.VertexColors: + +VertexColors +************ + +============ +VertexColors +============ +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexColors_api + + VertexColors + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexColors_api + + VertexColors.buffer + VertexColors.shared + VertexColors.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexColors_api + + VertexColors.add_event_handler + VertexColors.block_events + VertexColors.clear_event_handlers + VertexColors.remove_event_handler + VertexColors.set_value + diff --git a/docs/source/api/graphic_features/VertexPositions.rst b/docs/source/api/graphic_features/VertexPositions.rst new file mode 100644 index 000000000..9669ab6d5 --- /dev/null +++ b/docs/source/api/graphic_features/VertexPositions.rst @@ -0,0 +1,37 @@ +.. _api.VertexPositions: + +VertexPositions +*************** + +=============== +VertexPositions +=============== +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPositions_api + + VertexPositions + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPositions_api + + VertexPositions.buffer + VertexPositions.shared + VertexPositions.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexPositions_api + + VertexPositions.add_event_handler + VertexPositions.block_events + VertexPositions.clear_event_handlers + VertexPositions.remove_event_handler + VertexPositions.set_value + diff --git a/docs/source/api/graphic_features/Visible.rst b/docs/source/api/graphic_features/Visible.rst new file mode 100644 index 000000000..957b4433a --- /dev/null +++ b/docs/source/api/graphic_features/Visible.rst @@ -0,0 +1,35 @@ +.. _api.Visible: + +Visible +******* + +======= +Visible +======= +.. currentmodule:: fastplotlib.graphics._features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Visible_api + + Visible + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Visible_api + + Visible.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Visible_api + + Visible.add_event_handler + Visible.block_events + Visible.clear_event_handlers + Visible.remove_event_handler + Visible.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 06e3119e5..87504ea8a 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -4,20 +4,28 @@ Graphic Features .. toctree:: :maxdepth: 1 - ColorFeature - CmapFeature - ImageCmapFeature - HeatmapCmapFeature - PointsDataFeature + VertexColors + UniformColor + UniformSize + Thickness + VertexPositions PointsSizesFeature - ImageDataFeature - HeatmapDataFeature - PresentFeature - ThicknessFeature - GraphicFeature - GraphicFeatureIndexable - FeatureEvent - to_gpu_supported_dtype + VertexCmap + TextureArray + ImageCmap + ImageVmin + ImageVmax + ImageInterpolation + ImageCmapInterpolation + TextData + FontSize + TextFaceColor + TextOutlineColor + TextOutlineThickness LinearSelectionFeature LinearRegionSelectionFeature + Name + Offset + Rotation + Visible Deleted diff --git a/docs/source/api/graphic_features/to_gpu_supported_dtype.rst b/docs/source/api/graphic_features/to_gpu_supported_dtype.rst deleted file mode 100644 index 984a76157..000000000 --- a/docs/source/api/graphic_features/to_gpu_supported_dtype.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. _api.to_gpu_supported_dtype: - -to_gpu_supported_dtype -********************** - -====================== -to_gpu_supported_dtype -====================== -.. currentmodule:: fastplotlib.graphics._features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: to_gpu_supported_dtype_api - - to_gpu_supported_dtype - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: to_gpu_supported_dtype_api - - -Methods -~~~~~~~ -.. autosummary:: - :toctree: to_gpu_supported_dtype_api - - diff --git a/docs/source/api/graphics/HeatmapGraphic.rst b/docs/source/api/graphics/HeatmapGraphic.rst deleted file mode 100644 index ffa86eb16..000000000 --- a/docs/source/api/graphics/HeatmapGraphic.rst +++ /dev/null @@ -1,44 +0,0 @@ -.. _api.HeatmapGraphic: - -HeatmapGraphic -************** - -============== -HeatmapGraphic -============== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapGraphic_api - - HeatmapGraphic - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: HeatmapGraphic_api - - HeatmapGraphic.children - HeatmapGraphic.name - HeatmapGraphic.position - HeatmapGraphic.position_x - HeatmapGraphic.position_y - HeatmapGraphic.position_z - HeatmapGraphic.rotation - HeatmapGraphic.visible - HeatmapGraphic.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: HeatmapGraphic_api - - HeatmapGraphic.add_linear_region_selector - HeatmapGraphic.add_linear_selector - HeatmapGraphic.link - HeatmapGraphic.reset_feature - HeatmapGraphic.rotate - HeatmapGraphic.set_feature - diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index 00b27340d..a0ae8a5ed 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -20,14 +20,20 @@ Properties .. autosummary:: :toctree: ImageGraphic_api - ImageGraphic.children + ImageGraphic.block_events + ImageGraphic.cmap + ImageGraphic.cmap_interpolation + ImageGraphic.data + ImageGraphic.deleted + ImageGraphic.event_handlers + ImageGraphic.interpolation ImageGraphic.name - ImageGraphic.position - ImageGraphic.position_x - ImageGraphic.position_y - ImageGraphic.position_z + ImageGraphic.offset ImageGraphic.rotation + ImageGraphic.supported_events ImageGraphic.visible + ImageGraphic.vmax + ImageGraphic.vmin ImageGraphic.world_object Methods @@ -35,10 +41,13 @@ Methods .. autosummary:: :toctree: ImageGraphic_api + ImageGraphic.add_event_handler ImageGraphic.add_linear_region_selector ImageGraphic.add_linear_selector - ImageGraphic.link - ImageGraphic.reset_feature + ImageGraphic.clear_event_handlers + ImageGraphic.remove_event_handler + ImageGraphic.reset_vmin_vmax ImageGraphic.rotate - ImageGraphic.set_feature + ImageGraphic.share_property + ImageGraphic.unshare_property diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index 8d10d8376..c000b7334 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -20,17 +20,24 @@ Properties .. autosummary:: :toctree: LineCollection_api - LineCollection.children + LineCollection.block_events LineCollection.cmap - LineCollection.cmap_values + LineCollection.colors + LineCollection.data + LineCollection.deleted + LineCollection.event_handlers LineCollection.graphics + LineCollection.metadatas LineCollection.name - LineCollection.position - LineCollection.position_x - LineCollection.position_y - LineCollection.position_z + LineCollection.names + LineCollection.offset + LineCollection.offsets LineCollection.rotation + LineCollection.rotations + LineCollection.supported_events + LineCollection.thickness LineCollection.visible + LineCollection.visibles LineCollection.world_object Methods @@ -38,12 +45,14 @@ Methods .. autosummary:: :toctree: LineCollection_api + LineCollection.add_event_handler LineCollection.add_graphic LineCollection.add_linear_region_selector LineCollection.add_linear_selector - LineCollection.link + LineCollection.clear_event_handlers + LineCollection.remove_event_handler LineCollection.remove_graphic - LineCollection.reset_feature LineCollection.rotate - LineCollection.set_feature + LineCollection.share_property + LineCollection.unshare_property diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index 8b6fedf22..d260c3214 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -20,13 +20,17 @@ Properties .. autosummary:: :toctree: LineGraphic_api - LineGraphic.children + LineGraphic.block_events + LineGraphic.cmap + LineGraphic.colors + LineGraphic.data + LineGraphic.deleted + LineGraphic.event_handlers LineGraphic.name - LineGraphic.position - LineGraphic.position_x - LineGraphic.position_y - LineGraphic.position_z + LineGraphic.offset LineGraphic.rotation + LineGraphic.supported_events + LineGraphic.thickness LineGraphic.visible LineGraphic.world_object @@ -35,10 +39,12 @@ Methods .. autosummary:: :toctree: LineGraphic_api + LineGraphic.add_event_handler LineGraphic.add_linear_region_selector LineGraphic.add_linear_selector - LineGraphic.link - LineGraphic.reset_feature + LineGraphic.clear_event_handlers + LineGraphic.remove_event_handler LineGraphic.rotate - LineGraphic.set_feature + LineGraphic.share_property + LineGraphic.unshare_property diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index a39db46f8..18b35932d 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -20,17 +20,24 @@ Properties .. autosummary:: :toctree: LineStack_api - LineStack.children + LineStack.block_events LineStack.cmap - LineStack.cmap_values + LineStack.colors + LineStack.data + LineStack.deleted + LineStack.event_handlers LineStack.graphics + LineStack.metadatas LineStack.name - LineStack.position - LineStack.position_x - LineStack.position_y - LineStack.position_z + LineStack.names + LineStack.offset + LineStack.offsets LineStack.rotation + LineStack.rotations + LineStack.supported_events + LineStack.thickness LineStack.visible + LineStack.visibles LineStack.world_object Methods @@ -38,12 +45,14 @@ Methods .. autosummary:: :toctree: LineStack_api + LineStack.add_event_handler LineStack.add_graphic LineStack.add_linear_region_selector LineStack.add_linear_selector - LineStack.link + LineStack.clear_event_handlers + LineStack.remove_event_handler LineStack.remove_graphic - LineStack.reset_feature LineStack.rotate - LineStack.set_feature + LineStack.share_property + LineStack.unshare_property diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 44d87d008..8f2b17fd6 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -20,13 +20,17 @@ Properties .. autosummary:: :toctree: ScatterGraphic_api - ScatterGraphic.children + ScatterGraphic.block_events + ScatterGraphic.cmap + ScatterGraphic.colors + ScatterGraphic.data + ScatterGraphic.deleted + ScatterGraphic.event_handlers ScatterGraphic.name - ScatterGraphic.position - ScatterGraphic.position_x - ScatterGraphic.position_y - ScatterGraphic.position_z + ScatterGraphic.offset ScatterGraphic.rotation + ScatterGraphic.sizes + ScatterGraphic.supported_events ScatterGraphic.visible ScatterGraphic.world_object @@ -35,5 +39,10 @@ Methods .. autosummary:: :toctree: ScatterGraphic_api + ScatterGraphic.add_event_handler + ScatterGraphic.clear_event_handlers + ScatterGraphic.remove_event_handler ScatterGraphic.rotate + ScatterGraphic.share_property + ScatterGraphic.unshare_property diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index 23425cf41..a3cd9bbb9 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -20,18 +20,18 @@ Properties .. autosummary:: :toctree: TextGraphic_api - TextGraphic.children + TextGraphic.block_events + TextGraphic.deleted + TextGraphic.event_handlers TextGraphic.face_color + TextGraphic.font_size TextGraphic.name + TextGraphic.offset TextGraphic.outline_color - TextGraphic.outline_size - TextGraphic.position - TextGraphic.position_x - TextGraphic.position_y - TextGraphic.position_z + TextGraphic.outline_thickness TextGraphic.rotation + TextGraphic.supported_events TextGraphic.text - TextGraphic.text_size TextGraphic.visible TextGraphic.world_object @@ -40,5 +40,10 @@ Methods .. autosummary:: :toctree: TextGraphic_api + TextGraphic.add_event_handler + TextGraphic.clear_event_handlers + TextGraphic.remove_event_handler TextGraphic.rotate + TextGraphic.share_property + TextGraphic.unshare_property diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index 611ee5833..b64ac53c0 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -4,10 +4,9 @@ Graphics .. toctree:: :maxdepth: 1 + LineGraphic ImageGraphic ScatterGraphic - LineGraphic - HeatmapGraphic + TextGraphic LineCollection LineStack - TextGraphic diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 91884557a..61f5da307 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -42,7 +42,6 @@ Methods Subplot.add_animations Subplot.add_graphic - Subplot.add_heatmap Subplot.add_image Subplot.add_line Subplot.add_line_collection diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index 1b59e80c9..c9140bc7d 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -20,14 +20,17 @@ Properties .. autosummary:: :toctree: LinearRegionSelector_api - LinearRegionSelector.children + LinearRegionSelector.axis + LinearRegionSelector.block_events + LinearRegionSelector.deleted + LinearRegionSelector.event_handlers LinearRegionSelector.limits LinearRegionSelector.name - LinearRegionSelector.position - LinearRegionSelector.position_x - LinearRegionSelector.position_y - LinearRegionSelector.position_z + LinearRegionSelector.offset + LinearRegionSelector.parent LinearRegionSelector.rotation + LinearRegionSelector.selection + LinearRegionSelector.supported_events LinearRegionSelector.visible LinearRegionSelector.world_object @@ -36,10 +39,15 @@ Methods .. autosummary:: :toctree: LinearRegionSelector_api + LinearRegionSelector.add_event_handler LinearRegionSelector.add_ipywidget_handler + LinearRegionSelector.clear_event_handlers LinearRegionSelector.get_selected_data LinearRegionSelector.get_selected_index LinearRegionSelector.get_selected_indices LinearRegionSelector.make_ipywidget_slider + LinearRegionSelector.remove_event_handler LinearRegionSelector.rotate + LinearRegionSelector.share_property + LinearRegionSelector.unshare_property diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index 3278559d0..fa21f8f15 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -20,14 +20,17 @@ Properties .. autosummary:: :toctree: LinearSelector_api - LinearSelector.children + LinearSelector.axis + LinearSelector.block_events + LinearSelector.deleted + LinearSelector.event_handlers LinearSelector.limits LinearSelector.name - LinearSelector.position - LinearSelector.position_x - LinearSelector.position_y - LinearSelector.position_z + LinearSelector.offset + LinearSelector.parent LinearSelector.rotation + LinearSelector.selection + LinearSelector.supported_events LinearSelector.visible LinearSelector.world_object @@ -36,10 +39,15 @@ Methods .. autosummary:: :toctree: LinearSelector_api + LinearSelector.add_event_handler LinearSelector.add_ipywidget_handler + LinearSelector.clear_event_handlers LinearSelector.get_selected_data LinearSelector.get_selected_index LinearSelector.get_selected_indices LinearSelector.make_ipywidget_slider + LinearSelector.remove_event_handler LinearSelector.rotate + LinearSelector.share_property + LinearSelector.unshare_property diff --git a/docs/source/api/selectors/PolygonSelector.rst b/docs/source/api/selectors/PolygonSelector.rst deleted file mode 100644 index 8de87ec74..000000000 --- a/docs/source/api/selectors/PolygonSelector.rst +++ /dev/null @@ -1,43 +0,0 @@ -.. _api.PolygonSelector: - -PolygonSelector -*************** - -=============== -PolygonSelector -=============== -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PolygonSelector_api - - PolygonSelector - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PolygonSelector_api - - PolygonSelector.children - PolygonSelector.name - PolygonSelector.position - PolygonSelector.position_x - PolygonSelector.position_y - PolygonSelector.position_z - PolygonSelector.rotation - PolygonSelector.visible - PolygonSelector.world_object - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PolygonSelector_api - - PolygonSelector.get_selected_data - PolygonSelector.get_selected_index - PolygonSelector.get_selected_indices - PolygonSelector.get_vertices - PolygonSelector.rotate - diff --git a/docs/source/api/selectors/Synchronizer.rst b/docs/source/api/selectors/Synchronizer.rst deleted file mode 100644 index 2b28fe351..000000000 --- a/docs/source/api/selectors/Synchronizer.rst +++ /dev/null @@ -1,33 +0,0 @@ -.. _api.Synchronizer: - -Synchronizer -************ - -============ -Synchronizer -============ -.. currentmodule:: fastplotlib - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: Synchronizer_api - - Synchronizer - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: Synchronizer_api - - Synchronizer.selectors - -Methods -~~~~~~~ -.. autosummary:: - :toctree: Synchronizer_api - - Synchronizer.add - Synchronizer.clear - Synchronizer.remove - diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst index 01c040728..ffa4054db 100644 --- a/docs/source/api/selectors/index.rst +++ b/docs/source/api/selectors/index.rst @@ -6,5 +6,3 @@ Selectors LinearSelector LinearRegionSelector - PolygonSelector - Synchronizer diff --git a/docs/source/conf.py b/docs/source/conf.py index f681a8101..38133c901 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,7 +23,6 @@ "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx_design", - "nbsphinx", ] autosummary_generate = True @@ -52,7 +51,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), - "pygfx": ("https://pygfx.readthedocs.io/en/latest", None), + "pygfx": ("https://pygfx.com/latest", None), "wgpu": ("https://wgpu-py.readthedocs.io/en/latest", None), } diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py index a5f668130..0150836ec 100644 --- a/docs/source/generate_api.py +++ b/docs/source/generate_api.py @@ -263,6 +263,12 @@ def main(): with open(API_DIR.joinpath("utils.rst"), "w") as f: f.write(utils_str) + # gpu selection + fpl_functions = generate_functions_module(fastplotlib, "fastplotlib.utils.gpu") + + with open(API_DIR.joinpath("gpu.rst"), "w") as f: + f.write(fpl_functions) + if __name__ == "__main__": main() diff --git a/docs/source/index.rst b/docs/source/index.rst index 0bca839b9..e99e38c52 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,12 +1,6 @@ Welcome to fastplotlib's documentation! ======================================= -.. toctree:: - :caption: Quick Start - :maxdepth: 2 - - quickstart - .. toctree:: :caption: User Guide :maxdepth: 2 diff --git a/docs/source/quickstart.ipynb b/docs/source/quickstart.ipynb deleted file mode 100644 index 6a892399e..000000000 --- a/docs/source/quickstart.ipynb +++ /dev/null @@ -1,1673 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "93740a09-9111-4777-ad57-173e9b80a2f0", - "metadata": { - "tags": [] - }, - "source": [ - "# Quick Start Guide 🚀\n", - "\n", - "This notebook goes through the basic components of the `fastplotlib` API, images, image updates, line plots, scatter plots, and grid plots.\n", - "\n", - "**NOTE: This quick start guide in the docs is NOT interactive. Download the examples from the repo and try them on your own computer. You can run the desktop examples directly if you have `glfw` installed, or try the notebook demos:** https://github.com/kushalkolar/fastplotlib/tree/master/examples\n", - "\n", - "It will not be possible to have live demos on the docs until someone can figure out how to get [pygfx](https://github.com/pygfx/pygfx) to work with `wgpu` in the browser, perhaps through [pyodide](https://github.com/pyodide/pyodide) or something :D." - ] - }, - { - "cell_type": "markdown", - "id": "5d21c330-89cd-49ab-9069-4e3652d4286b", - "metadata": {}, - "source": [ - "**The example images are from `imageio` so you will need to install it for this example notebook. But `imageio` is not required to use `fasptlotlib`**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "07f064bb-025a-4794-9b05-243810edaf60", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "!pip install imageio" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5f842366-bd39-47de-ad00-723b2be707e4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "import imageio.v3 as iio" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "import fastplotlib as fpl\n", - "from ipywidgets import VBox, HBox, IntSlider\n", - "import numpy as np" - ] - }, - { - "cell_type": "markdown", - "id": "a9b386ac-9218-4f8f-97b3-f29b4201ef55", - "metadata": {}, - "source": [ - "## Images" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "237823b7-e2c0-4e2f-9ee8-e3fc2b4453c4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# create a `Figure` instance\n", - "fig = fpl.Figure()\n", - "\n", - "# get a grayscale image\n", - "data = iio.imread(\"imageio:camera.png\")\n", - "\n", - "# plot the image data\n", - "image_graphic = fig[0, 0].add_image(data=data, name=\"sample-image\")\n", - "\n", - "# show the plot\n", - "fig.show()" - ] - }, - { - "cell_type": "markdown", - "id": "be5b408f-dd91-4e36-807a-8c22c8d7d216", - "metadata": {}, - "source": [ - "**In live notebooks or desktop applications, you can use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**" - ] - }, - { - "cell_type": "markdown", - "id": "9ba07ec1-a0cb-4461-87c6-c7b64d4a882b", - "metadata": {}, - "source": [ - "This is how you can take a snapshot of the canvas. Snapshots are shown throughout this doc page for the purposes of documentation, they are NOT necessary for real interactive usage. Download the notebooks to run live demos." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b64ba135-e753-43a9-ad1f-adcc7310792d", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "ac5f5e75-9aa4-441f-9a41-66c22cd53de8", - "metadata": {}, - "source": [ - "Changing graphic **\"features\"**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d3541d1d-0819-450e-814c-588ffc8e7ed5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.cmap = \"viridis\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ab544719-9187-45bd-8127-aac79eea30e4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "9693cf94-11e9-46a6-a5b7-b0fbed42ad81", - "metadata": {}, - "source": [ - "### Slicing data\n", - "\n", - "**Most features, such as `data` support slicing!**\n", - "\n", - "Out image data is of shape [n_rows, n_cols]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "330a47b5-50b1-4e6a-b8ab-d55d92af2042", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data().shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "601f46d9-7f32-4a43-9090-4674218800ea", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data[::15, :] = 1\n", - "image_graphic.data[:, ::15] = 1" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3443948f-9ac9-484a-a4bf-3a06c1ce5658", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "53125b3b-3ce2-43c5-b2e3-7cd37cec7d7d", - "metadata": {}, - "source": [ - "**Fancy indexing**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7344cbbe-40c3-4d9e-ae75-7abe3ddaeeeb", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data[data > 175] = 255" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef113d79-5d86-4be0-868e-30f82f8ab528", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "4df5296e-2a18-403f-82f1-acb8eaf280e3", - "metadata": {}, - "source": [ - "Adjust vmin vmax" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28af88d1-0518-47a4-ab73-431d6aaf9cb8", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.cmap.vmin = 50\n", - "image_graphic.cmap.vmax = 150" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e3dfb827-c812-447d-b413-dc15653160b1", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "19a1b56b-fdca-40c5-91c9-3c9486fd8a21", - "metadata": {}, - "source": [ - "**Set the entire data array again**\n", - "\n", - "Note: The shape of the new data array must match the current data shown in the Graphic." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4dc3d0e4-b128-42cd-a53e-76846fc9b8a8", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "new_data = iio.imread(\"imageio:astronaut.png\")\n", - "new_data.shape" - ] - }, - { - "cell_type": "markdown", - "id": "3bd06068-fe3f-404d-ba4a-a72a2105904f", - "metadata": {}, - "source": [ - "This is an RGB image, convert to grayscale to maintain the shape of (512, 512)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "150047a6-a6ac-442d-a468-3e0c224a2b7e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "gray = new_data.dot([0.3, 0.6, 0.1])\n", - "gray.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bf24576b-d336-4754-9992-9649ccaa4d1e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.data = gray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "67d810c2-4020-4769-a5ba-0d4a972ee243", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "2fe82654-e554-4be6-92a0-ecdee0ef8519", - "metadata": {}, - "source": [ - "reset vmin vmax" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0be6e4bb-cf9a-4155-9f6a-8106e66e6132", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic.cmap.reset_vmin_vmax()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bd51936c-ad80-4b33-b855-23565265a430", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "a6c1f3fb-a3a7-4175-bd8d-bb3203740771", - "metadata": {}, - "source": [ - "### Indexing plots" - ] - }, - { - "cell_type": "markdown", - "id": "3fc38694-aca6-4f56-97ac-3435059a6be7", - "metadata": {}, - "source": [ - "**Plots are indexable and give you their graphics by name**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8a547138-0f7d-470b-9925-8df479c3979e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5551861f-9860-4515-8222-2f1c6d6a3220", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig[0, 0][\"sample-image\"]" - ] - }, - { - "cell_type": "markdown", - "id": "0c29b36e-0eb4-4bb3-a8db-add58c303ee8", - "metadata": {}, - "source": [ - "**You can also use numerical indexing on `plot.graphics`**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ce6adbb0-078a-4e74-b189-58f860ee5df5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig[0, 0].graphics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "119bd6af-c486-4378-bc23-79b1759aa3a4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig[0, 0].graphics[0]" - ] - }, - { - "cell_type": "markdown", - "id": "6b8e3f0d-56f8-447f-bf26-b52629d06e95", - "metadata": {}, - "source": [ - "The `Graphic` instance is also returned when you call `plot.add_`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "967c0cbd-287c-4d99-9891-9baf18f7b56a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5da72e26-3536-47b8-839c-53452dd94f7a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "image_graphic is fig[0, 0][\"sample-image\"]" - ] - }, - { - "cell_type": "markdown", - "id": "2b5ee18b-e61b-415d-902a-688b1c9c03b8", - "metadata": {}, - "source": [ - "### RGB images\n", - "\n", - "`cmap` arguments are ignored for rgb images, but vmin vmax still works" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1f7143ec-8ee1-47d2-b017-d0a8efc69fc6", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_rgb = fpl.Figure()\n", - "\n", - "fig_rgb[0, 0].add_image(new_data, name=\"rgb-image\")\n", - "\n", - "fig_rgb.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a47b1eaf-3638-470a-88a5-0026c81d7e2b", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_rgb.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "4848a929-4f3b-46d7-921b-ebfe8de0ebb5", - "metadata": {}, - "source": [ - "vmin and vmax are still applicable to rgb images" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ffe50132-8dd0-433c-b9c6-9ead8c3d48de", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_rgb[0, 0][\"rgb-image\"].cmap.vmin = 100" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "161468ba-b836-4021-8d11-8dfc140b94eb", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_rgb.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "1cb03f42-1029-4b16-a16b-35447d9e2955", - "metadata": { - "tags": [] - }, - "source": [ - "## Image updates\n", - "\n", - "This examples show how you can define animation functions that run on every render cycle." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "aadd757f-6379-4f52-a709-46aa57c56216", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# create another `Figure` instance\n", - "fig_vid = fpl.Figure()\n", - "\n", - "# make some random data again\n", - "data = np.random.rand(512, 512)\n", - "\n", - "# plot the data\n", - "fig_vid[0, 0].add_image(data=data, name=\"random-image\")\n", - "\n", - "# a function to update the image_graphic\n", - "# a subplot will pass its instance to the animation function as an argument\n", - "def update_data(subplot):\n", - " new_data = np.random.rand(512, 512)\n", - " subplot[\"random-image\"].data = new_data\n", - "\n", - "#add this as an animation function to the subplot\n", - "fig_vid[0, 0].add_animations(update_data)\n", - "\n", - "# show the plot\n", - "fig_vid.show()" - ] - }, - { - "cell_type": "markdown", - "id": "b313eda1-6e6c-466f-9fd5-8b70c1d3c110", - "metadata": {}, - "source": [ - "**Share controllers across plots**\n", - "\n", - "This example creates a new plot, but it synchronizes the pan-zoom controller" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "86e70b1e-4328-4035-b992-70dff16d2a69", - "metadata": {}, - "outputs": [], - "source": [ - "fig_sync = fpl.Figure(controllers=fig_vid.controllers)\n", - "\n", - "data = np.random.rand(512, 512)\n", - "\n", - "image_graphic_instance = fig_sync[0, 0].add_image(data=data, cmap=\"viridis\")\n", - "\n", - "# you will need to define a new animation function for this graphic\n", - "def update_data_2():\n", - " new_data = np.random.rand(512, 512)\n", - " # alternatively, you can use the stored reference to the graphic as well instead of indexing the Plot\n", - " image_graphic_instance.data = new_data\n", - "\n", - "# add the animation function to the figure instead of the subplot\n", - "fig_sync.add_animations(update_data_2)\n", - "\n", - "fig_sync.show()" - ] - }, - { - "cell_type": "markdown", - "id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5", - "metadata": {}, - "source": [ - "Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where you need flexibility in the naming of the graphics" - ] - }, - { - "cell_type": "markdown", - "id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d", - "metadata": {}, - "source": [ - "You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting\n", - "\n", - "Not shown in the docs, try the live demo for this feature" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", - "metadata": {}, - "outputs": [], - "source": [ - "#VBox([plot_v.canvas, plot_sync.show()])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", - "metadata": {}, - "outputs": [], - "source": [ - "#HBox([plot_v.show(), plot_sync.show()])" - ] - }, - { - "cell_type": "markdown", - "id": "e7859338-8162-408b-ac72-37e606057045", - "metadata": { - "tags": [] - }, - "source": [ - "## Line plots\n", - "\n", - "2D line plots\n", - "\n", - "This example plots a sine wave, cosine wave, and ricker wavelet and demonstrates how **Graphic Features** can be modified by slicing!" - ] - }, - { - "cell_type": "markdown", - "id": "a6fee1c2-4a24-4325-bca2-26e5a4bf6338", - "metadata": {}, - "source": [ - "Generate some data." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8e8280da-b421-43a5-a1a6-2a196a408e9a", - "metadata": {}, - "outputs": [], - "source": [ - "# linspace, create 100 evenly spaced x values from -10 to 10\n", - "xs = np.linspace(-10, 10, 100)\n", - "# sine wave\n", - "ys = np.sin(xs)\n", - "sine = np.dstack([xs, ys])[0]\n", - "\n", - "# cosine wave\n", - "ys = np.cos(xs) + 5\n", - "cosine = np.dstack([xs, ys])[0]\n", - "\n", - "# sinc function\n", - "a = 0.5\n", - "ys = np.sinc(xs) * 3 + 8\n", - "sinc = np.dstack([xs, ys])[0]" - ] - }, - { - "cell_type": "markdown", - "id": "fbb806e5-1565-4189-936c-b7cf147a59ee", - "metadata": {}, - "source": [ - "Plot all of it on the same plot. Each line plot will be an individual Graphic, you can have any combination of graphics on a plot." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7", - "metadata": {}, - "outputs": [], - "source": [ - "# Create a plot instance\n", - "fig_line = fpl.Figure()\n", - "\n", - "# plot sine wave, use a single color\n", - "sine_graphic = fig_line[0, 0].add_line(data=sine, thickness=5, colors=\"magenta\")\n", - "\n", - "# you can also use colormaps for lines!\n", - "cosine_graphic = fig_line[0, 0].add_line(data=cosine, thickness=12, cmap=\"autumn\")\n", - "\n", - "# or a list of colors for each datapoint\n", - "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n", - "sinc_graphic = fig_line[0, 0].add_line(data=sinc, thickness=5, colors = colors)\n", - "\n", - "fig_line.show()" - ] - }, - { - "cell_type": "markdown", - "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", - "metadata": {}, - "source": [ - "\"stretching\" the camera, useful for large timeseries data\n", - "\n", - "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", - "\n", - "You can also click the **`1:1`** button to toggle this." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line[0, 0].camera.maintain_aspect = False" - ] - }, - { - "cell_type": "markdown", - "id": "1651e965-f750-47ac-bf53-c23dae84cc98", - "metadata": {}, - "source": [ - "reset the plot area" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ba50a6ed-0f1b-4795-91dd-a7c3e40b8e3c", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line[0, 0].auto_scale(maintain_aspect=True)" - ] - }, - { - "cell_type": "markdown", - "id": "dcd68796-c190-4c3f-8519-d73b98ff6367", - "metadata": {}, - "source": [ - "Graphic features support slicing! :D " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", - "metadata": {}, - "outputs": [], - "source": [ - "# indexing of colors\n", - "cosine_graphic.colors[:15] = \"magenta\"\n", - "cosine_graphic.colors[90:] = \"red\"\n", - "cosine_graphic.colors[60] = \"w\"\n", - "\n", - "# indexing to assign colormaps to entire lines or segments\n", - "sinc_graphic.cmap[10:50] = \"gray\"\n", - "sine_graphic.cmap = \"seismic\"\n", - "\n", - "# more complex indexing, set the blue value directly from an array\n", - "cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65)" - ] - }, - { - "cell_type": "markdown", - "id": "bfe14ed3-e81f-4058-96a7-e2720b6d2f45", - "metadata": {}, - "source": [ - "Make a snapshot of the canvas after slicing" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a061a888-d732-406e-a9c2-8cc632fbc368", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "c9689887-cdf3-4a4d-948f-7efdb09bde4e", - "metadata": {}, - "source": [ - "**You can capture changes to a graphic feature as events**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cfa001f6-c640-4f91-beb0-c19b030e503f", - "metadata": {}, - "outputs": [], - "source": [ - "def callback_func(event_data):\n", - " print(event_data)\n", - "\n", - "# Will print event data when the color changes\n", - "cosine_graphic.colors.add_event_handler(callback_func)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d", - "metadata": {}, - "outputs": [], - "source": [ - "# more complex indexing of colors\n", - "# from point 15 - 30, set every 3rd point as \"cyan\"\n", - "cosine_graphic.colors[15:50:3] = \"cyan\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3da9a43b-35bd-4b56-9cc7-967536aac967", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "c29f81f9-601b-49f4-b20c-575c56e58026", - "metadata": {}, - "source": [ - "Graphic `data` is also indexable" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", - "metadata": {}, - "outputs": [], - "source": [ - "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", - "cosine_graphic.data[90:, 1] = 7" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", - "metadata": {}, - "outputs": [], - "source": [ - "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f779cba0-7ee2-4795-8da8-9a9593d3893e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "3f6d264b-1b03-407e-9d83-cd6cfb02e706", - "metadata": {}, - "source": [ - "Toggle the presence of a graphic within the scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = False" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a5e22d0f-a244-47e2-9a2d-1eaf79eda1d9", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = True" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b22a8660-26b3-4c73-b87a-df9d7cb4353a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "86f4e535-ce88-415a-b8d2-53612a2de7b9", - "metadata": {}, - "source": [ - "You can create callbacks to `present` too, for example to re-scale the plot w.r.t. graphics that are present in the scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "64a20a16-75a5-4772-a849-630ade9be4ff", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present.add_event_handler(fig_line[0, 0].auto_scale)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = False" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f9dd6a54-3460-4fb7-bffb-82fd9288902f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = True" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cb5bf73e-b015-4b4f-82a0-c3ae8cc39ef7", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", - "metadata": {}, - "source": [ - "You can set the z-positions of graphics to have them appear under or over other graphics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6bb33406-5bef-455b-86ea-358a7d3ffa94", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "img = np.random.rand(20, 100)\n", - "\n", - "fig_line[0, 0].add_image(img, name=\"image\", cmap=\"gray\")\n", - "\n", - "# z axis position -1 so it is below all the lines\n", - "fig_line[0, 0][\"image\"].position_z = -1\n", - "fig_line[0, 0][\"image\"].position_x = -50" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b586a89-ca3e-4e88-a801-bdd665384f59", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", - "metadata": {}, - "source": [ - "### 3D line plot" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", - "metadata": {}, - "outputs": [], - "source": [ - "# just set the camera as \"3d\", the rest is basically the same :D \n", - "fig_line_3d = fpl.Figure(cameras='3d')\n", - "\n", - "# create a spiral\n", - "phi = np.linspace(0, 30, 200)\n", - "\n", - "xs = phi * np.cos(phi)\n", - "ys = phi * np.sin(phi)\n", - "zs = phi\n", - "\n", - "# use 3D data\n", - "# note: you usually mix 3D and 2D graphics on the same plot\n", - "spiral = np.dstack([xs, ys, zs])[0]\n", - "\n", - "fig_line_3d[0, 0].add_line(data=spiral, thickness=2, cmap='winter')\n", - "\n", - "fig_line_3d.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28eb7014-4773-4a34-8bfc-bd3a46429012", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_line_3d[0, 0].auto_scale(maintain_aspect=True)" - ] - }, - { - "cell_type": "markdown", - "id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d", - "metadata": {}, - "source": [ - "## Scatter plots\n", - "\n", - "Plot tens of thousands or millions of points\n", - "\n", - "There might be a small delay for a few seconds before the plot shows, this is due to shaders being compiled and a few other things. The plot should be very fast and responsive once it is displayed and future modifications should also be fast!" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "39252df5-9ae5-4132-b97b-2785c5fa92ea", - "metadata": {}, - "outputs": [], - "source": [ - "# create a random distribution\n", - "# only 1,000 points shown here in the docs, but it can be millions\n", - "n_points = 1_000\n", - "\n", - "# if you have a good GPU go for 1.5 million points :D \n", - "# this is multiplied by 3\n", - "#n_points = 500_000\n", - "\n", - "# dimensions always have to be [n_points, xyz]\n", - "dims = (n_points, 3)\n", - "\n", - "clouds_offset = 15\n", - "\n", - "# create some random clouds\n", - "normal = np.random.normal(size=dims, scale=5)\n", - "# stack the data into a single array\n", - "cloud = np.vstack(\n", - " [\n", - " normal - clouds_offset,\n", - " normal,\n", - " normal + clouds_offset,\n", - " ]\n", - ")\n", - "\n", - "# color each of them separately\n", - "colors = [\"yellow\"] * n_points + [\"cyan\"] * n_points + [\"magenta\"] * n_points\n", - "\n", - "# create plot\n", - "fig_scatter = fpl.Figure()\n", - "\n", - "# use an alpha value since this will be a lot of points\n", - "scatter_graphic = fig_scatter[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.7)\n", - "\n", - "fig_scatter.show()" - ] - }, - { - "cell_type": "markdown", - "id": "b6e4a704-ee6b-4316-956e-acb4dcc1c6f2", - "metadata": {}, - "source": [ - "**Scatter graphic features work similarly to line graphic**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8fa46ec0-8680-44f5-894c-559de3145932", - "metadata": {}, - "outputs": [], - "source": [ - "# half of the first cloud's points to red\n", - "scatter_graphic.colors[:n_points:2] = \"r\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "293a4793-44b9-4d18-ae6a-68e7c6f91acc", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_scatter.canvas.snapshot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e4dc71e4-5144-436f-a464-f2a29eee8f0b", - "metadata": {}, - "outputs": [], - "source": [ - "# set the green value directly\n", - "scatter_graphic.colors[n_points:n_points * 2, 1] = 0.3" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5ea7852d-fdae-401b-83b6-b6cfd975f64f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_scatter.canvas.snapshot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd", - "metadata": {}, - "outputs": [], - "source": [ - "# set color values directly using an array\n", - "scatter_graphic.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02c19f51-6436-4601-976e-04326df0de81", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_scatter.canvas.snapshot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1", - "metadata": {}, - "outputs": [], - "source": [ - "# change the data, change y-values\n", - "scatter_graphic.data[n_points:n_points * 2, 1] += 15" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2ec43f58-4710-4603-9358-682c4af3f701", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_scatter.canvas.snapshot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed", - "metadata": {}, - "outputs": [], - "source": [ - "# set x values directly but using an array\n", - "scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6bcb3bc3-4b75-4bbc-b8ca-f8a3219ec3d7", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "fig_scatter.canvas.snapshot()" - ] - }, - { - "cell_type": "markdown", - "id": "d9e554de-c436-4684-a46a-ce8a33d409ac", - "metadata": {}, - "source": [ - "## ipywidget layouts\n", - "\n", - "This just plots everything from these examples in a single output cell" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "01a6f70b-c81b-4ee5-8a6b-d979b87227eb", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# row1 = HBox([plot.show(), plot_v.show(), plot_sync.show()])\n", - "# row2 = HBox([plot_l.show(), plot_l3d.show(), plot_s.show()])\n", - "\n", - "# VBox([row1, row2])" - ] - }, - { - "cell_type": "markdown", - "id": "a26c0063-b7e0-4f36-bb14-db06bafa31aa", - "metadata": {}, - "source": [ - "## More subplots" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6b7e1129-ae8e-4a0f-82dc-bd8fb65871fc", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# Figure of shape 2 x 3 with all controllers synced\n", - "figure_grid = fpl.Figure(shape=(2, 3), controller_ids=\"sync\")\n", - "\n", - "# Make a random image graphic for each subplot\n", - "for subplot in figure_grid:\n", - " # create image data\n", - " data = np.random.rand(512, 512)\n", - " # add an image to the subplot\n", - " subplot.add_image(data, name=\"rand-img\")\n", - "\n", - "# Define a function to update the image graphics with new data\n", - "# add_animations will pass the gridplot to the animation function\n", - "def update_data(f):\n", - " for subplot in f:\n", - " new_data = np.random.rand(512, 512)\n", - " # index the image graphic by name and set the data\n", - " subplot[\"rand-img\"].data = new_data\n", - " \n", - "# add the animation function\n", - "figure_grid.add_animations(update_data)\n", - "\n", - "# show the gridplot \n", - "figure_grid.show()" - ] - }, - { - "cell_type": "markdown", - "id": "f4f71c34-3925-442f-bd76-60dd57d09f48", - "metadata": {}, - "source": [ - "### Slicing GridPlot" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d8194c9e-9a99-4d4a-8984-a4cfcab0c42c", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# positional indexing\n", - "# row 0 and col 0\n", - "figure_grid[0, 0]" - ] - }, - { - "cell_type": "markdown", - "id": "d626640f-bc93-4883-9bf4-47b825bbc663", - "metadata": {}, - "source": [ - "You can get the graphics within a subplot, just like with simple `Plot`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bffec80c-e81b-4945-85a2-c2c5e8395677", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "figure_grid[0, 1].graphics" - ] - }, - { - "cell_type": "markdown", - "id": "a4e3184f-c86a-4a7e-b803-31632cc163b0", - "metadata": {}, - "source": [ - "and change their properties" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "04b616fb-6644-42ba-8683-0589ce7d165e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "figure_grid[0, 1].graphics[0].vmax = 0.5" - ] - }, - { - "cell_type": "markdown", - "id": "28f7362c-d1b9-43ef-85c5-4d68f70f459c", - "metadata": {}, - "source": [ - "more slicing with `GridPlot`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "920e6365-bb50-4882-9b0d-8367dc485360", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# you can give subplots human-readable string names\n", - "figure_grid[0, 2].name = \"top-right-plot\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "73300d2c-3e70-43ad-b5a2-40341b701ac8", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "figure_grid[\"top-right-plot\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "834d9905-35e9-4711-9375-5b1828c80ee2", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# view its position\n", - "figure_grid[\"top-right-plot\"].position" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9aa61efa-c6a5-4611-a03b-1b8da66b19f0", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# these are really the same\n", - "figure_grid[\"top-right-plot\"] is figure_grid[0, 2]" - ] - }, - { - "cell_type": "markdown", - "id": "28c8b145-86cb-4445-92be-b7537a87f7ca", - "metadata": {}, - "source": [ - "Indexing with subplot name and graphic name" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2b7b73a3-5335-4bd5-bbef-c7d3cfbb3ca7", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "figure_grid[\"top-right-plot\"][\"rand-img\"].vmin = 0.5" - ] - }, - { - "cell_type": "markdown", - "id": "6a5b4368-ae4d-442c-a11f-45c70267339b", - "metadata": {}, - "source": [ - "## Figure subplot customization" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "175d45a6-3351-4b75-8ff3-08797fe0a389", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# 2 rows and 3 columns\n", - "shape = (2, 3)\n", - "\n", - "# pan-zoom controllers for each view\n", - "# views are synced if they have the \n", - "# same controller ID\n", - "controller_ids = [\n", - " [0, 3, 1], # id each controller with an integer\n", - " [2, 2, 3]\n", - "]\n", - "\n", - "\n", - "# you can give string names for each subplot within the gridplot\n", - "names = [\n", - " [\"subplot0\", \"subplot1\", \"subplot2\"],\n", - " [\"subplot3\", \"subplot4\", \"subplot5\"]\n", - "]\n", - "\n", - "# Create the grid plot\n", - "figure_grid = fpl.Figure(\n", - " shape=shape,\n", - " controller_ids=controller_ids,\n", - " names=names,\n", - ")\n", - "\n", - "\n", - "# Make a random image graphic for each subplot\n", - "for subplot in figure_grid:\n", - " data = np.random.rand(512, 512)\n", - " # create and add an ImageGraphic\n", - " subplot.add_image(data=data, name=\"rand-image\")\n", - " \n", - "\n", - "# Define a function to update the image graphics \n", - "# with new randomly generated data\n", - "def set_random_frame(gp):\n", - " for subplot in gp:\n", - " new_data = np.random.rand(512, 512)\n", - " subplot[\"rand-image\"].data = new_data\n", - "\n", - "# add the animation\n", - "figure_grid.add_animations(set_random_frame)\n", - "figure_grid.show()" - ] - }, - { - "cell_type": "markdown", - "id": "4224f1c2-5e61-4894-8d72-0519598a3cef", - "metadata": {}, - "source": [ - "Indexing the gridplot to access subplots" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d88dd9b2-9359-42e8-9dfb-96dcbbb34b95", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# can access subplot by name\n", - "figure_grid[\"subplot0\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a14df7ea-14c3-4a8a-84f2-2e2194236d9e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# can access subplot by index\n", - "figure_grid[0, 0]" - ] - }, - { - "cell_type": "markdown", - "id": "5f8a3427-7949-40a4-aec2-38d5d95ef156", - "metadata": {}, - "source": [ - "**subplots also support indexing!**\n", - "\n", - "this can be used to get graphics if they are named" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c99fee0-ce46-4f18-8300-af025c9a967c", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# can access graphic directly via name\n", - "figure_grid[\"subplot0\"][\"rand-image\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ed4eebb7-826d-4856-bbb8-db2de966a0c3", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "figure_grid[\"subplot0\"][\"rand-image\"].vmin = 0.6\n", - "figure_grid[\"subplot0\"][\"rand-image\"].vmax = 0.8" - ] - }, - { - "cell_type": "markdown", - "id": "ad322f6f-e7de-4eb3-a1d9-cf28701a2eae", - "metadata": {}, - "source": [ - "positional indexing also works event if subplots have string names" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "759d3966-d92b-460f-ba48-e57adabbf163", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "figure_grid[1, 0][\"rand-image\"].vim = 0.1\n", - "figure_grid[1, 0][\"rand-image\"].vmax = 0.3" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/desktop/heatmap/heatmap.py b/examples/desktop/heatmap/heatmap.py index fa5ec6715..f3a1bf460 100644 --- a/examples/desktop/heatmap/heatmap.py +++ b/examples/desktop/heatmap/heatmap.py @@ -12,18 +12,14 @@ fig = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 10_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(20_000)]) # plot the image data -heatmap_graphic = fig[0, 0].add_heatmap(data=data, name="heatmap") +img = fig[0, 0].add_image(data=data, name="heatmap") fig.show() diff --git a/examples/desktop/heatmap/heatmap_cmap.py b/examples/desktop/heatmap/heatmap_cmap.py index a1434bb0e..39e697c93 100644 --- a/examples/desktop/heatmap/heatmap_cmap.py +++ b/examples/desktop/heatmap/heatmap_cmap.py @@ -4,7 +4,7 @@ Change the cmap of a heatmap """ -# test_example = true +# test_example = false import fastplotlib as fpl import numpy as np @@ -12,18 +12,14 @@ fig = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 10_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(20_000)]) # plot the image data -heatmap_graphic = fig[0, 0].add_heatmap(data=data, name="heatmap") +img = fig[0, 0].add_image(data=data, name="heatmap") fig.show() @@ -31,7 +27,7 @@ fig[0, 0].auto_scale() -heatmap_graphic.cmap = "viridis" +img.cmap = "viridis" if __name__ == "__main__": print(__doc__) diff --git a/examples/desktop/heatmap/heatmap_data.py b/examples/desktop/heatmap/heatmap_data.py index 67aee1668..75ef3ce41 100644 --- a/examples/desktop/heatmap/heatmap_data.py +++ b/examples/desktop/heatmap/heatmap_data.py @@ -4,7 +4,7 @@ Change the data of a heatmap """ -# test_example = true +# test_example = false import fastplotlib as fpl import numpy as np @@ -12,28 +12,24 @@ fig = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 9_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(9_000)]) # plot the image data -heatmap_graphic = fig[0, 0].add_heatmap(data=data, name="heatmap") +img = fig[0, 0].add_image(data=data, name="heatmap") fig.show() fig.canvas.set_logical_size(1500, 1500) fig[0, 0].auto_scale() +cosine = np.cos(np.sqrt(xs)[:3000]) -heatmap_graphic.data[:5_000] = sine -heatmap_graphic.data[5_000:] = cosine - +# change first 2,000 rows and 3,000 columns +img.data[:2_000, :3_000] = np.vstack([cosine * i * 4 for i in range(2_000)]) if __name__ == "__main__": print(__doc__) diff --git a/examples/desktop/heatmap/heatmap_square.py b/examples/desktop/heatmap/heatmap_square.py new file mode 100644 index 000000000..f776b74e1 --- /dev/null +++ b/examples/desktop/heatmap/heatmap_square.py @@ -0,0 +1,33 @@ +""" +Square Heatmap +============== +square heatmap test +""" + +# test_example = false + +import fastplotlib as fpl +import numpy as np + + +fig = fpl.Figure() + +xs = np.linspace(0, 1_000, 20_000, dtype=np.float32) + +sine = np.sin(np.sqrt(xs)) + +data = np.vstack([sine * i for i in range(20_000)]) + +# plot the image data +img = fig[0, 0].add_image(data=data, name="heatmap") + +del data # data no longer needed after given to graphic +fig.show() + +fig.canvas.set_logical_size(1500, 1500) + +fig[0, 0].auto_scale() + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/heatmap/heatmap_vmin_vmax.py b/examples/desktop/heatmap/heatmap_vmin_vmax.py index 6fe8a08b8..75b6b7b68 100644 --- a/examples/desktop/heatmap/heatmap_vmin_vmax.py +++ b/examples/desktop/heatmap/heatmap_vmin_vmax.py @@ -4,7 +4,7 @@ Change the vmin vmax of a heatmap """ -# test_example = true +# test_example = false import fastplotlib as fpl import numpy as np @@ -12,18 +12,14 @@ fig = fpl.Figure() -xs = np.linspace(0, 1_000, 10_000) +xs = np.linspace(0, 1_000, 10_000, dtype=np.float32) -sine = np.sin(xs) -cosine = np.cos(xs) +sine = np.sin(np.sqrt(xs)) -# alternating sines and cosines -data = np.zeros((10_000, 10_000), dtype=np.float32) -data[::2] = sine -data[1::2] = cosine +data = np.vstack([sine * i for i in range(20_000)]) # plot the image data -heatmap_graphic = fig[0, 0].add_heatmap(data=data, name="heatmap") +img = fig[0, 0].add_image(data=data, name="heatmap") fig.show() @@ -31,8 +27,8 @@ fig[0, 0].auto_scale() -heatmap_graphic.cmap.vmin = -0.5 -heatmap_graphic.cmap.vmax = 0.5 +img.vmin = -5_000 +img.vmax = 10_000 if __name__ == "__main__": print(__doc__) diff --git a/examples/desktop/heatmap/heatmap_wide.py b/examples/desktop/heatmap/heatmap_wide.py new file mode 100644 index 000000000..251c25fa4 --- /dev/null +++ b/examples/desktop/heatmap/heatmap_wide.py @@ -0,0 +1,32 @@ +""" +Wide Heatmap +============ +Wide example +""" + +# test_example = false + +import fastplotlib as fpl +import numpy as np + + +fig = fpl.Figure() + +xs = np.linspace(0, 1_000, 20_000, dtype=np.float32) + +sine = np.sin(np.sqrt(xs)) + +data = np.vstack([sine * i for i in range(10_000)]) + +# plot the image data +img = fig[0, 0].add_image(data=data, name="heatmap") + +fig.show() + +fig.canvas.set_logical_size(1500, 1500) + +fig[0, 0].auto_scale() + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/image/image_rgbvminvmax.py b/examples/desktop/image/image_rgbvminvmax.py index 9725c038a..56114e1e3 100644 --- a/examples/desktop/image/image_rgbvminvmax.py +++ b/examples/desktop/image/image_rgbvminvmax.py @@ -23,8 +23,8 @@ fig[0, 0].auto_scale() -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 if __name__ == "__main__": diff --git a/examples/desktop/image/image_vminvmax.py b/examples/desktop/image/image_vminvmax.py index 3c8607aef..d24d1f18c 100644 --- a/examples/desktop/image/image_vminvmax.py +++ b/examples/desktop/image/image_vminvmax.py @@ -23,8 +23,8 @@ fig[0, 0].auto_scale() -image_graphic.cmap.vmin = 0.5 -image_graphic.cmap.vmax = 0.75 +image_graphic.vmin = 0.5 +image_graphic.vmax = 0.75 if __name__ == "__main__": diff --git a/examples/desktop/line/line_cmap.py b/examples/desktop/line/line_cmap.py index 7d8e1e7d6..f18ceb201 100644 --- a/examples/desktop/line/line_cmap.py +++ b/examples/desktop/line/line_cmap.py @@ -21,21 +21,21 @@ ys = np.cos(xs) - 5 cosine = np.dstack([xs, ys])[0] -# cmap_values from an array, so the colors on the sine line will be based on the sine y-values +# cmap_transform from an array, so the colors on the sine line will be based on the sine y-values sine_graphic = fig[0, 0].add_line( data=sine, thickness=10, cmap="plasma", - cmap_values=sine[:, 1] + cmap_transform=sine[:, 1] ) # qualitative colormaps, useful for cluster labels or other types of categorical labels -cmap_values = [0] * 25 + [5] * 10 + [1] * 35 + [2] * 30 +labels = [0] * 25 + [5] * 10 + [1] * 35 + [2] * 30 cosine_graphic = fig[0, 0].add_line( data=cosine, thickness=10, cmap="tab10", - cmap_values=cmap_values + cmap_transform=labels ) fig.show() diff --git a/examples/desktop/line/line_colorslice.py b/examples/desktop/line/line_colorslice.py index 4df666531..28b877793 100644 --- a/examples/desktop/line/line_colorslice.py +++ b/examples/desktop/line/line_colorslice.py @@ -1,6 +1,6 @@ """ Line Plot -============ +========= Example showing color slicing with cosine, sine, sinc lines. """ @@ -15,25 +15,48 @@ xs = np.linspace(-10, 10, 100) # sine wave ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] +sine = np.column_stack([xs, ys]) # cosine wave -ys = np.cos(xs) + 5 -cosine = np.dstack([xs, ys])[0] +ys = np.cos(xs) +cosine = np.column_stack([xs, ys]) # sinc function a = 0.5 -ys = np.sinc(xs) * 3 + 8 -sinc = np.dstack([xs, ys])[0] +ys = np.sinc(xs) * 3 +sinc = np.column_stack([xs, ys]) -sine_graphic = fig[0, 0].add_line(data=sine, thickness=5, colors="magenta") +sine_graphic = fig[0, 0].add_line( + data=sine, + thickness=5, + colors="magenta" +) # you can also use colormaps for lines! -cosine_graphic = fig[0, 0].add_line(data=cosine, thickness=12, cmap="autumn") +cosine_graphic = fig[0, 0].add_line( + data=cosine, + thickness=12, + cmap="autumn", + offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data +) # or a list of colors for each datapoint colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = fig[0, 0].add_line(data=sinc, thickness=5, colors=colors) +sinc_graphic = fig[0, 0].add_line( + data=sinc, + thickness=5, + colors=colors, + offset=(0, 6, 0) +) + +zeros = np.zeros(xs.size) +zeros_data = np.column_stack([xs, zeros]) +zeros_graphic = fig[0, 0].add_line( + data=zeros_data, + thickness=8, + colors="w", + offset=(0, 10, 0) +) fig.show() @@ -42,10 +65,6 @@ cosine_graphic.colors[90:] = "red" cosine_graphic.colors[60] = "w" -# indexing to assign colormaps to entire lines or segments -sinc_graphic.cmap[10:50] = "gray" -sine_graphic.cmap = "seismic" - # more complex indexing, set the blue value directly from an array cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65) @@ -53,8 +72,14 @@ key = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 67, 19]) sinc_graphic.colors[key] = "Red" -key2 = np.array([True, False, True, False, True, True, True, True]) -cosine_graphic.colors[key2] = "Green" +# boolean fancy indexing +zeros_graphic.colors[xs < -5] = "green" + +# assign colormap to an entire line +sine_graphic.cmap = "seismic" +# or to segments of a line +zeros_graphic.cmap[50:75] = "jet" +zeros_graphic.cmap[75:] = "viridis" fig.canvas.set_logical_size(800, 800) diff --git a/examples/desktop/line/line_dataslice.py b/examples/desktop/line/line_dataslice.py index 12a5f0f04..c2c6b9d36 100644 --- a/examples/desktop/line/line_dataslice.py +++ b/examples/desktop/line/line_dataslice.py @@ -41,9 +41,9 @@ cosine_graphic.data[90:, 1] = 7 cosine_graphic.data[0] = np.array([[-10, 0, 0]]) -# additional fancy indexing using numpy -key2 = np.array([True, False, True, False, True, True, True, True]) -sinc_graphic.data[key2] = np.array([[5, 1, 2]]) +# additional fancy indexing with boolean array +bool_key = [True, True, True, False, False] * 20 +sinc_graphic.data[bool_key, 1] = 7 # y vals to 1 fig.canvas.set_logical_size(800, 800) diff --git a/examples/desktop/line/line_present_scaling.py b/examples/desktop/line/line_present_scaling.py deleted file mode 100644 index d334e6fbd..000000000 --- a/examples/desktop/line/line_present_scaling.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Line Plot -============ -Example showing present and scaling feature for lines. -""" - -# test_example = true - -import fastplotlib as fpl -import numpy as np - - -fig = fpl.Figure() - -xs = np.linspace(-10, 10, 100) -# sine wave -ys = np.sin(xs) -sine = np.dstack([xs, ys])[0] - -# cosine wave -ys = np.cos(xs) + 5 -cosine = np.dstack([xs, ys])[0] - -# sinc function -a = 0.5 -ys = np.sinc(xs) * 3 + 8 -sinc = np.dstack([xs, ys])[0] - -sine_graphic = fig[0, 0].add_line(data=sine, thickness=5, colors="magenta") - -# you can also use colormaps for lines! -cosine_graphic = fig[0, 0].add_line(data=cosine, thickness=12, cmap="autumn") - -# or a list of colors for each datapoint -colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25 -sinc_graphic = fig[0, 0].add_line(data=sinc, thickness=5, colors=colors) - -fig.show() - -sinc_graphic.present = False - -fig.canvas.set_logical_size(800, 800) - -fig[0, 0].auto_scale() - - -if __name__ == "__main__": - print(__doc__) - fpl.run() diff --git a/examples/desktop/line_collection/line_collection.py b/examples/desktop/line_collection/line_collection.py index dd6f3ca33..db99e32ed 100644 --- a/examples/desktop/line_collection/line_collection.py +++ b/examples/desktop/line_collection/line_collection.py @@ -1,6 +1,6 @@ """ -Line Plot -============ +Line collection +=============== Example showing how to plot line collections """ diff --git a/examples/desktop/line_collection/line_collection_cmap_values.py b/examples/desktop/line_collection/line_collection_cmap_values.py index 9eeef40f8..5ffc032e9 100644 --- a/examples/desktop/line_collection/line_collection_cmap_values.py +++ b/examples/desktop/line_collection/line_collection_cmap_values.py @@ -1,7 +1,7 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line collections quantitative cmap +================================== +Example showing a line collection with a quantitative cmap """ # test_example = true @@ -36,10 +36,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: fig = fpl.Figure() fig[0, 0].add_line_collection( - circles, - cmap="bwr", - cmap_values=cmap_values, - thickness=10 + circles, cmap="bwr", cmap_transform=cmap_values, thickness=10 ) fig.show() diff --git a/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py b/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py index 85f0724d8..f96fd3aac 100644 --- a/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py +++ b/examples/desktop/line_collection/line_collection_cmap_values_qualitative.py @@ -1,7 +1,7 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line collections qualitative cmaps +================================== +Example showing a line collection with a qualitative cmap """ # test_example = true @@ -44,7 +44,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: fig[0, 0].add_line_collection( circles, cmap="tab10", - cmap_values=cmap_values, + cmap_transform=cmap_values, thickness=10 ) diff --git a/examples/desktop/line_collection/line_collection_colors.py b/examples/desktop/line_collection/line_collection_colors.py index d53afcd5b..3ee561d8f 100644 --- a/examples/desktop/line_collection/line_collection_colors.py +++ b/examples/desktop/line_collection/line_collection_colors.py @@ -1,7 +1,7 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line collection colors +====================== +Example showing one way ot setting colors for individual lines in a collection """ # test_example = true diff --git a/examples/desktop/line_collection/line_collection_slicing.py b/examples/desktop/line_collection/line_collection_slicing.py new file mode 100644 index 000000000..9eaebdd7e --- /dev/null +++ b/examples/desktop/line_collection/line_collection_slicing.py @@ -0,0 +1,68 @@ +""" +Line collection slicing +======================= +Example showing how to slice a line collection +""" + +# test_example = true + +import numpy as np +import fastplotlib as fpl + + +xs = np.linspace(0, np.pi * 10, 100) +# sine wave +ys = np.sin(xs) + +data = np.column_stack([xs, ys]) +multi_data = np.stack([data] * 15) + + +fig = fpl.Figure() + +lines = fig[0, 0].add_line_stack( + multi_data, + thickness=[2, 10, 2, 5, 5, 5, 8, 8, 8, 9, 3, 3, 3, 4, 4], + separation=1, + metadatas=list(range(15)), # some metadata + names=list("abcdefghijklmno"), # unique name for each line +) + +print("slice a collection to return a collection indexer") +print(lines[1:5]) # lines 1, 2, 3, 4 + +print("collections supports fancy indexing!") +print(lines[::3]) + +print("fancy index using properties of individual lines!") +print(lines[lines.thickness < 3]) +print(lines[lines.metadatas > 10]) + +# set line properties, such as data +# set y-values of lines 3, 4, 5 +lines[3:6].data[:, 1] = np.cos(xs) +# set these same lines to a different color +lines[3:6].colors = "cyan" + +# setting properties using fancy indexing +# set cmap along the line collection +lines[-3:].cmap = "plasma" + +# set cmap of along a single line +lines[7].cmap = "jet" + +# fancy indexing using line properties! +lines[lines.thickness > 8].colors = "r" +lines[lines.names == "a"].colors = "b" + +# fancy index at the level of lines and individual line properties! +lines[::2].colors[::5] = "magenta" # set every 5th point of every other line to magenta +lines[3:6].colors[50:, -1] = 0.6 # set half the points alpha to 0.6 + +fig.show(maintain_aspect=False) + +fig.canvas.set_logical_size(900, 600) + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/line_collection/line_stack.py b/examples/desktop/line_collection/line_stack.py index cf5d933e3..676e6e5c2 100644 --- a/examples/desktop/line_collection/line_stack.py +++ b/examples/desktop/line_collection/line_stack.py @@ -1,7 +1,7 @@ """ -Line Plot -============ -Example showing how to plot line collections +Line stack +========== +Example showing how to plot a stack of lines """ # test_example = true @@ -10,17 +10,21 @@ import fastplotlib as fpl -xs = np.linspace(0, 100, 1000) +xs = np.linspace(0, np.pi * 10, 100) # sine wave -ys = np.sin(xs) * 20 +ys = np.sin(xs) -# make 25 lines -data = np.vstack([ys] * 25) +data = np.column_stack([xs, ys]) +multi_data = np.stack([data] * 10) fig = fpl.Figure() -# line stack takes all the same arguments as line collection and behaves similarly -fig[0, 0].add_line_stack(data, cmap="jet") +line_stack = fig[0, 0].add_line_stack( + multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] + cmap="jet", # applied along n_lines + thickness=5, + separation=1, # spacing between lines along the separation axis, default separation along "y" axis +) fig.show(maintain_aspect=False) diff --git a/examples/desktop/line_collection/line_stack_3d.py b/examples/desktop/line_collection/line_stack_3d.py new file mode 100644 index 000000000..41914e2d2 --- /dev/null +++ b/examples/desktop/line_collection/line_stack_3d.py @@ -0,0 +1,103 @@ +""" +Line stack 3D +============= +Example showing a 3D stack of lines with animations +""" + +# test_example = false + +import numpy as np +import fastplotlib as fpl + + +xs = np.linspace(0, np.pi * 10, 100) +# spiral +ys = np.sin(xs) +zs = np.cos(xs) + +data = np.column_stack([xs, ys, zs]) +multi_data = np.stack([data] * 10) + +# create figure to plot lines and use an orbit controller in 3D +fig = fpl.Figure(cameras="3d", controller_types="orbit") + +line_stack = fig[0, 0].add_line_stack( + multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy] + cmap="jet", # applied along n_lines + thickness=3, + separation=1, # spacing between lines along the separation axis, default separation along "y" axis + name="lines", +) + + +x_increment = 0.1 + + +def animate_data(subplot): + """animate with different rates of spinning the spirals""" + global xs # x vals + global x_increment # increment + + # calculate the new data + # new a different spinning rate for each spiral + # top ones will spin faster than the bottom ones + new_xs = [xs + (factor * x_increment) for factor in np.linspace(0.5, 1.5, 10)] + y = [np.sin(x) for x in new_xs] + z = [np.cos(x) for x in new_xs] + + # iterate through collection and set data of each line + for i, line in enumerate(subplot["lines"]): + # set y and z values + line.data[:, 1:] = np.column_stack([y[i], z[i]]) + + x_increment += 0.1 + + +colors_iteration = 0 + + +def animate_colors(subplot): + """animate the colors""" + global colors_iteration + + # change the colors only on every 50th render cycle + # otherwise it just looks like flickering because it's too fast :) + if colors_iteration % 50 != 0: + colors_iteration += 1 + return + + # use cmap_transform to shift the cmap + cmap_transform = np.roll(np.arange(10), shift=int(colors_iteration / 50)) + + # set cmap with the transform + subplot["lines"].cmap = "jet", cmap_transform + + colors_iteration += 1 + + +fig[0, 0].add_animations(animate_data, animate_colors) + +# just a pre-saved camera state +camera_state = { + "position": np.array([-18.0, 9.0, 8.0]), + "rotation": np.array([0.00401791, -0.5951809, 0.00297593, 0.80357619]), + "scale": np.array([1.0, 1.0, 1.0]), + "reference_up": np.array([0.0, 1.0, 0.0]), + "fov": 50.0, + "width": 32, + "height": 20, + "zoom": 1, + "maintain_aspect": True, + "depth_range": None, +} + +fig.show(maintain_aspect=False) + +fig[0, 0].camera.set_state(camera_state) + +fig.canvas.set_logical_size(500, 500) + + +if __name__ == "__main__": + print(__doc__) + fpl.run() diff --git a/examples/desktop/scatter/scatter_cmap.py b/examples/desktop/scatter/scatter_cmap.py index f1bba98c3..58c43c0ea 100644 --- a/examples/desktop/scatter/scatter_cmap.py +++ b/examples/desktop/scatter/scatter_cmap.py @@ -21,7 +21,11 @@ agg.fit_predict(data) scatter_graphic = fig[0, 0].add_scatter( - data=data[:, :-1], sizes=15, alpha=0.7, cmap="Set1", cmap_values=agg.labels_ + data=data[:, :-1], # use only xy data + sizes=15, + alpha=0.7, + cmap="Set1", + cmap_transform=agg.labels_ # use the labels as a transform to map colors from the colormap ) fig.show() diff --git a/examples/desktop/scatter/scatter_colorslice.py b/examples/desktop/scatter/scatter_colorslice.py index 43f405b06..60433b5f5 100644 --- a/examples/desktop/scatter/scatter_colorslice.py +++ b/examples/desktop/scatter/scatter_colorslice.py @@ -19,7 +19,12 @@ n_points = 50 colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points -scatter_graphic = fig[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) +scatter_graphic = fig[0, 0].add_scatter( + data=data[:, :-1], + sizes=6, + alpha=0.7, + colors=colors # use colors from the list of strings +) fig.show() diff --git a/examples/desktop/scatter/scatter_present.py b/examples/desktop/scatter/scatter_present.py deleted file mode 100644 index 5da4610bd..000000000 --- a/examples/desktop/scatter/scatter_present.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Scatter Plot -============ -Example showing present feature for scatter plot. -""" - -# test_example = true - -import fastplotlib as fpl -import numpy as np -from pathlib import Path - - -fig = fpl.Figure() - -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) - -n_points = 50 -colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points - -scatter_graphic = fig[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) - -colors = ["red"] * n_points + ["white"] * n_points + ["blue"] * n_points -scatter_graphic2 = fig[0, 0].add_scatter(data=data[:, 1:], sizes=6, alpha=0.7, colors=colors) - -fig.show() - -fig.canvas.set_logical_size(800, 800) - -fig[0, 0].auto_scale() - -scatter_graphic.present = False - - -if __name__ == "__main__": - print(__doc__) - fpl.run() diff --git a/examples/desktop/screenshots/gridplot.png b/examples/desktop/screenshots/gridplot.png index ebf2d3a97..9e81fe8c6 100644 --- a/examples/desktop/screenshots/gridplot.png +++ b/examples/desktop/screenshots/gridplot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f972f67b8830657ab14899f749fb385a080280304377d8868e6cd39c766a0afd -size 267084 +oid sha256:462a06e9c74dc9f0958aa265349dfac9c31d77a3ab3915f85596c85f2e7a6f3a +size 266056 diff --git a/examples/desktop/screenshots/gridplot_non_square.png b/examples/desktop/screenshots/gridplot_non_square.png index bc642b729..b74be7065 100644 --- a/examples/desktop/screenshots/gridplot_non_square.png +++ b/examples/desktop/screenshots/gridplot_non_square.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:352bf94c68444a330b000d7b6b3ec51b5b694ff3a0ce810299b325315923d9af -size 175938 +oid sha256:9ab4b1f8188824b81fe29b5c6ac7177734fb2b9958133e19f02919d1da98b96c +size 174978 diff --git a/examples/desktop/screenshots/heatmap.png b/examples/desktop/screenshots/heatmap.png index a8c8b73fe..ec6cf9955 100644 --- a/examples/desktop/screenshots/heatmap.png +++ b/examples/desktop/screenshots/heatmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5620e4dcb964dbf3318ac77e566af395a35b9762e0687dec2e1a2864eb291fd3 -size 102994 +oid sha256:754bd8713617bf61d1adf57b3e84c1257b038bf15412aa3c8bd466d1405086e7 +size 48524 diff --git a/examples/desktop/screenshots/heatmap_cmap.png b/examples/desktop/screenshots/heatmap_cmap.png index cee81dd30..c495cf72c 100644 --- a/examples/desktop/screenshots/heatmap_cmap.png +++ b/examples/desktop/screenshots/heatmap_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8863461569f5b89d1443e3051a5512f3987487fcb9e057215d2f030a180fa09f -size 97996 +oid sha256:d2ba0b76e982ceb1439c5ebaabfaf089ea9b09e50934718eaaa29d7492272196 +size 42746 diff --git a/examples/desktop/screenshots/heatmap_data.png b/examples/desktop/screenshots/heatmap_data.png index 316a73753..229d6c2cc 100644 --- a/examples/desktop/screenshots/heatmap_data.png +++ b/examples/desktop/screenshots/heatmap_data.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a975179e82893dbb04e4674310761e7b02bb62ae6abb1b89397720bddf96ae5f -size 19084 +oid sha256:a7160c4f034214f8052a6d88001dac706b0a85a5a4df076958ba1a176344b85a +size 53854 diff --git a/examples/desktop/screenshots/heatmap_square.png b/examples/desktop/screenshots/heatmap_square.png new file mode 100644 index 000000000..00a01133e --- /dev/null +++ b/examples/desktop/screenshots/heatmap_square.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d01171b2bd05b5c88df4312c303094fdede36b1cf930455ace6d1fb12d8eb36 +size 81274 diff --git a/examples/desktop/screenshots/heatmap_vmin_vmax.png b/examples/desktop/screenshots/heatmap_vmin_vmax.png index 357683d82..b028291f7 100644 --- a/examples/desktop/screenshots/heatmap_vmin_vmax.png +++ b/examples/desktop/screenshots/heatmap_vmin_vmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9592f3724016db1b7431bc100b16bec175e197c111e7b442dc2255d51da3f5e8 -size 114957 +oid sha256:61c3754de3a7e6622ce1a77dbbf9bbd6ccfd3ccad3b1463b009bf93511258034 +size 44426 diff --git a/examples/desktop/screenshots/heatmap_wide.png b/examples/desktop/screenshots/heatmap_wide.png new file mode 100644 index 000000000..927b933d6 --- /dev/null +++ b/examples/desktop/screenshots/heatmap_wide.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:271e0d769b608d0f34a153ab8b8353f1e5d127f239951fc407ccedd3eee5e2e5 +size 82687 diff --git a/examples/desktop/screenshots/image_cmap.png b/examples/desktop/screenshots/image_cmap.png index bbf51ab18..be16ba213 100644 --- a/examples/desktop/screenshots/image_cmap.png +++ b/examples/desktop/screenshots/image_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:555fd969606d0cb231ac152724f7c9717a2220ce22db663c5e7d5793f828ed34 -size 189654 +oid sha256:552a4d5141a5a87baaedd8a9d7d911dfdddee5792c024c77012665268af865e9 +size 189479 diff --git a/examples/desktop/screenshots/image_rgb.png b/examples/desktop/screenshots/image_rgb.png index 9a5082b12..8d391f07c 100644 --- a/examples/desktop/screenshots/image_rgb.png +++ b/examples/desktop/screenshots/image_rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:95f3cae6caf8d64d1a6b4799df52dc61cc05bd6b6ea465edbec06a9678f32435 -size 218089 +oid sha256:55e76cea92eb34e1e25d730d2533a9a0d845921e78bc980708d320bb353a2d73 +size 218413 diff --git a/examples/desktop/screenshots/image_rgbvminvmax.png b/examples/desktop/screenshots/image_rgbvminvmax.png index 00bbdc0c5..eabe85d28 100644 --- a/examples/desktop/screenshots/image_rgbvminvmax.png +++ b/examples/desktop/screenshots/image_rgbvminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fc06b8cdd72040cf2ffc44cde80d5ae21ca392daac25d79fe175b5865b13552 -size 34894 +oid sha256:7ee27d89170b30a3da7fe6d752961b30e17712d7905d8fa0686f9597debe68f9 +size 34620 diff --git a/examples/desktop/screenshots/image_simple.png b/examples/desktop/screenshots/image_simple.png index 94fcd3061..853eb2f01 100644 --- a/examples/desktop/screenshots/image_simple.png +++ b/examples/desktop/screenshots/image_simple.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3dcfb5d48d0e4db920c33ee725e2c66f3c8e04a66e03d283a6481f42a4121a16 -size 190178 +oid sha256:e943bd3b1e00acaed274dd185f5362210e39330e0f541db9ceee489fa0a9a344 +size 189822 diff --git a/examples/desktop/screenshots/image_vminvmax.png b/examples/desktop/screenshots/image_vminvmax.png index 00bbdc0c5..eabe85d28 100644 --- a/examples/desktop/screenshots/image_vminvmax.png +++ b/examples/desktop/screenshots/image_vminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fc06b8cdd72040cf2ffc44cde80d5ae21ca392daac25d79fe175b5865b13552 -size 34894 +oid sha256:7ee27d89170b30a3da7fe6d752961b30e17712d7905d8fa0686f9597debe68f9 +size 34620 diff --git a/examples/desktop/screenshots/line_collection_slicing.png b/examples/desktop/screenshots/line_collection_slicing.png new file mode 100644 index 000000000..ba4170874 --- /dev/null +++ b/examples/desktop/screenshots/line_collection_slicing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01090d611117fd0d2b3f9971e359871c9598a634a1829e74848b1c78a770d437 +size 131764 diff --git a/examples/desktop/screenshots/line_colorslice.png b/examples/desktop/screenshots/line_colorslice.png index 3d04c473f..789265530 100644 --- a/examples/desktop/screenshots/line_colorslice.png +++ b/examples/desktop/screenshots/line_colorslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa941eaf5b940b4eebab89ed836cbd092e16b4758abafa3722c296db65c0c4b5 -size 33233 +oid sha256:25e87f566a667c98b54d4acdf115d16b486e047242b9ce8b141e5724b9d0a46a +size 33191 diff --git a/examples/desktop/screenshots/line_dataslice.png b/examples/desktop/screenshots/line_dataslice.png index 0863751bf..e55a6111e 100644 --- a/examples/desktop/screenshots/line_dataslice.png +++ b/examples/desktop/screenshots/line_dataslice.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78ccd51d1891fb6a345cb2885a341f276d8ad7a6fa506deda6cae6ef14c64094 -size 45843 +oid sha256:e76275ea6f5719e16ff0ef3401dc33fe4b70c4c9010b3b673fca26812f33b9e8 +size 46400 diff --git a/examples/desktop/screenshots/line_present_scaling.png b/examples/desktop/screenshots/line_present_scaling.png deleted file mode 100644 index ba7142106..000000000 --- a/examples/desktop/screenshots/line_present_scaling.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:06f7dd45eb495fecfcf46478c6430a658640ceb2855c4797bc184cf4134571e3 -size 20180 diff --git a/examples/desktop/screenshots/line_stack.png b/examples/desktop/screenshots/line_stack.png index c13f05f04..29d941fd4 100644 --- a/examples/desktop/screenshots/line_stack.png +++ b/examples/desktop/screenshots/line_stack.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5480aefe6e723863b919a4eeb4755310fe7036b27beb8e2e2402e04943ee8c1e -size 201102 +oid sha256:73226917c233f3fd3d7ec0b40d5a7ded904d275c871242dc0578bddf4c19d0bd +size 93687 diff --git a/examples/desktop/screenshots/scatter_cmap.png b/examples/desktop/screenshots/scatter_cmap.png index 87a6e0ded..560f1942d 100644 --- a/examples/desktop/screenshots/scatter_cmap.png +++ b/examples/desktop/screenshots/scatter_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a02d2b5d4735d656d1b754ac3681a7700d961d7e4a43dfaf3a7dd0d4f6516ba6 -size 37808 +oid sha256:9479bb3995bd145a163a2f25592a4c85c52c663d33381efee7743ffc1f16aef1 +size 32894 diff --git a/examples/desktop/screenshots/scatter_present.png b/examples/desktop/screenshots/scatter_present.png deleted file mode 100644 index 08bc610b3..000000000 --- a/examples/desktop/screenshots/scatter_present.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd072918f21ed0ce4ea4e1f4499ec1ff66d867cfdc0ecd6b3ed8092141cd348e -size 14195 diff --git a/examples/notebooks/heatmap.ipynb b/examples/notebooks/heatmap.ipynb index 90c07a3cb..7de3af2a0 100644 --- a/examples/notebooks/heatmap.ipynb +++ b/examples/notebooks/heatmap.ipynb @@ -5,9 +5,7 @@ "id": "d8c90f4b-b635-4027-b7d5-080d77bd40a3", "metadata": {}, "source": [ - "# The `HeatmapGraphic` is useful for looking at very large arrays\n", - "\n", - "`ImageGraphic` is limited to a max size of `8192 x 8192`" + "# Looking at very large arrays" ] }, { @@ -40,13 +38,11 @@ }, "outputs": [], "source": [ - "xs = np.linspace(0, 50, 10_000)\n", - "\n", - "sine_data = np.sin(xs)\n", + "xs = np.linspace(0, 1_000, 20_000)\n", "\n", - "cosine_data = np.cos(xs)\n", + "sine = np.sin(np.sqrt(xs))\n", "\n", - "data = np.vstack([(sine_data, cosine_data) for i in range(5)])" + "data = np.vstack([sine * i for i in range(10_000)])" ] }, { @@ -72,7 +68,7 @@ "source": [ "fig = fpl.Figure()\n", "\n", - "fig[0, 0].add_heatmap(data, cmap=\"viridis\")\n", + "fig[0, 0].add_image(data, cmap=\"viridis\")\n", "\n", "fig.show(maintain_aspect=False)" ] diff --git a/examples/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb index 2ba40ed54..57a72bdec 100644 --- a/examples/notebooks/linear_region_selector.ipynb +++ b/examples/notebooks/linear_region_selector.ipynb @@ -17,7 +17,7 @@ "source": [ "import fastplotlib as fpl\n", "import numpy as np\n", - "from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", + "# from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n", "\n", "fig = fpl.Figure((2, 2))\n", "\n", @@ -25,11 +25,12 @@ "zoomed_prealloc = 1_000\n", "\n", "# data to plot\n", - "xs = np.linspace(0, 100, 1_000)\n", - "sine = np.sin(xs) * 20\n", + "xs = np.linspace(0, 10* np.pi, 1_000)\n", + "sine = np.sin(xs)\n", + "sine += 100\n", "\n", "# make sine along x axis\n", - "sine_graphic_x = fig[0, 0].add_line(sine)\n", + "sine_graphic_x = fig[0, 0].add_line(np.column_stack([xs, sine]), offset=(10, 0, 0))\n", "\n", "# just something that looks different for line along y-axis\n", "sine_y = sine\n", @@ -47,7 +48,7 @@ "ls_y = sine_graphic_y.add_linear_region_selector(axis=\"y\")\n", "\n", "# preallocate array for storing zoomed in data\n", - "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.random.rand(zoomed_prealloc)])\n", + "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)])\n", "\n", "# make line graphics for displaying zoomed data\n", "zoomed_x = fig[1, 0].add_line(zoomed_init)\n", @@ -62,62 +63,54 @@ " # interpolate to preallocated size\n", " return np.interp(x, xp, fp=subdata[:, axis]) # use the y-values\n", "\n", - "\n", + "@ls_x.add_event_handler(\"selection\")\n", "def set_zoom_x(ev):\n", " \"\"\"sets zoomed x selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_x.data = interpolate(selected_data, axis=1) # use the y-values\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_x.data[:, 1] = 0\n", + "\n", + " # set the y-values\n", + " zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)\n", " fig[1, 0].auto_scale()\n", "\n", "\n", "def set_zoom_y(ev):\n", - " \"\"\"sets zoomed y selector data\"\"\"\n", - " selected_data = ev.pick_info[\"selected_data\"]\n", - " zoomed_y.data = -interpolate(selected_data, axis=0) # use the x-values\n", + " \"\"\"sets zoomed x selector data\"\"\"\n", + " # get the selected data\n", + " selected_data = ev.get_selected_data()\n", + " if selected_data.size == 0:\n", + " # no data selected\n", + " zoomed_y.data[:, 0] = 0\n", + "\n", + " # set the x-values\n", + " zoomed_y.data[:, 0] = -interpolate(selected_data, axis=1)\n", " fig[1, 1].auto_scale()\n", "\n", "\n", - "# update zoomed plots when bounds change\n", - "ls_x.selection.add_event_handler(set_zoom_x)\n", - "ls_y.selection.add_event_handler(set_zoom_y)\n", - "\n", - "fig.show()" - ] - }, - { - "cell_type": "markdown", - "id": "0bad4a35-f860-4f85-9061-920154ab682b", - "metadata": {}, - "source": [ - "### On the x-axis we have a 1-1 mapping from the data that we have passed and the line geometry positions. So the `bounds` min max corresponds directly to the data indices." + "fig.show(maintain_aspect=False)" ] }, { "cell_type": "code", "execution_count": null, - "id": "2c96a3ff-c2e7-4683-8097-8491e97dd6d3", + "id": "2f29e913-c4f8-44a6-8692-eb14436849a5", "metadata": {}, "outputs": [], "source": [ - "ls_x.selection()" + "sine_graphic_x.data[:, 1].ptp()" ] }, { "cell_type": "code", "execution_count": null, - "id": "3ec71e3f-291c-43c6-a954-0a082ba5981c", + "id": "1947a477-5dd2-4df9-aecd-6967c6ab45fe", "metadata": {}, "outputs": [], "source": [ - "ls_x.get_selected_indices()" - ] - }, - { - "cell_type": "markdown", - "id": "1588a89e-1da4-4ada-92e2-7437ba942065", - "metadata": {}, - "source": [ - "### However, for the y-axis line we have passed a 2D array where we've used a linspace, so there is not a 1-1 mapping from the data to the line geometry positions. Use `get_selected_indices()` to get the indices of the data bounded by the current selection. In addition the position of the Graphic is not `(0, 0)`. You must use `get_selected_indices()` whenever you want the indices of the selected data." + "np.clip(-0.1, 0, 10)" ] }, { @@ -127,7 +120,7 @@ "metadata": {}, "outputs": [], "source": [ - "ls_y.selection()" + "ls_y.selection" ] }, { @@ -173,17 +166,18 @@ " subplot.add_line(zoomed_init, name=\"zoomed\")\n", "\n", "\n", + "@selector.add_event_handler(\"selection\")\n", "def update_zoomed_subplots(ev):\n", " \"\"\"update the zoomed subplots\"\"\"\n", - " zoomed_data = selector.get_selected_data()\n", + " zoomed_data = ev.get_selected_data()\n", " \n", " for i in range(len(zoomed_data)):\n", + " # interpolate y-vals\n", " data = interpolate(zoomed_data[i], axis=1)\n", - " fig_stack[i + 1, 0][\"zoomed\"].data = data\n", + " fig_stack[i + 1, 0][\"zoomed\"].data[:, 1] = data\n", " fig_stack[i + 1, 0].auto_scale()\n", "\n", "\n", - "selector.selection.add_event_handler(update_zoomed_subplots)\n", "fig_stack.show()" ] }, @@ -195,50 +189,6 @@ "# Large line stack with selector" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "d5ffb678-c989-49ee-85a9-4fd7822f033c", - "metadata": {}, - "outputs": [], - "source": [ - "import fastplotlib as fpl\n", - "import numpy as np\n", - "\n", - "# data to plot\n", - "xs = np.linspace(0, 250, 10_000)\n", - "sine = np.sin(xs) * 20\n", - "cosine = np.cos(xs) * 20\n", - "\n", - "fig_stack_large = fpl.Figure((1, 2))\n", - "\n", - "# sines and cosines\n", - "sines = [sine] * 1_00\n", - "cosines = [cosine] * 1_00\n", - "\n", - "# make line stack\n", - "line_stack = fig_stack_large[0, 0].add_line_stack(sines + cosines, separation=50)\n", - "\n", - "# make selector\n", - "stack_selector = line_stack.add_linear_region_selector(padding=200)\n", - "\n", - "zoomed_line_stack = fig_stack_large[0, 1].add_line_stack([zoomed_init] * 2_000, separation=50, name=\"zoomed\")\n", - " \n", - "def update_zoomed_stack(ev):\n", - " \"\"\"update the zoomed subplots\"\"\"\n", - " zoomed_data = stack_selector.get_selected_data()\n", - " \n", - " for i in range(len(zoomed_data)):\n", - " data = interpolate(zoomed_data[i], axis=1)\n", - " zoomed_line_stack.graphics[i].data = data\n", - " \n", - " fig_stack_large[0, 1].auto_scale()\n", - "\n", - "\n", - "stack_selector.selection.add_event_handler(update_zoomed_stack)\n", - "fig_stack_large.show()" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index e9c8e664a..bac8df182 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -5,7 +5,7 @@ "id": "a06e1fd9-47df-42a3-a76c-19e23d7b89fd", "metadata": {}, "source": [ - "## `LinearSelector`, draggable selector that can optionally associated with an ipywidget." + "## `LinearSelector`, draggable selector that can also be linked to an ipywidget slider" ] }, { @@ -16,7 +16,6 @@ "outputs": [], "source": [ "import fastplotlib as fpl\n", - "from fastplotlib.graphics.selectors import Synchronizer\n", "\n", "import numpy as np\n", "from ipywidgets import VBox, IntSlider, FloatSlider\n", @@ -35,16 +34,14 @@ "selector2 = sine_graphic.add_linear_selector(20)\n", "selector3 = sine_graphic.add_linear_selector(40)\n", "\n", - "ss = Synchronizer(selector, selector2, selector3)\n", - "\n", + "# one of the selectors will change the line colors when it moves\n", + "@selector.add_event_handler(\"selection\")\n", "def set_color_at_index(ev):\n", " # changes the color at the index where the slider is\n", - " ix = ev.pick_info[\"selected_index\"]\n", - " g = ev.pick_info[\"graphic\"].parent\n", + " ix = ev.get_selected_index()\n", + " g = ev.graphic.parent\n", " g.colors[ix] = \"green\"\n", "\n", - "selector.selection.add_event_handler(set_color_at_index)\n", - "\n", "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", "ipywidget_slider = selector.make_ipywidget_slider()\n", "ipywidget_slider.description = \"slider1\"\n", @@ -57,7 +54,15 @@ "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n", "\n", "fig[0, 0].auto_scale()\n", - "fig.show(add_widgets=[ipywidget_slider])" + "VBox([fig.show(), ipywidget_slider, ipywidget_slider2, ipywidget_slider3])" + ] + }, + { + "cell_type": "markdown", + "id": "d83caca6-e9b6-45df-b93c-0dfe0498d20e", + "metadata": {}, + "source": [ + "Double click the first selctor, and then use `Shift` + Right/Left Arrow Key to move it!" ] }, { @@ -67,13 +72,16 @@ "metadata": {}, "outputs": [], "source": [ + "# this controls the step-size of arrow key movements\n", "selector.step = 0.1" ] }, { "cell_type": "markdown", "id": "3b0f448f-bbe4-4b87-98e3-093f561c216c", - "metadata": {}, + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, "source": [ "### Drag linear selectors with the mouse, hold \"Shift\" to synchronize movement of all the selectors" ] @@ -105,8 +113,6 @@ "for i, c in enumerate(colors):\n", " sel = sine_stack.add_linear_selector(i * 100, color=c, name=str(i))\n", " selectors.append(sel)\n", - " \n", - "ss = Synchronizer(*selectors)\n", "\n", "fig.show()" ] diff --git a/examples/notebooks/lines_cmap.ipynb b/examples/notebooks/lines_cmap.ipynb index dbcbb3e16..3ceb25326 100644 --- a/examples/notebooks/lines_cmap.ipynb +++ b/examples/notebooks/lines_cmap.ipynb @@ -39,11 +39,11 @@ "xs = np.linspace(-10, 10, 100)\n", "# sine wave\n", "ys = np.sin(xs)\n", - "sine = np.dstack([xs, ys])[0]\n", + "sine = np.column_stack([xs, ys])\n", "\n", "# cosine wave\n", "ys = np.cos(xs)\n", - "cosine = np.dstack([xs, ys])[0]" + "cosine = np.column_stack([xs, ys])" ] }, { @@ -107,6 +107,14 @@ "plot_test(\"lines-cmap-jet\", fig)" ] }, + { + "cell_type": "markdown", + "id": "13c1c034-2b3b-4568-b979-7c0bbea698ae", + "metadata": {}, + "source": [ + "map colors from sine data values by setting the cmap transform" + ] + }, { "cell_type": "code", "execution_count": null, @@ -116,7 +124,7 @@ }, "outputs": [], "source": [ - "fig[0, 0].graphics[0].cmap.values = sine[:, 1]" + "fig[0, 0].graphics[0].cmap.transform = sine[:, 1]" ] }, { @@ -141,7 +149,8 @@ }, "outputs": [], "source": [ - "fig[0, 0].graphics[0].cmap.values = cosine[:, 1]" + "# set transform from cosine\n", + "fig[0, 0].graphics[0].cmap.transform = cosine[:, 1]" ] }, { @@ -166,6 +175,7 @@ }, "outputs": [], "source": [ + "# change cmap\n", "fig[0, 0].graphics[0].cmap = \"viridis\"" ] }, @@ -182,6 +192,14 @@ "plot_test(\"lines-cmap-viridis\", fig)" ] }, + { + "cell_type": "markdown", + "id": "1f52bfdc-8151-4bab-973c-1bac36011802", + "metadata": {}, + "source": [ + "use cmap transform to map for a qualitative transform" + ] + }, { "cell_type": "code", "execution_count": null, @@ -191,7 +209,7 @@ }, "outputs": [], "source": [ - "cmap_values = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20" + "cmap_transform = [0] * 25 + [1] * 5 + [2] * 50 + [3] * 20" ] }, { @@ -203,7 +221,7 @@ }, "outputs": [], "source": [ - "fig[0, 0].graphics[0].cmap.values = cmap_values" + "fig[0, 0].graphics[0].cmap.transform = cmap_transform" ] }, { diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb index 9bfd822ab..5c5040418 100644 --- a/examples/notebooks/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -156,7 +156,9 @@ }, "outputs": [], "source": [ - "image_graphic.data().shape" + "# some graphic properties behave like arrays\n", + "# access the underlying array using .values\n", + "image_graphic.data.value.shape" ] }, { @@ -209,8 +211,8 @@ }, "outputs": [], "source": [ - "image_graphic.cmap.vmin = 50\n", - "image_graphic.cmap.vmax = 150" + "image_graphic.vmin = 50\n", + "image_graphic.vmax = 150" ] }, { @@ -301,7 +303,7 @@ }, "outputs": [], "source": [ - "image_graphic.cmap.reset_vmin_vmax()" + "image_graphic.reset_vmin_vmax()" ] }, { @@ -500,7 +502,7 @@ }, "outputs": [], "source": [ - "fig_rgb[0, 0][\"rgb-image\"].cmap.vmin = 100" + "fig_rgb[0, 0][\"rgb-image\"].vmin = 100" ] }, { @@ -893,72 +895,6 @@ "plot_test(\"lines-data\", fig_lines)" ] }, - { - "cell_type": "markdown", - "id": "3f6d264b-1b03-407e-9d83-cd6cfb02e706", - "metadata": {}, - "source": [ - "### Toggle the presence of a graphic within the scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = False" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = True" - ] - }, - { - "cell_type": "markdown", - "id": "86f4e535-ce88-415a-b8d2-53612a2de7b9", - "metadata": {}, - "source": [ - "### You can create callbacks to this too, for example to re-scale the plot w.r.t. graphics that are present in the scene" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "64a20a16-75a5-4772-a849-630ade9be4ff", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present.add_event_handler(subplot.auto_scale)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = False" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", - "metadata": {}, - "outputs": [], - "source": [ - "sinc_graphic.present = True" - ] - }, { "cell_type": "markdown", "id": "05f93e93-283b-45d8-ab31-8d15a7671dd2", @@ -978,12 +914,13 @@ "source": [ "img = iio.imread(\"imageio:camera.png\")\n", "\n", - "subplot.add_image(img[::20, ::20], name=\"image\", cmap=\"gray\")\n", + "subplot.add_image(\n", + " img[::20, ::20],\n", + " name=\"image\",\n", + " cmap=\"gray\",\n", + ")\n", "\n", - "# z axis position -1 so it is below all the lines\n", - "subplot[\"image\"].position_z = -1\n", - "subplot[\"image\"].position_x = -8\n", - "subplot[\"image\"].position_y = -8" + "subplot[\"image\"].offset = (-12, -10, -1)" ] }, { @@ -1282,11 +1219,8 @@ "magnetic_vectors = [np.array([[0, 0, z], [0, y, z]]) for (y, z) in zip(m_ys[::10], zs[::10])]\n", "\n", "# add as a line collection\n", - "fig_em[0, 0].add_line_collection(electric_vectors, colors=\"blue\", thickness=1.5, name=\"e-vec\", z_offset=0)\n", - "fig_em[0, 0].add_line_collection(magnetic_vectors, colors=\"red\", thickness=1.5, name=\"m-vec\", z_offset=0)\n", - "# note that the z_offset in `add_line_collection` is not data-related\n", - "# it is the z-offset for where to place the *graphic*, by default with Orthographic cameras (i.e. 2D views)\n", - "# it will increment by 1 for each line in the collection, we want to disable this so set z_position=0\n", + "fig_em[0, 0].add_line_collection(electric_vectors, colors=\"blue\", thickness=1.5, name=\"e-vec\")\n", + "fig_em[0, 0].add_line_collection(magnetic_vectors, colors=\"red\", thickness=1.5, name=\"m-vec\")\n", "\n", "# axes are a WIP, just draw a white line along z for now\n", "z_axis = np.array([[0, 0, 0], [0, 0, stop]])\n", @@ -1537,8 +1471,8 @@ "source": [ "def update_points(subplot):\n", " # move every point by a small amount\n", - " deltas = np.random.normal(size=scatter_graphic.data().shape, loc=0, scale=0.15)\n", - " scatter_graphic.data = scatter_graphic.data() + deltas \n", + " deltas = np.random.normal(size=scatter_graphic.data.value.shape, loc=0, scale=0.15)\n", + " scatter_graphic.data = scatter_graphic.data[:] + deltas\n", "\n", "subplot_scatter.add_animations(update_points)" ] @@ -2048,7 +1982,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.2" + "version": "3.11.3" } }, "nbformat": 4, diff --git a/examples/notebooks/scatter_sizes_animation.ipynb b/examples/notebooks/scatter_sizes_animation.ipynb index 9ca067bee..0cd301fb1 100644 --- a/examples/notebooks/scatter_sizes_animation.ipynb +++ b/examples/notebooks/scatter_sizes_animation.ipynb @@ -17,16 +17,17 @@ "size_delta_scales = np.array([10, 40, 100], dtype=np.float32)\n", "min_sizes = 6\n", "\n", + "\n", "def update_positions(subplot):\n", - " current_time = time()\n", - " newPositions = points + np.sin(((current_time / 4) % 1)*np.pi)\n", - " subplot.graphics[0].data = newPositions\n", + " g = subplot.graphics[0]\n", + " g.data[:, :-1] += np.sin(((time() / 4))*np.pi)\n", + "\n", "\n", "def update_sizes(subplot):\n", - " current_time = time()\n", - " sin_sample = np.sin(((current_time / 4) % 1)*np.pi)\n", - " size_delta = sin_sample*size_delta_scales\n", - " subplot.graphics[0].sizes = min_sizes + size_delta\n", + " sin_sample = np.abs(np.sin((time() / 1)*np.pi))\n", + " size_delta = sin_sample * size_delta_scales\n", + " subplot.graphics[0].sizes = size_delta\n", + "\n", "\n", "scatter = fig[0, 0].add_scatter(points, colors=[\"red\", \"green\", \"blue\"], sizes=12)\n", "fig[0, 0].add_animations(update_positions, update_sizes)\n", @@ -34,13 +35,6 @@ "fig[0, 0].camera.width = 12\n", "fig.show(autoscale=False)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/examples/notebooks/screenshots/nb-astronaut.png b/examples/notebooks/screenshots/nb-astronaut.png index 378260288..3e55979ee 100644 --- a/examples/notebooks/screenshots/nb-astronaut.png +++ b/examples/notebooks/screenshots/nb-astronaut.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e584533ea04b9758634ba62dceeb72991861c509d01dc082436c54c272686409 -size 112104 +oid sha256:a0fdb5b319347b4db4611dcf92cf08359c938f42a64b05d0dd163e0ca289e3c3 +size 112299 diff --git a/examples/notebooks/screenshots/nb-astronaut_RGB.png b/examples/notebooks/screenshots/nb-astronaut_RGB.png index bf11bf667..fbb514e3e 100644 --- a/examples/notebooks/screenshots/nb-astronaut_RGB.png +++ b/examples/notebooks/screenshots/nb-astronaut_RGB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:db9602a610f258803d74ac03cd46447dd5a7ad62241ec26a4c3df30c1d6de299 -size 110408 +oid sha256:2d312ce9097114bc32886c0370861bcf7deebfb4fda99e03817ebec2226eabdc +size 110338 diff --git a/examples/notebooks/screenshots/nb-camera.png b/examples/notebooks/screenshots/nb-camera.png index 9db4005bc..5629bd211 100644 --- a/examples/notebooks/screenshots/nb-camera.png +++ b/examples/notebooks/screenshots/nb-camera.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bb9080b99c2717e093bf6ae4986bf0689a8d377e137a7022c9c6929b9a335d3 -size 77965 +oid sha256:0a415917cc16f09ab7b78eea5e5579d7dd45b6d92e80d87ba0970e9dd0568eb2 +size 77419 diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png b/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png index 5be8f55a3..486c89963 100644 --- a/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png +++ b/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66a310e312add59a310ff0a50335db97ac557d7f2967d8251a7d811c25a4de28 -size 40517 +oid sha256:3dbb4d04175c5603ff7e56a04438c8f0cfff7deff61889a06c342cedc04ac323 +size 43172 diff --git a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png index b8bf7adeb..02423a02a 100644 --- a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png +++ b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dbfa1e7aeb7f0a068a33f2f11023a06f834332f7b3d8e4cf97b51222536fd6cb -size 434782 +oid sha256:d7384d1a69629cfcdbebdc9e9e6a152383446f3cb696e69a11543253cdde2e64 +size 434060 diff --git a/examples/notebooks/screenshots/nb-image-widget-single.png b/examples/notebooks/screenshots/nb-image-widget-single.png index 86119e247..408739d6e 100644 --- a/examples/notebooks/screenshots/nb-image-widget-single.png +++ b/examples/notebooks/screenshots/nb-image-widget-single.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ceee2cdd73092cb84b4b0f2876fc08d838b8a47bb94d431a6c19c8a4793a153 -size 403521 +oid sha256:b57dffe179b6f52d204968085c885d70183d1f6a9a3f5a1dc2d005229b7acd01 +size 404179 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png index 82cee281f..596548486 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47e3e6cea0e738b2731060488886606f05595cfdfb0d81e6db1aa099dc8e3a84 -size 148181 +oid sha256:da1c660e4fb779ac6a4baed3d329cf80274981288ea076f243bb43aee7fb8eff +size 157731 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png index 0b7832eee..1318be413 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0af4ceb50ed269aa80667c3012a871c87f73777cd8cb497ebb243b53932b9bad -size 72377 +oid sha256:0d4f6407c3b029b01088fab9522a21f7d90d7a87def1ecbbb45f0fb4f8508f87 +size 69106 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png index 2bc2db3a5..e5fdbdd28 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9053c70da35fd42fe44a76e0ace8788ba79667b33c596409ca1e1f2f6d6ba3ad -size 195906 +oid sha256:e4176109805f4f521a1b630b68df1dce80a63b82a5ed01a6ba4c2cae0dfeb6bd +size 184423 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png index d5999dd0f..bf9548962 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77d4a8542a5507e3eda1203a6da29a2f533bbbe2988ad297948c74e44a4337ec -size 177152 +oid sha256:12df56b1045cdaddb94355b7e960aa58137a44eff4ff22aab3596c53ea7944c8 +size 179403 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png index 29af0398d..7b3e6bfba 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3422923039d45b20ea6150f0ad545bdf876596ba60b156df5ec4004590a29a3e -size 139029 +oid sha256:d5fd2f0918f4a29769ebb572f5163abb52667cf24e89efdd1d99bc57a0f5f607 +size 140124 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png index bb07b8fbb..a72245f3b 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba9e055298372238ce0cd0c5ac4d75db8cd53f3f4acffbcc22bf7d503b40ec57 -size 79174 +oid sha256:981c925f52ae8789f7f0d24ef3fe34efb7a08f221a7bc6079dd12f01099c3d25 +size 75054 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png index 6e8274659..19c19dc1f 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c6aed15f9f1b6bae442687613c5b04621f42e78f1dbda1e3560b000d652ba0b3 -size 61523 +oid sha256:6a5ecd1f966250ead16a96df996ff39877b4ee28534b7724a4a8e1db9c8984d2 +size 58334 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png index 28704bd2d..bcf663279 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:499fab9183f2528297fdfa3c96a25eb58b2376a44556d790ef06928e0379af3a -size 174612 +oid sha256:dbe1375ae8d2f348ad5d7e030fa67d45c250c6ed263c67179d542d0bd903e0d3 +size 177334 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png index d163fd22a..963290515 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72832b86f802ee90b4eb54cb64d64aff59527fe0c7dcb87a4d8ab281ad15726b -size 142136 +oid sha256:0ff341df374d816411e58c82d432e98a9f4cec1551725dafcd65cdb0c43edb12 +size 138235 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png index 52ebd8591..a049a484c 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b414fbb8f6935901b65851de9c7cb37df628d7b24759dc7f407ee130389216a3 -size 371687 +oid sha256:bd4c51f7e07e46d7c660d28563aff1b7d3759387fc10db10addca29dfc0919b0 +size 365838 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png index 6c406a621..ada15017c 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e0ef52156509f308d972533fb45509ba7451b4d6149400d519aae28274609e41 -size 212053 +oid sha256:1d13cc8a32b40f5c721ab30020632d7dc1c679c8c8e5857476176e986de18ad3 +size 211240 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png index aaed804b8..2e71fd30d 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8fd94e597074094dc3718153c8bb94fb0b1bf58e58e34601e5c7281f938f52bd -size 200278 +oid sha256:39044f4bb54038eee17f0d75940fd848b304629858d9e747ac0c81ce076d3c25 +size 199075 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png index 3110fa7cf..690b1c578 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76ab21314fbd7846c1b94aeeed9ef7b97be99d2f2f7f09c13c0474a889c21179 -size 159319 +oid sha256:109a4c8d708114e1b36d1a9fa129dbd9a768617baa953f37500310c0968b688a +size 154169 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png index 0cfad54e7..3e577698c 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77f247de5374a8cb7ce27a338ab8be880d53d9586b52f646b400901ba70be3aa -size 146217 +oid sha256:568ae05e8889cec56530db0149613826f2697f55d8252cffbd32ff692b565fcf +size 141338 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png index c74807939..1ab48d117 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33c3dfa77bbc558493634ab83fd1539ef76022a2e20d10e1353d2bd0a0e94a2c -size 183739 +oid sha256:e7847dc083d6df2b20788b42153674c557b627b75db74d9446b06e165aa5a50a +size 182713 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png index a2841b1d5..0b0f05fc3 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:715f7909db0d374c2e618bb41f7a6341a8cc8891b1e3e8678a6f934fd71159a4 -size 127129 +oid sha256:839dd3fdc9db98d7044d88e816c179bff34f30584ba26ce7a96ea3b35fc3374e +size 122463 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png index 9064f2323..534403b1e 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76708e8e6e6865d700aa5286fca4d58ba4eb91f21ab3b0243bb128e9a84f063c -size 131192 +oid sha256:f7088e11517a4a78492d16746ac8101b2e5e9142ebd61966030c555ab173443e +size 126267 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png index 1fbaec974..94993c688 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51c62474b9ebee76242ef82a710a83b90e0183792790f6a2cd00213642b76755 -size 99519 +oid sha256:709c7ec07e3e37e0415fad3fa542729d2896e8e546b6ea8d1373e7b46005bc26 +size 97278 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png index 5e0750ac8..27c693c1a 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f8f74a0a5fa24e10a88d3723836306913243fa5fc23f46f44bbdae4c0209075 -size 58878 +oid sha256:c66db583e0d455319b665d35a8c5c8a5f717653c11311cdaba90e2c85e64235f +size 58941 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png index 8df83fe33..7444d7dbf 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0809b2dda0e773b7f100386f97144c40d36d51cd935c86ef1dcd4a938fce3981 -size 56319 +oid sha256:36867cd793634d00c46c782911abae6e7c579067aeeed891e40ddedbb0c228d9 +size 56505 diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png index 5bbefc7ae..3941f3120 100644 --- a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png +++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2e2e2cf7ac6be1a4fccec54494c3fd48af673765653675438fa2469c549e90c -size 55055 +oid sha256:8b23f3655fcfcd85f6138f4de5cedf228a6dbad0c8cff0c87900042f83b8f409 +size 55269 diff --git a/examples/notebooks/screenshots/nb-lines-underlay.png b/examples/notebooks/screenshots/nb-lines-underlay.png index d4b3d9f6d..e7f6aeb0c 100644 --- a/examples/notebooks/screenshots/nb-lines-underlay.png +++ b/examples/notebooks/screenshots/nb-lines-underlay.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b12c8f29436be8d17c38f420120ab3d54b0eee9bef751eea2f99d01b1a8fa43 -size 50761 +oid sha256:8ed174c362c7e7c491eba02b32102f59423af41537577c694fdcd54d69c065b3 +size 50422 diff --git a/examples/notebooks/subplots.ipynb b/examples/notebooks/subplots.ipynb index 72b4b3007..c9774029f 100644 --- a/examples/notebooks/subplots.ipynb +++ b/examples/notebooks/subplots.ipynb @@ -136,39 +136,51 @@ "metadata": {}, "outputs": [], "source": [ - "fig[\"subplot0\"][\"rand-image\"].cmap.vmin = 0.6\n", - "fig[\"subplot0\"][\"rand-image\"].cmap.vmax = 0.8" + "fig[\"subplot0\"][\"rand-image\"].vmin = 0.6\n", + "fig[\"subplot0\"][\"rand-image\"].vmax = 0.8" ] }, { "cell_type": "markdown", + "id": "39c8a5acbad7980b", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "If they are not named use .graphics" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", "execution_count": null, + "id": "d27af25002237db5", + "metadata": { + "collapsed": false, + "is_executing": true, + "jupyter": { + "outputs_hidden": false + } + }, "outputs": [], "source": [ "fig[\"subplot0\"].graphics" - ], - "metadata": { - "collapsed": false, - "is_executing": true - } + ] }, { "cell_type": "markdown", + "id": "2299a8ae23e39c37", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, "source": [ "### positional indexing also works" - ], - "metadata": { - "collapsed": false - } + ] }, { "cell_type": "code", @@ -177,17 +189,9 @@ "metadata": {}, "outputs": [], "source": [ - "fig[1, 0][\"rand-image\"].cmap.vim = 0.1\n", - "fig[1, 0][\"rand-image\"].cmap.vmax = 0.3" + "fig[1, 0][\"rand-image\"].vim = 0.1\n", + "fig[1, 0][\"rand-image\"].vmax = 0.3" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a61e34a5-ee1b-4abb-8718-ec4715ffaa52", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/examples/notebooks/subplots_simple.ipynb b/examples/notebooks/subplots_simple.ipynb index e519584d3..9ff4e4284 100644 --- a/examples/notebooks/subplots_simple.ipynb +++ b/examples/notebooks/subplots_simple.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "5171a06e-1bdc-4908-9726-3c1fd45dbb9d", "metadata": { "ExecuteTime": { @@ -19,40 +19,7 @@ }, "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "301d76bd4c5c42c7912cdd28651e2899", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Image(value=b'\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x01,\\x00\\x00\\x007\\x08\\x06\\x00\\x00\\x00\\xb6\\x1bw\\x99\\x…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Unable to find extension: VK_EXT_swapchain_colorspace\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Available devices:\n", - "✅ (default) | AMD RADV POLARIS10 (ACO) | DiscreteGPU | Vulkan | Mesa 20.3.5 (ACO)\n", - "❗ | llvmpipe (LLVM 11.0.1, 256 bits) | CPU | Vulkan | Mesa 20.3.5 (LLVM 11.0.1)\n", - "✅ | NVIDIA GeForce RTX 3080 | DiscreteGPU | Vulkan | 530.30.02\n", - "❗ | Radeon RX 570 Series (POLARIS10, DRM 3.40.0, 5.10.0-21-amd64, LLVM 11.0.1) | Unknown | OpenGL | \n" - ] - } - ], + "outputs": [], "source": [ "import numpy as np\n", "import fastplotlib as fpl" @@ -165,7 +132,7 @@ }, "outputs": [], "source": [ - "fig[0, 1].graphics[0].cmap.vmax = 0.5" + "fig[0, 1].graphics[0].vmax = 0.5" ] }, { @@ -244,7 +211,7 @@ }, "outputs": [], "source": [ - "fig[\"top-right-plot\"][\"rand-img\"].cmap.vmin = 0.5" + "fig[\"top-right-plot\"][\"rand-img\"].vmin = 0.5" ] }, { diff --git a/examples/notebooks/test_gc.ipynb b/examples/notebooks/test_gc.ipynb index 39f964cf7..57d7bb576 100644 --- a/examples/notebooks/test_gc.ipynb +++ b/examples/notebooks/test_gc.ipynb @@ -93,12 +93,11 @@ "\n", "\n", "for g in objects:\n", - " for feature in g.feature_events:\n", - " if isinstance(g, fpl.LineCollection):\n", - " continue # skip collections for now\n", + " for feature in g._features:\n", + " # if isinstance(g, fpl.LineCollection):?\n", + " # continue # skip collections for now\n", " \n", - " f = getattr(g, feature)\n", - " f.add_event_handler(feature_changed_handler)\n", + " g.add_event_handler(feature_changed_handler, feature)\n", "\n", "fig.show()" ] @@ -136,9 +135,8 @@ "\n", "# add some events onto all the image graphics\n", "for g in iw.managed_graphics:\n", - " for f in g.feature_events:\n", - " fea = getattr(g, f)\n", - " fea.add_event_handler(feature_changed_handler)\n", + " for f in g._features:\n", + " g.add_event_handler(feature_changed_handler, f)\n", "\n", "iw.show()" ] @@ -174,6 +172,14 @@ "source": [ "test_references(old_graphics)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "712bb6ea-7244-4e03-8dfa-9419daa34915", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -192,7 +198,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.11.2" } }, "nbformat": 4, diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION index d9958b371..0ea3a944b 100644 --- a/fastplotlib/VERSION +++ b/fastplotlib/VERSION @@ -1 +1 @@ -0.1.0.a16 +0.2.0 diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 2a008015e..ff96baa4c 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -1,15 +1,15 @@ from .line import LineGraphic from .scatter import ScatterGraphic -from .image import ImageGraphic, HeatmapGraphic +from .image import ImageGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack + __all__ = [ + "LineGraphic", "ImageGraphic", "ScatterGraphic", - "LineGraphic", - "HeatmapGraphic", + "TextGraphic", "LineCollection", "LineStack", - "TextGraphic", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 3a5b043f5..cab941894 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,22 +1,28 @@ +from collections import defaultdict +from functools import partial from typing import Any, Literal, TypeAlias import weakref -from warnings import warn -from abc import ABC, abstractmethod -from dataclasses import dataclass import numpy as np import pylinalg as la +from wgpu.gui.base import log_exception -from pygfx import WorldObject - -from ._features import GraphicFeature, PresentFeature, GraphicFeatureIndexable, Deleted +import pygfx +from ._features import ( + BufferManager, + Deleted, + Name, + Offset, + Rotation, + Visible, +) HexStr: TypeAlias = str # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects -WORLD_OBJECTS: dict[HexStr, WorldObject] = dict() #: {hex id str: WorldObject} +WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict() #: {hex id str: WorldObject} PYGFX_EVENTS = [ @@ -35,9 +41,11 @@ ] -class BaseGraphic: +class Graphic: + _features = {} + def __init_subclass__(cls, **kwargs): - """set the type of the graphic in lower case like "image", "line_collection", etc.""" + # set the type of the graphic in lower case like "image", "line_collection", etc. cls.type = ( cls.__name__.lower() .replace("graphic", "") @@ -45,22 +53,24 @@ def __init_subclass__(cls, **kwargs): .replace("stack", "_stack") ) + # set of all features + cls._features = { + *cls._features, + "name", + "offset", + "rotation", + "visible", + "deleted", + } super().__init_subclass__(**kwargs) - -class Graphic(BaseGraphic): - feature_events = {} - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - # all graphics give off a feature event when deleted - cls.feature_events = {*cls.feature_events, "deleted"} - def __init__( self, name: str = None, + offset: np.ndarray | list | tuple = (0.0, 0.0, 0.0), + rotation: np.ndarray | list | tuple = (0.0, 0.0, 0.0, 1.0), + visible: bool = True, metadata: Any = None, - collection_index: int = None, ): """ @@ -69,6 +79,12 @@ def __init__( name: str, optional name this graphic to use it as a key to access from the plot + offset: (float, float, float), default (0., 0., 0.) + (x, y, z) vector to offset this graphic from the origin + + rotation: (float, float, float, float), default (0, 0, 0, 1) + rotation quaternion + metadata: Any, optional metadata attached to this Graphic, this is for the user to manage @@ -76,116 +92,254 @@ def __init__( if (name is not None) and (not isinstance(name, str)): raise TypeError("Graphic `name` must be of type ") - self._name = name self.metadata = metadata - self.collection_index = collection_index self.registered_callbacks = dict() - self.present = PresentFeature(parent=self) # store hex id str of Graphic instance mem location self._fpl_address: HexStr = hex(id(self)) - self.deleted = Deleted(self, False) - self._plot_area = None + # event handlers + self._event_handlers = defaultdict(set) + + # maps callbacks to their partials + self._event_handler_wrappers = defaultdict(set) + + # all the common features + self._name = Name(name) + self._deleted = Deleted(False) + self._rotation = Rotation(rotation) + self._offset = Offset(offset) + self._visible = Visible(visible) + self._block_events = False + + @property + def supported_events(self) -> tuple[str]: + """events supported by this graphic""" + return (*tuple(self._features), *PYGFX_EVENTS) + @property def name(self) -> str | None: - """str name reference for this item""" - return self._name + """Graphic name""" + return self._name.value @name.setter - def name(self, name: str): - if self.name == name: - return + def name(self, value: str): + self._name.set_value(self, value) + + @property + def offset(self) -> np.ndarray: + """Offset position of the graphic, array: [x, y, z]""" + return self._offset.value + + @offset.setter + def offset(self, value: np.ndarray | list | tuple): + self._offset.set_value(self, value) + + @property + def rotation(self) -> np.ndarray: + """Orientation of the graphic as a quaternion""" + return self._rotation.value + + @rotation.setter + def rotation(self, value: np.ndarray | list | tuple): + self._rotation.set_value(self, value) + + @property + def visible(self) -> bool: + """Whether the graphic is visible""" + return self._visible.value + + @visible.setter + def visible(self, value: bool): + self._visible.set_value(self, value) + + @property + def deleted(self) -> bool: + """used to emit an event after the graphic is deleted""" + return self._deleted.value - if not isinstance(name, str): - raise TypeError("`Graphic` name must be of type ") + @deleted.setter + def deleted(self, value: bool): + self._deleted.set_value(self, value) - if self._plot_area is not None: - self._plot_area._check_graphic_name_exists(name) + @property + def block_events(self) -> bool: + """Used to block events for a graphic and prevent recursion.""" + return self._block_events - self._name = name + @block_events.setter + def block_events(self, value: bool): + self._block_events = value @property - def world_object(self) -> WorldObject: + def world_object(self) -> pygfx.WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" # We use weakref to simplify garbage collection return weakref.proxy(WORLD_OBJECTS[self._fpl_address]) - def _set_world_object(self, wo: WorldObject): + def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo - @property - def position(self) -> np.ndarray: - """position of the graphic, [x, y, z]""" - return self.world_object.world.position + self.world_object.visible = self.visible - @property - def position_x(self) -> float: - """x-axis position of the graphic""" - return self.world_object.world.x + # set offset if it's not (0., 0., 0.) + if not all(self.world_object.world.position == self.offset): + self.offset = self.offset - @property - def position_y(self) -> float: - """y-axis position of the graphic""" - return self.world_object.world.y + # set rotation if it's not (0., 0., 0., 1.) + if not all(self.world_object.world.rotation == self.rotation): + self.rotation = self.rotation + + def unshare_property(self, feature: str): + raise NotImplementedError + + def share_property(self, feature: BufferManager): + raise NotImplementedError @property - def position_z(self) -> float: - """z-axis position of the graphic""" - return self.world_object.world.z + def event_handlers(self) -> list[tuple[str, callable, ...]]: + """ + Registered event handlers. Read-only use ``add_event_handler()`` + and ``remove_event_handler()`` to manage callbacks + """ + return list(self._event_handlers.items()) - @position.setter - def position(self, val): - self.world_object.world.position = val + def add_event_handler(self, *args): + """ + Register an event handler. - @position_x.setter - def position_x(self, val): - self.world_object.world.x = val + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" - @position_y.setter - def position_y(self, val): - self.world_object.world.y = val + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html - @position_z.setter - def position_z(self, val): - self.world_object.world.z = val + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented - @property - def rotation(self): - return self.world_object.local.rotation + Can also be used as a decorator. - @rotation.setter - def rotation(self, val): - self.world_object.local.rotation = val + Example + ------- - @property - def visible(self) -> bool: - """Access or change the visibility.""" - return self.world_object.visible + .. code-block:: py - @visible.setter - def visible(self, v: bool): - """Access or change the visibility.""" - self.world_object.visible = v + def my_handler(event): + print(event) - @property - def children(self) -> list[WorldObject]: - """Return the children of the WorldObject.""" - return self.world_object.children + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + callback = None if decorating else args[0] + types = args if decorating else args[1:] - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, GraphicFeature): - attr._set(value) - return + unsupported_events = [t for t in types if t not in self.supported_events] - super().__setattr__(key, value) + if len(unsupported_events) > 0: + raise TypeError( + f"unsupported events passed: {unsupported_events} for {self.__class__.__name__}\n" + f"`graphic.events` will return a tuple of supported events" + ) + + def decorator(_callback): + _callback_wrapper = partial( + self._handle_event, _callback + ) # adds graphic instance as attribute and other things + + for t in types: + # add to our record + self._event_handlers[t].add(_callback) + + if t in self._features: + # fpl feature event + feature = getattr(self, f"_{t}") + feature.add_event_handler(_callback_wrapper) + else: + # wrap pygfx event + self.world_object._event_handlers[t].add(_callback_wrapper) + + # keep track of the partial too + self._event_handler_wrappers[t].add((_callback, _callback_wrapper)) + return _callback + + if decorating: + return decorator + + return decorator(callback) + + def clear_event_handlers(self): + """clear all event handlers added to this graphic""" + for ev, handlers in self.event_handlers: + handlers = list(handlers) + for h in handlers: + self.remove_event_handler(h, ev) + + def _handle_event(self, callback, event: pygfx.Event): + """Wrap pygfx event to add graphic to pick_info""" + event.graphic = self + + if self.block_events: + return + + if event.type in self._features: + # for feature events + event._target = self.world_object + + if isinstance(event, pygfx.PointerEvent): + # map from screen to world space and data space + world_xy = self._plot_area.map_screen_to_world(event) + + # subtract offset to map to data + data_xy = world_xy - self.offset + + # append attributes + event.x_world, event.y_world = world_xy[:2] + event.x_data, event.y_data = data_xy[:2] + + with log_exception(f"Error during handling {event.type} event"): + callback(event) + + def remove_event_handler(self, callback, *types): + # remove from our record first + for t in types: + for wrapper_map in self._event_handler_wrappers[t]: + # TODO: not sure if we can handle this mapping in a better way + if wrapper_map[0] == callback: + wrapper = wrapper_map[1] + self._event_handler_wrappers[t].remove(wrapper_map) + break + else: + raise KeyError( + f"event type: {t} with callback: {callback} is not registered" + ) + + self._event_handlers[t].remove(callback) + # remove callback wrapper from world object if pygfx event + if t in PYGFX_EVENTS: + print("pygfx event") + print(wrapper) + self.world_object.remove_event_handler(wrapper, t) + else: + feature = getattr(self, f"_{t}") + feature.remove_event_handler(wrapper) + + def _fpl_add_plot_area_hook(self, plot_area): + self._plot_area = plot_area def __repr__(self): rval = f"{self.__class__.__name__} @ {hex(id(self))}" @@ -211,6 +365,9 @@ def _fpl_cleanup(self): Optionally implemented in subclasses """ + # remove event handlers + self.clear_event_handlers() + # clear any attached event handlers and animation functions for attr in dir(self): try: @@ -237,9 +394,8 @@ def _fpl_cleanup(self): self.world_object._event_handlers.clear() - feature_names = getattr(self, "feature_events") - for n in feature_names: - fea = getattr(self, n) + for n in self._features: + fea = getattr(self, f"_{n}") fea.clear_event_handlers() def __del__(self): @@ -267,443 +423,3 @@ def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): f"`axis` must be either `x`, `y`, or `z`. `{axis}` provided instead!" ) self.rotation = la.quat_mul(rot, self.rotation) - - -class Interaction(ABC): - """Mixin class that makes graphics interactive""" - - @abstractmethod - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass - - @abstractmethod - def reset_feature(self, feature: str): - pass - - def link( - self, - event_type: str, - target: Any, - feature: str, - new_data: Any, - callback: callable = None, - bidirectional: bool = False, - ): - """ - Link this graphic to another graphic upon an ``event_type`` to change the ``feature`` - of a ``target`` graphic. - - Parameters - ---------- - event_type: str - can be a pygfx event ("key_down", "key_up","pointer_down", "pointer_move", "pointer_up", - "pointer_enter", "pointer_leave", "click", "double_click", "wheel", "close", "resize") - or appropriate feature event (ex. colors, data, etc.) associated with the graphic (can use - ``graphic_instance.feature_events`` to get a tuple of the valid feature events for the - graphic) - - target: Any - graphic to be linked to - - feature: str - feature (ex. colors, data, etc.) of the target graphic that will change following - the event - - new_data: Any - appropriate data that will be changed in the feature of the target graphic after - the event occurs - - callback: callable, optional - user-specified callable that will handle event, - the callable must take the following four arguments - | ''source'' - this graphic instance - | ''target'' - the graphic to be changed following the event - | ''event'' - the ''pygfx event'' or ''feature event'' that occurs - | ''new_data'' - the appropriate data of the ''target'' that will be changed - - bidirectional: bool, default False - if True, the target graphic is also linked back to this graphic instance using the - same arguments - - For example: - .. code-block::python - - Returns - ------- - None - - """ - if event_type in PYGFX_EVENTS: - self.world_object.add_event_handler(self._event_handler, event_type) - - # make sure event is valid - elif event_type in self.feature_events: - if isinstance(self, GraphicCollection): - feature_instance = getattr(self[:], event_type) - else: - feature_instance = getattr(self, event_type) - - feature_instance.add_event_handler(self._event_handler) - - else: - raise ValueError( - f"Invalid event, valid events are: {PYGFX_EVENTS + self.feature_events}" - ) - - # make sure target feature is valid - if feature is not None: - if feature not in target.feature_events: - raise ValueError( - f"Invalid feature for target, valid features are: {target.feature_events}" - ) - - if event_type not in self.registered_callbacks.keys(): - self.registered_callbacks[event_type] = list() - - callback_data = CallbackData( - target=target, - feature=feature, - new_data=new_data, - callback_function=callback, - ) - - for existing_callback_data in self.registered_callbacks[event_type]: - if existing_callback_data == callback_data: - warn( - "linkage already exists for given event, target, and data, skipping" - ) - return - - self.registered_callbacks[event_type].append(callback_data) - - if bidirectional: - if event_type in PYGFX_EVENTS: - warn("cannot use bidirectional link for pygfx events") - return - - target.link( - event_type=event_type, - target=self, - feature=feature, - new_data=new_data, - callback=callback, - bidirectional=False, # else infinite recursion, otherwise target will call - # this instance .link(), and then it will happen again etc. - ) - - def _event_handler(self, event): - """Handles the event after it occurs when two graphic have been linked together.""" - if event.type in self.registered_callbacks.keys(): - for target_info in self.registered_callbacks[event.type]: - if target_info.callback_function is not None: - # if callback_function is not None, then callback function should handle the entire event - target_info.callback_function( - source=self, - target=target_info.target, - event=event, - new_data=target_info.new_data, - ) - - elif isinstance(self, GraphicCollection): - # if target is a GraphicCollection, then indices will be stored in collection_index - if event.type in self.feature_events: - indices = event.pick_info["collection-index"] - - # for now we only have line collections so this works - else: - # get index of world object that made this event - for i, item in enumerate(self.graphics): - wo = WORLD_OBJECTS[item._fpl_address] - # we only store hex id of worldobject, but worldobject `pick_info` is always the real object - # so if pygfx worldobject triggers an event by itself, such as `click`, etc., this will be - # the real world object in the pick_info and not the proxy - if wo is event.pick_info["world_object"]: - indices = i - target_info.target.set_feature( - feature=target_info.feature, - new_data=target_info.new_data, - indices=indices, - ) - else: - # if target is a single graphic, then indices do not matter - target_info.target.set_feature( - feature=target_info.feature, - new_data=target_info.new_data, - indices=None, - ) - - -@dataclass -class CallbackData: - """Class for keeping track of the info necessary for interactivity after event occurs.""" - - target: Any - feature: str - new_data: Any - callback_function: callable = None - - def __eq__(self, other): - if not isinstance(other, CallbackData): - raise TypeError("Can only compare against other types") - - if other.target is not self.target: - return False - - if not other.feature == self.feature: - return False - - if not other.new_data == self.new_data: - return False - - if (self.callback_function is None) and (other.callback_function is None): - return True - - if other.callback_function is self.callback_function: - return True - - else: - return False - - -@dataclass -class PreviouslyModifiedData: - """Class for keeping track of previously modified data at indices""" - - data: Any - indices: Any - - -# Dict that holds all collection graphics in one python instance -COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() - - -class GraphicCollection(Graphic): - """Graphic Collection base class""" - - def __init__(self, name: str = None): - super().__init__(name) - self._graphics: list[str] = list() - - self._graphics_changed: bool = True - self._graphics_array: np.ndarray[Graphic] = None - - @property - def graphics(self) -> np.ndarray[Graphic]: - """The Graphics within this collection. Always returns a proxy to the Graphics.""" - if self._graphics_changed: - proxies = [ - weakref.proxy(COLLECTION_GRAPHICS[addr]) for addr in self._graphics - ] - self._graphics_array = np.array(proxies) - self._graphics_array.flags["WRITEABLE"] = False - self._graphics_changed = False - - return self._graphics_array - - def add_graphic(self, graphic: Graphic, reset_index: False): - """ - Add a graphic to the collection. - - Parameters - ---------- - graphic: Graphic - graphic to add, must be a real ``Graphic`` not a proxy - - reset_index: bool, default ``False`` - reset the collection index - - """ - - if not type(graphic).__name__ == self.child_type: - raise TypeError( - f"Can only add graphics of the same type to a collection, " - f"You can only add {self.child_type} to a {self.__class__.__name__}, " - f"you are trying to add a {graphic.__class__.__name__}." - ) - - addr = graphic._fpl_address - COLLECTION_GRAPHICS[addr] = graphic - - self._graphics.append(addr) - - if reset_index: - self._reset_index() - elif graphic.collection_index is None: - graphic.collection_index = len(self) - - self.world_object.add(graphic.world_object) - - self._graphics_changed = True - - def remove_graphic(self, graphic: Graphic, reset_index: True): - """ - Remove a graphic from the collection. - - Parameters - ---------- - graphic: Graphic - graphic to remove - - reset_index: bool, default ``False`` - reset the collection index - - """ - - self._graphics.remove(graphic._fpl_address) - - if reset_index: - self._reset_index() - - self.world_object.remove(graphic.world_object) - - self._graphics_changed = True - - def __getitem__(self, key): - return CollectionIndexer( - parent=self, - selection=self.graphics[key], - ) - - def __del__(self): - self.world_object.clear() - - for addr in self._graphics: - del COLLECTION_GRAPHICS[addr] - - super().__del__() - - def _reset_index(self): - for new_index, graphic in enumerate(self._graphics): - graphic.collection_index = new_index - - def __len__(self): - return len(self._graphics) - - def __repr__(self): - rval = super().__repr__() - return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" - - -class CollectionIndexer: - """Collection Indexer""" - - def __init__( - self, - parent: GraphicCollection, - selection: list[Graphic], - ): - """ - - Parameters - ---------- - parent: GraphicCollection - the GraphicCollection object that is being indexed - - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - """ - - self._parent = weakref.proxy(parent) - self._selection = selection - - # we use parent.graphics[0] instead of selection[0] - # because the selection can be empty - for attr_name in self._parent.graphics[0].__dict__.keys(): - attr = getattr(self._parent.graphics[0], attr_name) - if isinstance(attr, GraphicFeature): - collection_feature = CollectionFeature( - self._selection, feature=attr_name - ) - collection_feature.__doc__ = ( - f"indexable <{attr_name}> feature for collection" - ) - setattr(self, attr_name, collection_feature) - - @property - def graphics(self) -> np.ndarray[Graphic]: - """Returns an array of the selected graphics. Always returns a proxy to the Graphic""" - return tuple(self._selection) - - def __setattr__(self, key, value): - if hasattr(self, key): - attr = getattr(self, key) - if isinstance(attr, CollectionFeature): - attr._set(value) - return - - super().__setattr__(key, value) - - def __len__(self): - return len(self._selection) - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" - ) - - -class CollectionFeature: - """Collection Feature""" - - def __init__(self, selection: list[Graphic], feature: str): - """ - selection: list of Graphics - a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` - - feature: str - feature of Graphics in the GraphicCollection being indexed - - """ - - self._selection = selection - self._feature = feature - - self._feature_instances: list[GraphicFeature] = list() - - if len(self._selection) > 0: - for graphic in self._selection: - fi = getattr(graphic, self._feature) - self._feature_instances.append(fi) - - if isinstance(fi, GraphicFeatureIndexable): - self._indexable = True - else: - self._indexable = False - else: # it's an empty selection so it doesn't really matter - self._indexable = False - - def _set(self, value): - self[:] = value - - def __getitem__(self, item): - # only for indexable graphic features - return [fi[item] for fi in self._feature_instances] - - def __setitem__(self, key, value): - if self._indexable: - for fi in self._feature_instances: - fi[key] = value - - else: - for fi in self._feature_instances: - fi._set(value) - - def add_event_handler(self, handler: callable): - """Adds an event handler to each of the selected Graphics from the parent GraphicCollection""" - for fi in self._feature_instances: - fi.add_event_handler(handler) - - def remove_event_handler(self, handler: callable): - """Removes an event handler from each of the selected Graphics of the parent GraphicCollection""" - for fi in self._feature_instances: - fi.remove_event_handler(handler) - - def block_events(self, b: bool): - """Blocks event handling from occurring.""" - for fi in self._feature_instances: - fi.block_events(b) - - def __repr__(self): - return f"Collection feature for: <{self._feature}>" diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py new file mode 100644 index 000000000..2805c684d --- /dev/null +++ b/fastplotlib/graphics/_collection_base.py @@ -0,0 +1,388 @@ +from typing import Any +import weakref + +import numpy as np + +from ._base import HexStr, Graphic + +# Dict that holds all collection graphics in one python instance +COLLECTION_GRAPHICS: dict[HexStr, Graphic] = dict() + + +class CollectionProperties: + """ + Properties common to all Graphic Collections + + Allows getting and setting the common properties of the individual graphics in the collection + """ + + def _set_feature(self, feature, values): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self, values): + setattr(g, feature, v) + + @property + def names(self) -> np.ndarray[str | None]: + """get or set the name of the individual graphics in the collection""" + return np.asarray([g.name for g in self]) + + @names.setter + def names(self, values: np.ndarray[str] | list[str]): + self._set_feature("name", values) + + @property + def metadatas(self) -> np.ndarray[str | None]: + """get or set the metadata of the individual graphics in the collection""" + return np.asarray([g.metadata for g in self]) + + @metadatas.setter + def metadatas(self, values: np.ndarray[str] | list[str]): + self._set_feature("metadata", values) + + @property + def offsets(self) -> np.ndarray: + """get or set the offset of the individual graphics in the collection""" + return np.stack([g.offset for g in self]) + + @offsets.setter + def offsets(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("offset", values) + + @property + def rotations(self) -> np.ndarray: + """get or set the rotation of the individual graphics in the collection""" + return np.stack([g.rotation for g in self]) + + @rotations.setter + def rotations(self, values: np.ndarray | list[np.ndarray]): + self._set_feature("rotation", values) + + # TODO: how to work with deleted feature in a collection + + @property + def visibles(self) -> np.ndarray[bool]: + """get or set the offsets of the individual graphics in the collection""" + return np.asarray([g.visible for g in self]) + + @visibles.setter + def visibles(self, values: np.ndarray[bool] | list[bool]): + self._set_feature("visible", values) + + +class CollectionIndexer(CollectionProperties): + """Collection Indexer""" + + def __init__(self, selection: np.ndarray[Graphic], features: set[str]): + """ + + Parameters + ---------- + + selection: np.ndarray of Graphics + array of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + + """ + + if isinstance(selection, Graphic): + selection = np.asarray([selection]) + + self._selection = selection + self.features = features + + @property + def graphics(self) -> np.ndarray[Graphic]: + """Returns an array of the selected graphics""" + return tuple(self._selection) + + def add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + decorating = not callable(args[0]) + types = args if decorating else args[1:] + + if decorating: + + def decorator(_callback): + for g in self: + g.add_event_handler(_callback, *types) + return _callback + + return decorator + + for g in self: + g.add_event_handler(*args) + + def remove_event_handler(self, callback, *types): + for g in self: + g.remove_event_handler(callback, *types) + + def clear_event_handlers(self): + for g in self: + g.clear_event_handlers() + + def __getitem__(self, item): + return self.graphics[item] + + def __len__(self): + return len(self._selection) + + def __iter__(self): + self._iter = iter(range(len(self))) + return self + + def __next__(self) -> Graphic: + index = next(self._iter) + + return self.graphics[index] + + def __repr__(self): + return ( + f"{self.__class__.__name__} @ {hex(id(self))}\n" + f"Selection of <{len(self._selection)}> {self._selection[0].__class__.__name__}" + ) + + +class GraphicCollection(Graphic, CollectionProperties): + """Graphic Collection base class""" + + _child_type: type + _indexer: type + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._features = cls._child_type._features + + def __init__(self, name: str = None, metadata: Any = None, **kwargs): + super().__init__(name=name, metadata=metadata, **kwargs) + + # list of mem locations of the graphics + self._graphics: list[str] = list() + + self._graphics_changed: bool = True + self._graphics_array: np.ndarray[Graphic] = None + + self._iter = None + + @property + def graphics(self) -> np.ndarray[Graphic]: + """The Graphics within this collection. Always returns a proxy to the Graphics.""" + if self._graphics_changed: + proxies = [ + weakref.proxy(COLLECTION_GRAPHICS[addr]) for addr in self._graphics + ] + self._graphics_array = np.array(proxies) + self._graphics_array.flags["WRITEABLE"] = False + self._graphics_changed = False + + return self._graphics_array + + def add_graphic(self, graphic: Graphic): + """ + Add a graphic to the collection. + + Parameters + ---------- + graphic: Graphic + graphic to add, must be a real ``Graphic`` not a proxy + + """ + + if not type(graphic) == self._child_type: + raise TypeError( + f"Can only add graphics of the same type to a collection.\n" + f"You can only add {self._child_type.__name__} to a {self.__class__.__name__}, " + f"you are trying to add a {graphic.__class__.__name__}." + ) + + addr = graphic._fpl_address + COLLECTION_GRAPHICS[addr] = graphic + + self._graphics.append(addr) + + self.world_object.add(graphic.world_object) + + self._graphics_changed = True + + def remove_graphic(self, graphic: Graphic): + """ + Remove a graphic from the collection. + + Note: Only removes the graphic from the collection. Does not remove + the graphic from the scene, and does not delete the graphic. + + Parameters + ---------- + graphic: Graphic + graphic to remove + + """ + + self._graphics.remove(graphic._fpl_address) + + self.world_object.remove(graphic.world_object) + + self._graphics_changed = True + + def add_event_handler(self, *args): + """ + Register an event handler. + + Parameters + ---------- + callback: callable, the first argument + Event handler, must accept a single event argument + *types: list of strings + A list of event types, ex: "click", "data", "colors", "pointer_down" + + For the available renderer event types, see + https://jupyter-rfb.readthedocs.io/en/stable/events.html + + All feature support events, i.e. ``graphic.features`` will give a set of + all features that are evented + + Can also be used as a decorator. + + Example + ------- + + .. code-block:: py + + def my_handler(event): + print(event) + + graphic.add_event_handler(my_handler, "pointer_up", "pointer_down") + + Decorator usage example: + + .. code-block:: py + + @graphic.add_event_handler("click") + def my_handler(event): + print(event) + """ + + return self[:].add_event_handler(*args) + + def remove_event_handler(self, callback, *types): + """remove an event handler""" + self[:].remove_event_handler(callback, *types) + + def clear_event_handlers(self): + self[:].clear_event_handlers() + + def _fpl_add_plot_area_hook(self, plot_area): + super()._fpl_add_plot_area_hook(plot_area) + + for g in self: + g._fpl_add_plot_area_hook(plot_area) + + def _fpl_cleanup(self): + """ + Cleans up the graphic in preparation for __del__(), such as removing event handlers from + plot renderer, feature event handlers, etc. + + Optionally implemented in subclasses + """ + # clear any attached event handlers and animation functions + self.world_object._event_handlers.clear() + + for g in self: + g._fpl_cleanup() + + def __getitem__(self, key) -> CollectionIndexer: + if np.issubdtype(type(key), np.integer): + addr = self._graphics[key] + return weakref.proxy(COLLECTION_GRAPHICS[addr]) + + return self._indexer(selection=self.graphics[key], features=self._features) + + def __del__(self): + self.world_object.clear() + + for addr in self._graphics: + del COLLECTION_GRAPHICS[addr] + + super().__del__() + + def __len__(self): + return len(self._graphics) + + def __iter__(self): + self._iter = iter(range(len(self))) + return self + + def __next__(self) -> Graphic: + index = next(self._iter) + addr = self._graphics[index] + + return weakref.proxy(COLLECTION_GRAPHICS[addr]) + + def __repr__(self): + rval = super().__repr__() + return f"{rval}\nCollection of <{len(self._graphics)}> Graphics" + + +class CollectionFeature: + """Collection Feature""" + + def __init__(self, selection: np.ndarray[Graphic], feature: str): + """ + selection: list of Graphics + a list of the selected Graphics from the parent GraphicCollection based on the ``selection_indices`` + + feature: str + feature of Graphics in the GraphicCollection being indexed + + """ + + self._selection = selection + self._feature = feature + + self._feature_instances = [getattr(g, feature) for g in self._selection] + + def __getitem__(self, item): + return np.stack([fi[item] for fi in self._feature_instances]) + + def __setitem__(self, key, value): + for fi in self._feature_instances: + fi[key] = value + + def __repr__(self): + return f"Collection feature for: <{self._feature}>" diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py index fb25db287..e36de089e 100644 --- a/fastplotlib/graphics/_features/__init__.py +++ b/fastplotlib/graphics/_features/__init__.py @@ -1,33 +1,64 @@ -from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature -from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature -from ._sizes import PointsSizesFeature -from ._present import PresentFeature -from ._thickness import ThicknessFeature +from ._positions_graphics import ( + VertexColors, + UniformColor, + UniformSize, + Thickness, + VertexPositions, + PointsSizesFeature, + VertexCmap, +) +from ._image import ( + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, + WGPU_MAX_TEXTURE_SIZE, +) from ._base import ( GraphicFeature, - GraphicFeatureIndexable, + BufferManager, FeatureEvent, to_gpu_supported_dtype, ) + +from ._text import ( + TextData, + FontSize, + TextFaceColor, + TextOutlineColor, + TextOutlineThickness, +) + from ._selection_features import LinearSelectionFeature, LinearRegionSelectionFeature -from ._deleted import Deleted +from ._common import Name, Offset, Rotation, Visible, Deleted + __all__ = [ - "ColorFeature", - "CmapFeature", - "ImageCmapFeature", - "HeatmapCmapFeature", - "PointsDataFeature", + "VertexColors", + "UniformColor", + "UniformSize", + "Thickness", + "VertexPositions", "PointsSizesFeature", - "ImageDataFeature", - "HeatmapDataFeature", - "PresentFeature", - "ThicknessFeature", - "GraphicFeature", - "GraphicFeatureIndexable", - "FeatureEvent", - "to_gpu_supported_dtype", + "VertexCmap", + "TextureArray", + "ImageCmap", + "ImageVmin", + "ImageVmax", + "ImageInterpolation", + "ImageCmapInterpolation", + "TextData", + "FontSize", + "TextFaceColor", + "TextOutlineColor", + "TextOutlineThickness", "LinearSelectionFeature", "LinearRegionSelectionFeature", + "Name", + "Offset", + "Rotation", + "Visible", "Deleted", ] diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py index 99ebbf436..1b24d3b78 100644 --- a/fastplotlib/graphics/_features/_base.py +++ b/fastplotlib/graphics/_features/_base.py @@ -1,14 +1,17 @@ -from abc import ABC, abstractmethod -from inspect import getfullargspec from warnings import warn -from typing import * -import weakref +from typing import Any, Literal import numpy as np +from numpy.typing import NDArray + +from wgpu.gui.base import log_exception import pygfx +WGPU_MAX_TEXTURE_SIZE = 8192 + + supported_dtypes = [ np.uint8, np.uint16, @@ -41,64 +44,44 @@ def to_gpu_supported_dtype(array): return array -class FeatureEvent: +class FeatureEvent(pygfx.Event): """ - Dataclass that holds feature event information. Has ``type`` and ``pick_info`` attributes. - - Attributes - ---------- - type: str, example "colors" - - pick_info: dict: - - ============== ============================================================================= - key value - ============== ============================================================================= - "index" indices where feature data was changed, ``range`` object or ``List[int]`` - "world_object" world object the feature belongs to - "new_data: the new data for this feature - ============== ============================================================================= - - .. note:: - pick info varies between features, this is just the general structure + **All event instances have the following attributes** + + +------------+-------------+-----------------------------------------------+ + | attribute | type | description | + +============+=============+===============================================+ + | type | str | "colors" - name of the event | + +------------+-------------+-----------------------------------------------+ + | graphic | Graphic | graphic instance that the event is from | + +------------+-------------+-----------------------------------------------+ + | info | dict | event info dictionary (see below) | + +------------+-------------+-----------------------------------------------+ + | target | WorldObject | pygfx rendering engine object for the graphic | + +------------+-------------+-----------------------------------------------+ + | time_stamp | float | time when the event occured, in ms | + +------------+-------------+-----------------------------------------------+ """ - def __init__(self, type: str, pick_info: dict): - self.type = type - self.pick_info = pick_info - - def __repr__(self): - return ( - f"{self.__class__.__name__} @ {hex(id(self))}\n" - f"type: {self.type}\n" - f"pick_info: {self.pick_info}\n" - ) - - -class GraphicFeature(ABC): - def __init__(self, parent, data: Any, collection_index: int = None): - # not shown as a docstring so it doesn't show up in the docs - # - # Parameters - # ---------- - # parent - # - # data: Any - # - # collection_index: int - # if part of a collection, index of this graphic within the collection + def __init__(self, type: str, info: dict): + super().__init__(type=type) + self.info = info - self._parent = weakref.proxy(parent) - self._data = to_gpu_supported_dtype(data) - - self._collection_index = collection_index +class GraphicFeature: + def __init__(self, **kwargs): self._event_handlers = list() self._block_events = False - def __call__(self, *args, **kwargs): - return self._data + @property + def value(self) -> Any: + """Graphic Feature value, must be implemented in subclass""" + raise NotImplemented + + def set_value(self, graphic, value: float): + """Graphic Feature value setter, must be implemented in subclass""" + raise NotImplementedError def block_events(self, val: bool): """ @@ -112,23 +95,14 @@ def block_events(self, val: bool): """ self._block_events = val - @abstractmethod - def _set(self, value): - pass - - def _parse_set_value(self, value): - if isinstance(value, GraphicFeature): - return value() - - return value - def add_event_handler(self, handler: callable): """ Add an event handler. All added event handlers are called when this feature changes. - The ``handler`` can optionally accept a :class:`.FeatureEvent` as the first and only argument. - The ``FeatureEvent`` only has two attributes, ``type`` which denotes the type of event - as a ``str`` in the form of "", such as "color". And ``pick_info`` which contains - information about the event and Graphic that triggered it. + + Used by `Graphic` classes to add to their event handlers, not meant for users. Users + add handlers to Graphic instances only. + + The ``handler`` must accept a :class:`.FeatureEvent` as the first and only argument. Parameters ---------- @@ -164,196 +138,202 @@ def clear_event_handlers(self): """Clear all event handlers""" self._event_handlers.clear() - # TODO: maybe this can be implemented right here in the base class - @abstractmethod - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - """Called whenever a feature changes, and it calls all funcs in self._event_handlers""" - pass - def _call_event_handlers(self, event_data: FeatureEvent): if self._block_events: return for func in self._event_handlers: - try: - args = getfullargspec(func).args - - if len(args) > 0: - if args[0] == "self" and not len(args) > 1: - func() - else: - func(event_data) - else: - func() - except TypeError: - warn( - f"Event handler {func} has an unresolvable argspec, calling it without arguments" - ) - func() - - @abstractmethod - def __repr__(self) -> str: - pass - - -def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: - """ - - If the key in an `int`, it just returns it. Otherwise, - it parses it and removes the `None` vals and replaces - them with corresponding values that can be used to - create a `range`, get `len` etc. - - Parameters - ---------- - key - upper_bound - - Returns - ------- - - """ - if isinstance(key, int): - return key - - if isinstance(key, np.ndarray): - return cleanup_array_slice(key, upper_bound) - - if isinstance(key, tuple): - # if tuple of slice we only need the first obj - # since the first obj is the datapoint indices - if isinstance(key[0], slice): - key = key[0] + with log_exception( + f"Error during handling {self.__class__.__name__} event" + ): + func(event_data) + + +class BufferManager(GraphicFeature): + """Smaller wrapper for pygfx.Buffer""" + + def __init__( + self, + data: NDArray | pygfx.Buffer, + buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", + isolated_buffer: bool = True, + texture_dim: int = 2, + **kwargs, + ): + super().__init__() + if isolated_buffer and not isinstance(data, pygfx.Resource): + # useful if data is read-only, example: memmaps + bdata = np.zeros(data.shape, dtype=data.dtype) + bdata[:] = data[:] else: - raise TypeError("Tuple slicing must have slice object in first position") - - if not isinstance(key, slice): - raise TypeError("Must pass slice or int object") - - start = key.start - stop = key.stop - step = key.step - for attr in [start, stop, step]: - if attr is None: - continue - if attr < 0: - raise IndexError("Negative indexing not supported.") - - if start is None: - start = 0 - - if stop is None: - stop = upper_bound - - elif stop > upper_bound: - raise IndexError( - f"Index: `{stop}` out of bounds for feature array of size: `{upper_bound}`" - ) - - step = key.step - if step is None: - step = 1 + # user's input array is used as the buffer + bdata = data + + if isinstance(data, pygfx.Resource): + # already a buffer, probably used for + # managing another BufferManager, example: VertexCmap manages VertexColors + self._buffer = data + elif buffer_type == "buffer": + self._buffer = pygfx.Buffer(bdata) + elif buffer_type == "texture": + # TODO: placeholder, not currently used since TextureArray is used specifically for Image graphics + self._buffer = pygfx.Texture(bdata, dim=texture_dim) + else: + raise ValueError( + "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" + ) - return slice(start, stop, step) + self._event_handlers: list[callable] = list() + self._shared: int = 0 -def cleanup_array_slice(key: np.ndarray, upper_bound) -> Union[np.ndarray, None]: - """ - Cleanup numpy array used for fancy indexing, make sure key[-1] <= upper_bound. + @property + def value(self) -> np.ndarray: + """numpy array object representing the data managed by this buffer""" + return self.buffer.data - Returns None if nothing to change. + def set_value(self, graphic, value): + """Sets values on entire array""" + self[:] = value - Parameters - ---------- - key: np.ndarray - integer or boolean array + @property + def buffer(self) -> pygfx.Buffer | pygfx.Texture: + """managed buffer""" + return self._buffer - upper_bound + @property + def shared(self) -> int: + """Number of graphics that share this buffer""" + return self._shared - Returns - ------- - np.ndarray - integer indexing array + @property + def __array_interface__(self): + raise BufferError( + f"Cannot use graphic feature buffer as an array, use .value instead.\n" + f"Examples: line.data.value, line.colors.value, scatter.data.value, scatter.sizes.value" + ) - """ + def __getitem__(self, item): + return self.buffer.data[item] - if key.ndim > 1: - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + def __setitem__(self, key, value): + raise NotImplementedError - # if boolean array convert to integer array of indices - if key.dtype == bool: - key = np.nonzero(key)[0] + def _parse_offset_size( + self, + key: int | slice | np.ndarray[int | bool] | list[bool | int], + upper_bound: int, + ): + """ + parse offset and size for first, i.e. n_datapoints, dimension + """ + if np.issubdtype(type(key), np.integer): + # simplest case, just an int + offset = key + size = 1 + + elif isinstance(key, slice): + # TODO: off-by-one sometimes when step is used + # the offset can be one to the left or the size + # is one extra so it's not really an issue for now + # parse slice + start, stop, step = key.indices(upper_bound) + + # account for backwards indexing + if (start > stop) and step < 0: + offset = stop + else: + offset = start - if key.size < 1: - return None + # slice.indices will give -1 if None is passed + # which just means 0 here since buffers do not + # use negative indexing + offset = max(0, offset) - # make sure indices within bounds of feature buffer range - if key[-1] > upper_bound: - raise IndexError( - f"Index: `{key[-1]}` out of bounds for feature array of size: `{upper_bound}`" - ) + # number of elements to upload + # this is indexing so do not add 1 + size = abs(stop - start) - # make sure indices are integers - if np.issubdtype(key.dtype, np.integer): - return key + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) - raise TypeError(f"Can only use 1D boolean or integer arrays for fancy indexing") + if not key.ndim == 1: + raise TypeError( + f"can only use 1D arrays for fancy indexing, you have passed a data with: {key.ndim} dimensions" + ) + if key.dtype == bool: + # convert bool mask to integer indices + key = np.nonzero(key)[0] -class GraphicFeatureIndexable(GraphicFeature): - """An indexable Graphic Feature, colors, data, sizes etc.""" + if not np.issubdtype(key.dtype, np.integer): + # fancy indexing doesn't make sense with non-integer types + raise TypeError( + f"can only using integer or booleans arrays for fancy indexing, your array is of type: {key.dtype}" + ) - def _set(self, value): - value = self._parse_set_value(value) - self[:] = value + if key.size < 1: + # nothing to update + return - @abstractmethod - def __getitem__(self, item): - pass + # convert any negative integer indices to positive indices + key %= upper_bound - @abstractmethod - def __setitem__(self, key, value): - pass + # index of first element to upload + offset = key.min() - @abstractmethod - def _update_range(self, key): - pass + # size range to upload + # add 1 because this is direct + # passing of indices, not a start:stop + size = np.ptp(key) + 1 - @property - @abstractmethod - def buffer(self) -> Union[pygfx.Buffer, pygfx.Texture]: - """Underlying buffer for this feature""" - pass + else: + raise TypeError( + f"invalid key for indexing buffer: {key}\n" + f"valid ways to index buffers are using integers, slices, or fancy indexing with integers or bool" + ) + + return offset, size + + def _update_range( + self, + key: ( + int | slice | np.ndarray[int | bool] | list[bool | int] | tuple[slice, ...] + ), + ): + """ + Uses key from slicing to determine the offset and + size of the buffer to mark for upload to the GPU + """ + upper_bound = self.value.shape[0] - @property - def _upper_bound(self) -> int: - return self._data.shape[0] + if isinstance(key, tuple): + if any([k is Ellipsis for k in key]): + # let's worry about ellipsis later + raise TypeError("ellipses not supported for indexing buffers") + # if multiple dims are sliced, we only need the key for + # the first dimension corresponding to n_datapoints + key: int | np.ndarray[int | bool] | slice = key[0] - def _update_range_indices(self, key): - """Currently used by colors and positions data""" - if not isinstance(key, np.ndarray): - key = cleanup_slice(key, self._upper_bound) + offset, size = self._parse_offset_size(key, upper_bound) + self.buffer.update_range(offset=offset, size=size) - if isinstance(key, int): - self.buffer.update_range(key, size=1) + def _emit_event(self, type: str, key, value): + if len(self._event_handlers) < 1: return - # else if it's a slice obj - if isinstance(key, slice): - if key.step == 1: # we cleaned up the slice obj so step of None becomes 1 - # update range according to size using the offset - self.buffer.update_range(offset=key.start, size=key.stop - key.start) + event_info = { + "key": key, + "value": value, + } + event = FeatureEvent(type, info=event_info) - else: - step = key.step - # convert slice to indices - ixs = range(key.start, key.stop, step) - for ix in ixs: - self.buffer.update_range(ix, size=1) + self._call_event_handlers(event) - # TODO: See how efficient this is with large indexing - elif isinstance(key, np.ndarray): - self.buffer.update_range() + def __len__(self): + raise NotImplementedError - else: - raise TypeError("must pass int or slice to update range") + def __repr__(self): + return f"{self.__class__.__name__} buffer data:\n" f"{self.value.__repr__()}" diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py deleted file mode 100644 index 48405e74c..000000000 --- a/fastplotlib/graphics/_features/_colors.py +++ /dev/null @@ -1,434 +0,0 @@ -import numpy as np -import pygfx - -from ...utils import ( - make_colors, - get_cmap_texture, - make_pygfx_colors, - parse_cmap_values, - quick_min_max, -) -from ._base import ( - GraphicFeature, - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - cleanup_array_slice, -) - - -class ColorFeature(GraphicFeatureIndexable): - """ - Manages the color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` - - **event pick info:** - - ==================== =============================== ========================================================================= - key type description - ==================== =============================== ========================================================================= - "index" ``numpy.ndarray`` or ``None`` changed indices in the buffer - "new_data" ``numpy.ndarray`` or ``None`` new buffer data at the changed indices - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== =============================== ========================================================================= - - """ - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.colors - - def __getitem__(self, item): - return self.buffer.data[item] - - def __init__( - self, - parent, - colors, - n_colors: int, - alpha: float = 1.0, - collection_index: int = None, - ): - """ - ColorFeature - - Parameters - ---------- - parent: Graphic or GraphicCollection - - colors: str, array, or iterable - specify colors as a single human readable string, RGBA array, - or an iterable of strings or RGBA arrays - - n_colors: int - number of colors to hold, if passing in a single str or single RGBA array - - alpha: float - alpha value for the colors - - """ - # if provided as a numpy array of str - if isinstance(colors, np.ndarray): - if colors.dtype.kind in ["U", "S"]: - colors = colors.tolist() - # if the color is provided as a numpy array - if isinstance(colors, np.ndarray): - if colors.shape == (4,): # single RGBA array - data = np.repeat(np.array([colors]), n_colors, axis=0) - # else assume it's already a stack of RGBA arrays, keep this directly as the data - elif colors.ndim == 2: - if colors.shape[1] != 4 and colors.shape[0] != n_colors: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - data = colors - else: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - - # if the color is provided as an iterable - elif isinstance(colors, (list, tuple, np.ndarray)): - # if iterable of str - if all([isinstance(val, str) for val in colors]): - if not len(colors) == n_colors: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` of `str` " - f"where the length of the iterable is the same as the number of datapoints." - ) - - data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) - - # if it's a single RGBA array as a tuple/list - elif len(colors) == 4: - c = pygfx.Color(colors) - data = np.repeat(np.array([c]), n_colors, axis=0) - - else: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " - f"an iterable of `str` with the same length as the number of datapoints." - ) - elif isinstance(colors, str): - if colors == "random": - data = np.random.rand(n_colors, 4) - data[:, -1] = alpha - else: - data = make_pygfx_colors(colors, n_colors) - else: - # assume it's a single color, use pygfx.Color to parse it - data = make_pygfx_colors(colors, n_colors) - - if alpha != 1.0: - data[:, -1] = alpha - - super().__init__(parent, data, collection_index=collection_index) - - def __setitem__(self, key, value): - # parse numerical slice indices - if isinstance(key, slice): - _key = cleanup_slice(key, self._upper_bound) - indices = range(_key.start, _key.stop, _key.step) - - # or single numerical index - elif isinstance(key, (int, np.integer)): - key = cleanup_slice(key, self._upper_bound) - indices = [key] - - elif isinstance(key, tuple): - if not isinstance(value, (float, int, np.ndarray)): - raise ValueError( - "If using multiple-fancy indexing for color, you can only set numerical" - "values since this sets the RGBA array data directly." - ) - - if len(key) != 2: - raise ValueError( - "fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]" - ) - - # set the user passed data directly - self.buffer.data[key] = value - - # update range - # first slice obj is going to be the indexing so use key[0] - # key[1] is going to be RGBA so get rid of it to pass to _update_range - # _key = cleanup_slice(key[0], self._upper_bound) - self._update_range(key) - self._feature_changed(key, value) - return - - elif isinstance(key, np.ndarray): - key = cleanup_array_slice(key, self._upper_bound) - if key is None: - return - - indices = key - - else: - raise TypeError( - "Graphic features only support integer and numerical fancy indexing" - ) - - new_data_size = len(indices) - - if not isinstance(value, np.ndarray): - color = np.array(pygfx.Color(value)) # pygfx color parser - # make it of shape [n_colors_modify, 4] - new_colors = np.repeat( - np.array([color]).astype(np.float32), new_data_size, axis=0 - ) - - # if already a numpy array - elif isinstance(value, np.ndarray): - # if a single color provided as numpy array - if value.shape == (4,): - new_colors = value.astype(np.float32) - # if there are more than 1 datapoint color to modify - if new_data_size > 1: - new_colors = np.repeat( - np.array([new_colors]).astype(np.float32), new_data_size, axis=0 - ) - - elif value.ndim == 2: - if value.shape[1] != 4 and value.shape[0] != new_data_size: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) - # if there is a single datapoint to change color of but user has provided shape [1, 4] - if new_data_size == 1: - new_colors = value.ravel().astype(np.float32) - else: - new_colors = value.astype(np.float32) - - else: - raise ValueError( - "numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)" - ) - - self.buffer.data[key] = new_colors - - self._update_range(key) - self._feature_changed(key, new_colors) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - else: - raise TypeError("feature changed key must be slice or int") - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="colors", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ColorsFeature for {self._parent}. Call `.colors()` to get values." - return s - - -class CmapFeature(ColorFeature): - """ - Indexable colormap feature, mostly wraps colors and just provides a way to set colormaps. - - Same event pick info as :class:`ColorFeature` - """ - - def __init__(self, parent, colors, cmap_name: str, cmap_values: np.ndarray): - # Skip the ColorFeature's __init__ - super(ColorFeature, self).__init__(parent, colors) - - self._cmap_name = cmap_name - self._cmap_values = cmap_values - - def __setitem__(self, key, cmap_name): - key = cleanup_slice(key, self._upper_bound) - if not isinstance(key, (slice, np.ndarray)): - raise TypeError( - "Cannot set cmap on single indices, must pass a slice object, " - "numpy.ndarray or set it on the entire data." - ) - - if isinstance(key, slice): - n_colors = len(range(key.start, key.stop, key.step)) - - else: - # numpy array - n_colors = key.size - - colors = parse_cmap_values( - n_colors=n_colors, cmap_name=cmap_name, cmap_values=self._cmap_values - ) - - self._cmap_name = cmap_name - super().__setitem__(key, colors) - - @property - def name(self) -> str: - return self._cmap_name - - @property - def values(self) -> np.ndarray: - return self._cmap_values - - @values.setter - def values(self, values: np.ndarray): - if not isinstance(values, np.ndarray): - values = np.array(values) - - colors = parse_cmap_values( - n_colors=self().shape[0], cmap_name=self._cmap_name, cmap_values=values - ) - - self._cmap_values = values - - super().__setitem__(slice(None), colors) - - def __repr__(self) -> str: - s = f"CmapFeature for {self._parent}, to get name or values: `.cmap.name`, `.cmap.values`" - return s - - -class ImageCmapFeature(GraphicFeature): - """ - Colormap for :class:`ImageGraphic`. - - .cmap() returns the Texture buffer for the cmap. - - .cmap.name returns the cmap name as a str. - - **event pick info:** - - ================ =================== =============== - key type description - ================ =================== =============== - "index" ``None`` not used - "name" ``str`` colormap name - "world_object" pygfx.WorldObject world object - "vmin" ``float`` minimum value - "vmax" ``float`` maximum value - ================ =================== =============== - - """ - - def __init__(self, parent, cmap: str): - cmap_texture_view = get_cmap_texture(cmap) - super().__init__(parent, cmap_texture_view) - self._name = cmap - - def _set(self, cmap_name: str): - if self._parent.data().ndim > 2: - return - - self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) - self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self._name) - - @property - def name(self) -> str: - return self._name - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent.world_object.material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent.world_object.material.clim = ( - value, - self._parent.world_object.material.clim[1], - ) - self._feature_changed(key=None, new_data=None) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent.world_object.material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent.world_object.material.clim = ( - self._parent.world_object.material.clim[0], - value, - ) - self._feature_changed(key=None, new_data=None) - - def reset_vmin_vmax(self): - """Reset vmin vmax values based on current data""" - self.vmin, self.vmax = quick_min_max(self._parent.data()) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "world_object": self._parent.world_object, - "name": self._name, - "vmin": self.vmin, - "vmax": self.vmax, - } - - event_data = FeatureEvent(type="cmap", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ImageCmapFeature for {self._parent}. Use `.cmap.name` to get str name of cmap." - return s - - -class HeatmapCmapFeature(ImageCmapFeature): - """ - Colormap for :class:`HeatmapGraphic` - - Same event pick info as :class:`ImageCmapFeature` - """ - - def _set(self, cmap_name: str): - # in heatmap we use one material for all ImageTiles - self._parent._material.map.data[:] = make_colors(256, cmap_name) - self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) - self._name = cmap_name - - self._feature_changed(key=None, new_data=self.name) - - @property - def vmin(self) -> float: - """Minimum contrast limit.""" - return self._parent._material.clim[0] - - @vmin.setter - def vmin(self, value: float): - """Minimum contrast limit.""" - self._parent._material.clim = (value, self._parent._material.clim[1]) - - @property - def vmax(self) -> float: - """Maximum contrast limit.""" - return self._parent._material.clim[1] - - @vmax.setter - def vmax(self, value: float): - """Maximum contrast limit.""" - self._parent._material.clim = (self._parent._material.clim[0], value) diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py new file mode 100644 index 000000000..fe32a485f --- /dev/null +++ b/fastplotlib/graphics/_features/_common.py @@ -0,0 +1,123 @@ +import numpy as np + +from ._base import GraphicFeature, FeatureEvent + + +class Name(GraphicFeature): + """Graphic name""" + + def __init__(self, value: str): + self._value = value + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + if not isinstance(value, str): + raise TypeError("`Graphic` name must be of type ") + + if graphic._plot_area is not None: + graphic._plot_area._check_graphic_name_exists(value) + + self._value = value + + event = FeatureEvent(type="name", info={"value": value}) + self._call_event_handlers(event) + + +class Offset(GraphicFeature): + """Offset position of the graphic, [x, y, z]""" + + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False + super().__init__() + + def _validate(self, value): + if not len(value) == 3: + raise ValueError("offset must be a list, tuple, or array of 3 float values") + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) + + graphic.world_object.world.position = value + self._value = graphic.world_object.world.position.copy() + self._value.flags.writeable = False + + event = FeatureEvent(type="offset", info={"value": value}) + self._call_event_handlers(event) + + +class Rotation(GraphicFeature): + """Graphic rotation quaternion""" + + def __init__(self, value: np.ndarray | list | tuple): + self._validate(value) + self._value = np.array(value) + self._value.flags.writeable = False + super().__init__() + + def _validate(self, value): + if not len(value) == 4: + raise ValueError( + "rotation quaternion must be a list, tuple, or array of 4 float values" + ) + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value: np.ndarray | list | tuple): + self._validate(value) + + graphic.world_object.world.rotation = value + self._value = graphic.world_object.world.rotation.copy() + self._value.flags.writeable = False + + event = FeatureEvent(type="rotation", info={"value": value}) + self._call_event_handlers(event) + + +class Visible(GraphicFeature): + """Access or change the visibility.""" + + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + graphic.world_object.visible = value + self._value = value + + event = FeatureEvent(type="visible", info={"value": value}) + self._call_event_handlers(event) + + +class Deleted(GraphicFeature): + """ + Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted + """ + + def __init__(self, value: bool): + self._value = value + super().__init__() + + @property + def value(self) -> bool: + return self._value + + def set_value(self, graphic, value: bool): + self._value = value + event = FeatureEvent(type="deleted", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py deleted file mode 100644 index bcfe9446a..000000000 --- a/fastplotlib/graphics/_features/_data.py +++ /dev/null @@ -1,219 +0,0 @@ -from typing import * - -import numpy as np - -import pygfx - -from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - to_gpu_supported_dtype, - cleanup_array_slice, -) - - -class PointsDataFeature(GraphicFeatureIndexable): - """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. - """ - - def __init__(self, parent, data: Any, collection_index: int = None): - data = self._fix_data(data, parent) - super().__init__(parent, data, collection_index=collection_index) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.positions - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_data(self, data, parent): - graphic_type = parent.__class__.__name__ - - data = to_gpu_supported_dtype(data) - - if data.ndim == 1: - # for scatter if we receive just 3 points in a 1d array, treat it as just a single datapoint - # this is different from fix_data for LineGraphic since there we assume that a 1d array - # is just y-values - if graphic_type == "ScatterGraphic": - data = np.array([data]) - elif graphic_type == "LineGraphic": - data = np.dstack([np.arange(data.size, dtype=data.dtype), data])[0] - - if data.shape[1] != 3: - if data.shape[1] != 2: - raise ValueError(f"Must pass 1D, 2D or 3D data to {graphic_type}") - - # zeros for z - zs = np.zeros(data.shape[0], dtype=data.dtype) - - data = np.dstack([data[:, 0], data[:, 1], zs])[0] - - return data - - def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put data into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_data(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - self.buffer.data[key] = value - self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PointsDataFeature for {self._parent}, call `.data()` to get values" - return s - - -class ImageDataFeature(GraphicFeatureIndexable): - """ - Access to the Texture buffer shown in an ImageGraphic. - """ - - def __init__(self, parent, data: Any): - if data.ndim not in (2, 3): - raise ValueError( - "`data.ndim` must be 2 or 3, ImageGraphic data shape must be " - "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" - ) - - super().__init__(parent, data) - - @property - def buffer(self) -> pygfx.Texture: - """Texture buffer for the image data""" - return self._parent.world_object.geometry.grid - - def update_gpu(self): - """Update the GPU with the buffer""" - self._update_range(None) - - def __call__(self, *args, **kwargs): - return self.buffer.data - - def __getitem__(self, item): - return self.buffer.data[item] - - def __setitem__(self, key, value): - # make sure float32 - value = to_gpu_supported_dtype(value) - - self.buffer.data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self.buffer.update_range((0, 0, 0), size=self.buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ImageDataFeature for {self._parent}, call `.data()` to get values" - return s - - -class HeatmapDataFeature(ImageDataFeature): - @property - def buffer(self) -> List[pygfx.Texture]: - """list of Texture buffer for the image data""" - return [img.geometry.grid for img in self._parent.world_object.children] - - def __getitem__(self, item): - return self._data[item] - - def __call__(self, *args, **kwargs): - return self._data - - def __setitem__(self, key, value): - # make sure supported type, not float64 etc. - value = to_gpu_supported_dtype(value) - - self._data[key] = value - self._update_range(key) - - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - for buffer in self.buffer: - buffer.update_range((0, 0, 0), size=buffer.size) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, int): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif key is None: - indices = None - - pick_info = { - "index": indices, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="data", pick_info=pick_info) - - self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/_features/_deleted.py b/fastplotlib/graphics/_features/_deleted.py deleted file mode 100644 index 7900385eb..000000000 --- a/fastplotlib/graphics/_features/_deleted.py +++ /dev/null @@ -1,41 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class Deleted(GraphicFeature): - """ - Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, value: bool): - super().__init__(parent, value) - - def _set(self, value: bool): - value = self._parse_set_value(value) - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="deleted", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"DeletedFeature for {self._parent}" - return s diff --git a/fastplotlib/graphics/_features/_image.py b/fastplotlib/graphics/_features/_image.py new file mode 100644 index 000000000..e31184c4b --- /dev/null +++ b/fastplotlib/graphics/_features/_image.py @@ -0,0 +1,262 @@ +from itertools import product + +from math import ceil + +import numpy as np + +import pygfx +from ._base import GraphicFeature, FeatureEvent, WGPU_MAX_TEXTURE_SIZE + +from ...utils import ( + make_colors, + get_cmap_texture, +) + + +# manages an array of 8192x8192 Textures representing chunks of an image +class TextureArray(GraphicFeature): + def __init__(self, data, isolated_buffer: bool = True): + super().__init__() + + data = self._fix_data(data) + + if isolated_buffer: + # useful if data is read-only, example: memmaps + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] + else: + # user's input array is used as the buffer + self._value = data + + # data start indices for each Texture + self._row_indices = np.arange( + 0, + ceil(self.value.shape[0] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, + WGPU_MAX_TEXTURE_SIZE, + ) + self._col_indices = np.arange( + 0, + ceil(self.value.shape[1] / WGPU_MAX_TEXTURE_SIZE) * WGPU_MAX_TEXTURE_SIZE, + WGPU_MAX_TEXTURE_SIZE, + ) + + # buffer will be an array of textures + self._buffer: np.ndarray[pygfx.Texture] = np.empty( + shape=(self.row_indices.size, self.col_indices.size), dtype=object + ) + + self._iter = None + + # iterate through each chunk of passed `data` + # create a pygfx.Texture from this chunk + for _, buffer_index, data_slice in self: + texture = pygfx.Texture(self.value[data_slice], dim=2) + + self.buffer[buffer_index] = texture + + self._shared: int = 0 + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value): + self[:] = value + + @property + def buffer(self) -> np.ndarray[pygfx.Texture]: + return self._buffer + + @property + def row_indices(self) -> np.ndarray: + """ + row indices that are used to chunk the big data array + into individual Textures on the GPU + """ + return self._row_indices + + @property + def col_indices(self) -> np.ndarray: + """ + column indices that are used to chunk the big data array + into individual Textures on the GPU + """ + return self._col_indices + + @property + def shared(self) -> int: + return self._shared + + def _fix_data(self, data): + if data.ndim not in (2, 3): + raise ValueError( + "image data must be 2D with or without an RGB(A) dimension, i.e. " + "it must be of shape [x, y], [x, y, 3] or [x, y, 4]" + ) + + # let's just cast to float32 always + return data.astype(np.float32) + + def __iter__(self): + self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) + return self + + def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]]: + """ + Iterate through each Texture within the texture array + + Returns + ------- + Texture, tuple[int, int], tuple[slice, slice] + | Texture: pygfx.Texture + | tuple[int, int]: chunk index, i.e corresponding index of ``self.buffer`` array + | tuple[slice, slice]: data slice of big array in this chunk and Texture + """ + (chunk_row, data_row_start), (chunk_col, data_col_start) = next(self._iter) + + # indices for to self.buffer for this chunk + chunk_index = (chunk_row, chunk_col) + + # stop indices of big data array for this chunk + row_stop = min(self.value.shape[0] - 1, data_row_start + WGPU_MAX_TEXTURE_SIZE) + col_stop = min(self.value.shape[1] - 1, data_col_start + WGPU_MAX_TEXTURE_SIZE) + + # row and column slices that slice the data for this chunk from the big data array + data_slice = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) + + # texture for this chunk + texture = self.buffer[chunk_index] + + return texture, chunk_index, data_slice + + def __getitem__(self, item): + return self.value[item] + + def __setitem__(self, key, value): + self.value[key] = value + + for texture in self.buffer.ravel(): + texture.update_range((0, 0, 0), texture.size) + + event = FeatureEvent("data", info={"key": key, "value": value}) + self._call_event_handlers(event) + + def __len__(self): + return self.buffer.size + + +class ImageVmin(GraphicFeature): + """lower contrast limit""" + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmax = graphic._material.clim[1] + graphic._material.clim = (value, vmax) + self._value = value + + event = FeatureEvent(type="vmin", info={"value": value}) + self._call_event_handlers(event) + + +class ImageVmax(GraphicFeature): + """upper contrast limit""" + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + vmin = graphic._material.clim[0] + graphic._material.clim = (vmin, value) + self._value = value + + event = FeatureEvent(type="vmax", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmap(GraphicFeature): + """colormap for texture""" + + def __init__(self, value: str): + self._value = value + self.texture = get_cmap_texture(value) + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + new_colors = make_colors(256, value) + graphic._material.map.data[:] = new_colors + graphic._material.map.update_range((0, 0, 0), size=(256, 1, 1)) + + self._value = value + event = FeatureEvent(type="cmap", info={"value": value}) + self._call_event_handlers(event) + + +class ImageInterpolation(GraphicFeature): + """Image interpolation method""" + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError("`interpolation` must be one of 'nearest' or 'linear'") + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + graphic._material.interpolation = value + + self._value = value + event = FeatureEvent(type="interpolation", info={"value": value}) + self._call_event_handlers(event) + + +class ImageCmapInterpolation(GraphicFeature): + """Image cmap interpolation method""" + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + def _validate(self, value): + if value not in ["nearest", "linear"]: + raise ValueError( + "`cmap_interpolation` must be one of 'nearest' or 'linear'" + ) + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + self._validate(value) + + # common material for all image tiles + graphic._material.map_interpolation = value + + self._value = value + event = FeatureEvent(type="cmap_interpolation", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py new file mode 100644 index 000000000..ee7927a36 --- /dev/null +++ b/fastplotlib/graphics/_features/_positions_graphics.py @@ -0,0 +1,458 @@ +from typing import Any, List + +import numpy as np +import pygfx + +from ...utils import ( + parse_cmap_values, +) +from ._base import ( + GraphicFeature, + BufferManager, + FeatureEvent, + to_gpu_supported_dtype, +) +from .utils import parse_colors + + +class VertexColors(BufferManager): + """ + + **info dict** + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | dict key | value type | value description | + +============+===========================================================+==================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which colors were indexed/sliced | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + | user_value | str | np.ndarray | tuple[float] | list[float] | list[str] | user input value that was parsed into the RGBA array | + +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+ + + """ + + def __init__( + self, + colors: str | np.ndarray | tuple[float] | list[float] | list[str], + n_colors: int, + alpha: float = None, + isolated_buffer: bool = True, + ): + """ + Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` + + Parameters + ---------- + colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float + specify colors as a single human-readable string, RGBA array, + or an iterable of strings or RGBA arrays + + n_colors: int + number of colors, if passing in a single str or single RGBA array + + alpha: float, optional + alpha value for the colors + + """ + data = parse_colors(colors, n_colors, alpha) + + super().__init__(data=data, isolated_buffer=isolated_buffer) + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], + user_value: str | np.ndarray | tuple[float] | list[float] | list[str], + ): + user_key = key + + if isinstance(key, tuple): + # directly setting RGBA values for points, we do no parsing + if not isinstance(user_value, (int, float, np.ndarray)): + raise TypeError( + "Can only set from int, float, or array to set colors directly by slicing the entire array" + ) + value = user_value + + elif isinstance(key, int): + # set color of one point + n_colors = 1 + value = parse_colors(user_value, n_colors) + + elif isinstance(key, slice): + # find n_colors by converting slice to range and then parse colors + start, stop, step = key.indices(self.value.shape[0]) + + n_colors = len(range(start, stop, step)) + + value = parse_colors(user_value, n_colors) + + elif isinstance(key, (np.ndarray, list)): + if isinstance(key, list): + # convert to array + key = np.array(key) + + # make sure it's 1D + if not key.ndim == 1: + raise TypeError( + "If slicing colors with an array, it must be a 1D bool or int array" + ) + + if key.dtype == bool: + # make sure len is same + if not key.size == self.buffer.data.shape[0]: + raise IndexError( + f"Length of array for fancy indexing must match number of datapoints.\n" + f"There are {len(self.buffer.data.shape[0])} datapoints and you have passed {key.size} indices" + ) + n_colors = np.count_nonzero(key) + + elif np.issubdtype(key.dtype, np.integer): + n_colors = key.size + + else: + raise TypeError( + "If slicing colors with an array, it must be a 1D bool or int array" + ) + + value = parse_colors(user_value, n_colors) + + else: + raise TypeError( + f"invalid key for setting colors, you may set colors using integer indices, slices, or " + f"fancy indexing using an array of integers or bool" + ) + + self.buffer.data[key] = value + + self._update_range(key) + + if len(self._event_handlers) < 1: + return + + event_info = { + "key": user_key, + "value": value, + "user_value": user_value, + } + + event = FeatureEvent("colors", info=event_info) + self._call_event_handlers(event) + + def __len__(self): + return len(self.buffer.data) + + +# Manages uniform color for line or scatter material +class UniformColor(GraphicFeature): + def __init__( + self, value: str | np.ndarray | tuple | list | pygfx.Color, alpha: float = 1.0 + ): + v = (*tuple(pygfx.Color(value))[:-1], alpha) # apply alpha + self._value = pygfx.Color(v) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = value + + event = FeatureEvent(type="colors", info={"value": value}) + self._call_event_handlers(event) + + +# manages uniform size for scatter material +class UniformSize(GraphicFeature): + def __init__(self, value: int | float): + self._value = float(value) + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float | int): + graphic.world_object.material.size = float(value) + self._value = value + + event = FeatureEvent(type="sizes", info={"value": value}) + self._call_event_handlers(event) + + +class VertexPositions(BufferManager): + """ + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | dict key | value type | value description | + +==========+==========================================================+==========================================================================================+ + | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set | + +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+ + + """ + + def __init__(self, data: Any, isolated_buffer: bool = True): + """ + Manages the vertex positions buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + data = self._fix_data(data) + super().__init__(data, isolated_buffer=isolated_buffer) + + def _fix_data(self, data): + # data = to_gpu_supported_dtype(data) + + if data.ndim == 1: + # if user provides a 1D array, assume these are y-values + data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) + + if data.shape[1] != 3: + if data.shape[1] != 2: + raise ValueError(f"Must pass 1D, 2D or 3D data") + + # zeros for z + zs = np.zeros(data.shape[0], dtype=data.dtype) + + # column stack [x, y, z] to make data of shape [n_points, 3] + data = np.column_stack([data[:, 0], data[:, 1], zs]) + + return to_gpu_supported_dtype(data) + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], + value: np.ndarray | float | list[float], + ): + # directly use the key to slice the buffer + self.buffer.data[key] = value + + # _update_range handles parsing the key to + # determine offset and size for GPU upload + self._update_range(key) + + self._emit_event("data", key, value) + + def __len__(self): + return len(self.buffer.data) + + +class PointsSizesFeature(BufferManager): + """ + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | dict key | value type | value description | + +==========+===================================================================+==============================================+ + | key | int | slice | np.ndarray[int | bool] | list[int | bool] | key at which point sizes indexed/sliced | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + | value | int | float | np.ndarray | list[int | float] | tuple[int | float] | new size values for points that were changed | + +----------+-------------------------------------------------------------------+----------------------------------------------+ + """ + + def __init__( + self, + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + isolated_buffer: bool = True, + ): + """ + Manages sizes buffer of scatter points. + """ + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__(data=sizes, isolated_buffer=isolated_buffer) + + def _fix_sizes( + self, + sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], + n_datapoints: int, + ): + if np.issubdtype(type(sizes), np.number): + # single value given + sizes = np.full( + n_datapoints, sizes, dtype=np.float32 + ) # force it into a float to avoid weird gpu errors + + elif isinstance( + sizes, (np.ndarray, tuple, list) + ): # if it's not a ndarray already, make it one + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): + raise ValueError( + f"sequence of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError( + "sizes must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints" + ) + + if np.count_nonzero(sizes < 0) > 1: + raise ValueError( + "All sizes must be positive numbers greater than or equal to 0.0." + ) + + return sizes + + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | list[int | bool], + value: int | float | np.ndarray | list[int | float] | tuple[int | float], + ): + # this is a very simple 1D buffer, no parsing required, directly set buffer + self.buffer.data[key] = value + self._update_range(key) + + self._emit_event("sizes", key, value) + + def __len__(self): + return len(self.buffer.data) + + +class Thickness(GraphicFeature): + """line thickness""" + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + graphic.world_object.material.thickness = value + self._value = value + + event = FeatureEvent(type="thickness", info={"value": value}) + self._call_event_handlers(event) + + +class VertexCmap(BufferManager): + """ + Sliceable colormap feature, manages a VertexColors instance and + provides a way to set colormaps with arbitrary transforms + """ + + def __init__( + self, + vertex_colors: VertexColors, + cmap_name: str | None, + transform: np.ndarray | None, + alpha: float = 1.0, + ): + super().__init__(data=vertex_colors.buffer) + + self._vertex_colors = vertex_colors + self._cmap_name = cmap_name + self._transform = transform + self._alpha = alpha + + if self._cmap_name is not None: + if not isinstance(self._cmap_name, str): + raise TypeError( + f"cmap name must be of type , you have passed: {self._cmap_name} of type: {type(self._cmap_name)}" + ) + + if self._transform is not None: + self._transform = np.asarray(self._transform) + + n_datapoints = vertex_colors.value.shape[0] + + colors = parse_cmap_values( + n_colors=n_datapoints, + cmap_name=self._cmap_name, + transform=self._transform, + ) + colors[:, -1] = alpha + # set vertex colors from cmap + self._vertex_colors[:] = colors + + def __setitem__(self, key: slice, cmap_name): + if not isinstance(key, slice): + raise TypeError( + "fancy indexing not supported for VertexCmap, only slices " + "of a continuous are supported for apply a cmap" + ) + if key.step is not None: + raise TypeError( + "step sized indexing not currently supported for setting VertexCmap, " + "slices must be a continuous region" + ) + + # parse slice + start, stop, step = key.indices(self.value.shape[0]) + n_elements = len(range(start, stop, step)) + + colors = parse_cmap_values( + n_colors=n_elements, cmap_name=cmap_name, transform=self._transform + ) + colors[:, -1] = self.alpha + + self._cmap_name = cmap_name + self._vertex_colors[key] = colors + + # TODO: should we block vertex_colors from emitting an event? + # Because currently this will result in 2 emitted events, one + # for cmap and another from the colors + self._emit_event("cmap", key, cmap_name) + + @property + def name(self) -> str: + return self._cmap_name + + @property + def transform(self) -> np.ndarray | None: + """Get or set the cmap transform. Maps values from the transform array to the cmap colors""" + return self._transform + + @transform.setter + def transform( + self, + values: np.ndarray | list[float | int], + indices: slice | list | np.ndarray = None, + ): + if self._cmap_name is None: + raise AttributeError( + "cmap name is not set, set the cmap name before setting the transform" + ) + + values = np.asarray(values) + + colors = parse_cmap_values( + n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values + ) + + colors[:, -1] = self.alpha + + self._transform = values + + if indices is None: + indices = slice(None) + + self._vertex_colors[indices] = colors + + self._emit_event("cmap.transform", indices, values) + + @property + def alpha(self) -> float: + """Get or set the alpha level""" + return self._alpha + + @alpha.setter + def alpha(self, value: float, indices: slice | list | np.ndarray = None): + self._vertex_colors[indices, -1] = value + self._alpha = value + + self._emit_event("cmap.alpha", indices, value) + + def __len__(self): + raise NotImplementedError( + "len not implemented for `cmap`, use len(colors) instead" + ) + + def __repr__(self): + return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}" diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py deleted file mode 100644 index a73d66523..000000000 --- a/fastplotlib/graphics/_features/_present.py +++ /dev/null @@ -1,72 +0,0 @@ -from pygfx import Scene, Group - -from ._base import GraphicFeature, FeatureEvent - - -class PresentFeature(GraphicFeature): - """ - Toggles if the object is present in the scene, different from visible. - Useful for computing bounding boxes from the Scene to only include graphics - that are present. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "new_data" ``bool`` new data, ``True`` or ``False`` - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, present: bool = True, collection_index: int = False): - self._scene = None - super().__init__(parent, present, collection_index) - - def _set(self, present: bool): - present = self._parse_set_value(present) - - i = 0 - wo = self._parent.world_object - while not isinstance(self._scene, (Group, Scene)): - wo_parent = wo.parent - self._scene = wo_parent - wo = wo_parent - i += 1 - - if i > 100: - raise RecursionError( - "Exceeded scene graph depth threshold, cannot find Scene associated with" - "this graphic." - ) - - if present: - if self._parent.world_object not in self._scene.children: - self._scene.add(self._parent.world_object) - - else: - if self._parent.world_object in self._scene.children: - self._scene.remove(self._parent.world_object) - - self._data = present - self._feature_changed(key=None, new_data=present) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="present", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PresentFeature for {self._parent}, call `.present()` to get values" - return s diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py index 21e5d0a09..71ba53425 100644 --- a/fastplotlib/graphics/_features/_selection_features.py +++ b/fastplotlib/graphics/_features/_selection_features.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union, Any +from typing import Sequence import numpy as np @@ -7,196 +7,186 @@ class LinearSelectionFeature(GraphicFeature): - # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - Manages the linear selection and callbacks + **additional event attributes:** - **event pick info** + +--------------------+----------+------------------------------------+ + | attribute | type | description | + +====================+==========+====================================+ + | get_selected_index | callable | returns indices under the selector | + +--------------------+----------+------------------------------------+ - =================== =============================== ================================================================================================= - key type selection - =================== =============================== ================================================================================================= - "selected_index" ``int`` the graphic data index that corresponds to the selector position - "world_object" ``pygfx.WorldObject`` pygfx WorldObject - "new_data" ``numpy.ndarray`` or ``None`` the new selector position in world coordinates, not necessarily the same as "selected_index" - "graphic" ``Graphic`` the selector graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - =================== =============================== ================================================================================================= + **info dict:** + + +----------+------------+-------------------------------+ + | dict key | value type | value description | + +==========+============+===============================+ + | value | np.ndarray | new x or y value of selection | + +----------+------------+-------------------------------+ """ - def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): - super().__init__(parent, data=value) + def __init__(self, axis: str, value: float, limits: tuple[float, float]): + """ - self._axis = axis - self._limits = limits + Parameters + ---------- + axis: "x" | "y" + axis the selector is restricted to - def _set(self, value: float): - if not (self._limits[0] <= value <= self._limits[1]): - return + value: float + position of the slider in world space, NOT data space + limits: (float, float) + min, max limits of the selector - if self._axis == "x": - self._parent.position_x = value - else: - self._parent.position_y = value + """ - self._data = value - self._feature_changed(key=None, new_data=value) + super().__init__() - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): - if len(self._event_handlers) < 1: - return + self._axis = axis + self._limits = limits + self._value = value + + @property + def value(self) -> float: + """ + selection, data x or y value + """ + return self._value - if self._parent.parent is not None: - g_ix = self._parent.get_selected_index() - else: - g_ix = None + def set_value(self, selector, value: float): + # clip value between limits + value = np.clip(value, self._limits[0], self._limits[1]) - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None + # set position + if self._axis == "x": + dim = 0 + elif self._axis == "y": + dim = 1 - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_index": g_ix, - "graphic": self._parent, - "pygfx_event": pygfx_ev, - "delta": self._parent.delta, - } + for edge in selector._edges: + edge.geometry.positions.data[:, dim] = value + edge.geometry.positions.update_range() - event_data = FeatureEvent(type="selection", pick_info=pick_info) + self._value = value - self._call_event_handlers(event_data) + event = FeatureEvent("selection", {"value": value}) + event.get_selected_index = selector.get_selected_index - def __repr__(self) -> str: - s = f"LinearSelectionFeature for {self._parent}" - return s + self._call_event_handlers(event) class LinearRegionSelectionFeature(GraphicFeature): """ - Feature for a linearly bounding region - - **event pick info** - - ===================== =============================== ======================================================================================= - key type description - ===================== =============================== ======================================================================================= - "selected_indices" ``numpy.ndarray`` or ``None`` selected graphic data indices - "world_object" ``pygfx.WorldObject`` pygfx World Object - "new_data" ``(float, float)`` current bounds in world coordinates, NOT necessarily the same as "selected_indices". - "graphic" ``Graphic`` the selection graphic - "delta" ``numpy.ndarray`` the delta vector of the graphic in NDC - "pygfx_event" ``pygfx.Event`` pygfx Event - "selected_data" ``numpy.ndarray`` or ``None`` selected graphic data - "move_info" ``MoveInfo`` last position and event source (pygfx.Mesh or pygfx.Line) - ===================== =============================== ======================================================================================= + **additional event attributes:** + + +----------------------+----------+------------------------------------+ + | attribute | type | description | + +======================+==========+====================================+ + | get_selected_indices | callable | returns indices under the selector | + +----------------------+----------+------------------------------------+ + | get_selected_data | callable | returns data under the selector | + +----------------------+----------+------------------------------------+ + + **info dict:** + + +----------+------------+-----------------------------+ + | dict key | value type | value description | + +==========+============+=============================+ + | value | np.ndarray | new [min, max] of selection | + +----------+------------+-----------------------------+ """ - def __init__( - self, parent, selection: Tuple[int, int], axis: str, limits: Tuple[int, int] - ): - super().__init__(parent, data=selection) + def __init__(self, value: tuple[int, int], axis: str, limits: tuple[float, float]): + super().__init__() self._axis = axis self._limits = limits + self._value = tuple(int(v) for v in value) - self._set(selection) + @property + def value(self) -> np.ndarray[float]: + """ + (min, max) of the selection, in data space + """ + return self._value @property def axis(self) -> str: """one of "x" | "y" """ return self._axis - def _set(self, value: Tuple[float, float]): - # sets new bounds - if not isinstance(value, tuple): + def set_value(self, selector, value: Sequence[float]): + """ + Set start, stop range of selector + + Parameters + ---------- + selector: LinearRegionSelector + + value: (float, float) + (min, max) values in data space + + """ + if not len(value) == 2: raise TypeError( - "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " - "where `min_bound` and `max_bound` are numeric values." + "selection must be a array, tuple, list, or sequence in the form of `(min, max)`, " + "where `min` and `max` are numeric values." ) - # make sure bounds not exceeded - for v in value: - if not (self._limits[0] <= v <= self._limits[1]): - return + # convert to array, clip values if they are beyond the limits + value = np.asarray(value, dtype=np.float32).clip(*self._limits) # make sure `selector width >= 2`, left edge must not move past right edge! # or bottom edge must not move past top edge! - # has to be at least 2 otherwise can't join datapoints for lines - if not (value[1] - value[0]) >= 2: + if not (value[1] - value[0]) >= 0: return if self.axis == "x": # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_left] = value[0] + selector.fill.geometry.positions.data[mesh_masks.x_left] = value[0] # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.x_right] = value[1] + selector.fill.geometry.positions.data[mesh_masks.x_right] = value[1] # change x position of the left edge line - self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + selector.edges[0].geometry.positions.data[:, 0] = value[0] # change x position of the right edge line - self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + selector.edges[1].geometry.positions.data[:, 0] = value[1] elif self.axis == "y": # change bottom y position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] + selector.fill.geometry.positions.data[mesh_masks.y_bottom] = value[0] # change top position of the fill mesh - self._parent.fill.geometry.positions.data[mesh_masks.y_top] = value[1] + selector.fill.geometry.positions.data[mesh_masks.y_top] = value[1] # change y position of the bottom edge line - self._parent.edges[0].geometry.positions.data[:, 1] = value[0] + selector.edges[0].geometry.positions.data[:, 1] = value[0] # change y position of the top edge line - self._parent.edges[1].geometry.positions.data[:, 1] = value[1] + selector.edges[1].geometry.positions.data[:, 1] = value[1] - self._data = value # (value[0], value[1]) + self._value = value # send changes to GPU - self._parent.fill.geometry.positions.update_range() - - self._parent.edges[0].geometry.positions.update_range() - self._parent.edges[1].geometry.positions.update_range() + selector.fill.geometry.positions.update_range() - # calls any events - self._feature_changed(key=None, new_data=value) + selector.edges[0].geometry.positions.update_range() + selector.edges[1].geometry.positions.update_range() - def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + # send event if len(self._event_handlers) < 1: return - if self._parent.parent is not None: - selected_ixs = self._parent.get_selected_indices() - selected_data = self._parent.get_selected_data() - else: - selected_ixs = None - selected_data = None - - # get pygfx event and reset it - pygfx_ev = self._parent._pygfx_event - self._parent._pygfx_event = None - - pick_info = { - "world_object": self._parent.world_object, - "new_data": new_data, - "selected_indices": selected_ixs, - "selected_data": selected_data, - "graphic": self._parent, - "delta": self._parent.delta, - "pygfx_event": pygfx_ev, - "move_info": self._parent._move_info, - } - - event_data = FeatureEvent(type="selection", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"LinearRegionSelectionFeature for {self._parent}" - return s + event = FeatureEvent("selection", {"value": self.value}) + + event.get_selected_indices = selector.get_selected_indices + event.get_selected_data = selector.get_selected_data + + self._call_event_handlers(event) + # TODO: user's selector event handlers can call event.graphic.get_selected_indices() to get the data index, + # and event.graphic.get_selected_data() to get the data under the selection + # this is probably a good idea so that the data isn't sliced until it's actually necessary diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py deleted file mode 100644 index 2ceeb7862..000000000 --- a/fastplotlib/graphics/_features/_sizes.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Any - -import numpy as np - -import pygfx - -from ._base import ( - GraphicFeatureIndexable, - cleanup_slice, - FeatureEvent, - to_gpu_supported_dtype, - cleanup_array_slice, -) - - -class PointsSizesFeature(GraphicFeatureIndexable): - """ - Access to the vertex buffer data shown in the graphic. - Supports fancy indexing if the data array also supports it. - """ - - def __init__(self, parent, sizes: Any, collection_index: int = None): - sizes = self._fix_sizes(sizes, parent) - super().__init__(parent, sizes, collection_index=collection_index) - - @property - def buffer(self) -> pygfx.Buffer: - return self._parent.world_object.geometry.sizes - - def __getitem__(self, item): - return self.buffer.data[item] - - def _fix_sizes(self, sizes, parent): - graphic_type = parent.__class__.__name__ - - n_datapoints = parent.data().shape[0] - if not isinstance(sizes, (list, tuple, np.ndarray)): - sizes = np.full( - n_datapoints, sizes, dtype=np.float32 - ) # force it into a float to avoid weird gpu errors - elif not isinstance( - sizes, np.ndarray - ): # if it's not a ndarray already, make it one - sizes = np.array(sizes, dtype=np.float32) # read it in as a numpy.float32 - if (sizes.ndim != 1) or (sizes.size != parent.data().shape[0]): - raise ValueError( - f"sequence of `sizes` must be 1 dimensional with " - f"the same length as the number of datapoints" - ) - - sizes = to_gpu_supported_dtype(sizes) - - if any(s < 0 for s in sizes): - raise ValueError( - "All sizes must be positive numbers greater than or equal to 0.0." - ) - - if sizes.ndim == 1: - if graphic_type == "ScatterGraphic": - sizes = np.array(sizes) - else: - raise ValueError( - f"Sizes must be an array of shape (n,) where n == the number of data points provided.\ - Received shape={sizes.shape}." - ) - - return np.array(sizes) - - def __setitem__(self, key, value): - if isinstance(key, np.ndarray): - # make sure 1D array of int or boolean - key = cleanup_array_slice(key, self._upper_bound) - - # put sizes into right shape if they're only indexing datapoints - if isinstance(key, (slice, int, np.ndarray, np.integer)): - value = self._fix_sizes(value, self._parent) - # otherwise assume that they have the right shape - # numpy will throw errors if it can't broadcast - - if value.size != self.buffer.data[key].size: - raise ValueError( - f"{value.size} is not equal to buffer size {self.buffer.data[key].size}.\ - If you want to set size to a non-scalar value, make sure it's the right length!" - ) - - self.buffer.data[key] = value - self._update_range(key) - # avoid creating dicts constantly if there are no events to handle - if len(self._event_handlers) > 0: - self._feature_changed(key, value) - - def _update_range(self, key): - self._update_range_indices(key) - - def _feature_changed(self, key, new_data): - if key is not None: - key = cleanup_slice(key, self._upper_bound) - if isinstance(key, (int, np.integer)): - indices = [key] - elif isinstance(key, slice): - indices = range(key.start, key.stop, key.step) - elif isinstance(key, np.ndarray): - indices = key - elif key is None: - indices = None - - pick_info = { - "index": indices, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="sizes", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"PointsSizesFeature for {self._parent}, call `.sizes()` to get values" - return s diff --git a/fastplotlib/graphics/_features/_text.py b/fastplotlib/graphics/_features/_text.py new file mode 100644 index 000000000..baa2734d5 --- /dev/null +++ b/fastplotlib/graphics/_features/_text.py @@ -0,0 +1,92 @@ +import numpy as np + +import pygfx + +from ._base import GraphicFeature, FeatureEvent + + +class TextData(GraphicFeature): + def __init__(self, value: str): + self._value = value + super().__init__() + + @property + def value(self) -> str: + return self._value + + def set_value(self, graphic, value: str): + graphic.world_object.geometry.set_text(value) + self._value = value + + event = FeatureEvent(type="text", info={"value": value}) + self._call_event_handlers(event) + + +class FontSize(GraphicFeature): + def __init__(self, value: float | int): + self._value = value + super().__init__() + + @property + def value(self) -> float | int: + return self._value + + def set_value(self, graphic, value: float | int): + graphic.world_object.geometry.font_size = value + self._value = graphic.world_object.geometry.font_size + + event = FeatureEvent(type="font_size", info={"value": value}) + self._call_event_handlers(event) + + +class TextFaceColor(GraphicFeature): + def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]): + value = pygfx.Color(value) + graphic.world_object.material.color = value + self._value = graphic.world_object.material.color + + event = FeatureEvent(type="face_color", info={"value": value}) + self._call_event_handlers(event) + + +class TextOutlineColor(GraphicFeature): + def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]): + value = pygfx.Color(value) + graphic.world_object.material.outline_color = value + self._value = graphic.world_object.material.outline_color + + event = FeatureEvent(type="outline_color", info={"value": value}) + self._call_event_handlers(event) + + +class TextOutlineThickness(GraphicFeature): + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + def set_value(self, graphic, value: float): + graphic.world_object.material.outline_thickness = value + self._value = graphic.world_object.material.outline_thickness + + event = FeatureEvent(type="outline_thickness", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py deleted file mode 100644 index fc90ef96f..000000000 --- a/fastplotlib/graphics/_features/_thickness.py +++ /dev/null @@ -1,46 +0,0 @@ -from ._base import GraphicFeature, FeatureEvent - - -class ThicknessFeature(GraphicFeature): - """ - Used by Line graphics for line material thickness. - - **event pick info:** - - ==================== ======================== ========================================================================= - key type description - ==================== ======================== ========================================================================= - "index" ``None`` not used - "new_data" ``float`` new thickness value - "collection-index" int the index of the graphic within the collection that triggered the event - "world_object" pygfx.WorldObject world object - ==================== ======================== ========================================================================= - """ - - def __init__(self, parent, thickness: float): - self._scene = None - super().__init__(parent, thickness) - - def _set(self, value: float): - value = self._parse_set_value(value) - - self._parent.world_object.material.thickness = value - self._feature_changed(key=None, new_data=value) - - def _feature_changed(self, key, new_data): - # this is a non-indexable feature so key=None - - pick_info = { - "index": None, - "collection-index": self._collection_index, - "world_object": self._parent.world_object, - "new_data": new_data, - } - - event_data = FeatureEvent(type="thickness", pick_info=pick_info) - - self._call_event_handlers(event_data) - - def __repr__(self) -> str: - s = f"ThicknessFeature for {self._parent}, call `.thickness()` to get value" - return s diff --git a/fastplotlib/graphics/_features/utils.py b/fastplotlib/graphics/_features/utils.py new file mode 100644 index 000000000..e2f6e3428 --- /dev/null +++ b/fastplotlib/graphics/_features/utils.py @@ -0,0 +1,87 @@ +import pygfx +import numpy as np + +from ._base import to_gpu_supported_dtype +from ...utils import make_pygfx_colors + + +def parse_colors( + colors: str | np.ndarray | list[str] | tuple[str], + n_colors: int | None, + alpha: float | None = None, +): + """ + + Parameters + ---------- + colors + n_colors + alpha + key + + Returns + ------- + + """ + + # if provided as a numpy array of str + if isinstance(colors, np.ndarray): + if colors.dtype.kind in ["U", "S"]: + colors = colors.tolist() + # if the color is provided as a numpy array + if isinstance(colors, np.ndarray): + if colors.shape == (4,): # single RGBA array + data = np.repeat(np.array([colors]), n_colors, axis=0) + # else assume it's already a stack of RGBA arrays, keep this directly as the data + elif colors.ndim == 2: + if colors.shape[1] != 4 and colors.shape[0] != n_colors: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + data = colors + else: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + + # if the color is provided as list or tuple + elif isinstance(colors, (list, tuple)): + # if iterable of str + if all([isinstance(val, str) for val in colors]): + if not len(colors) == n_colors: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` of `str` " + f"where the length of the iterable is the same as the number of datapoints." + ) + + data = np.vstack([np.array(pygfx.Color(c)) for c in colors]) + + # if it's a single RGBA array as a tuple/list + elif len(colors) == 4: + c = pygfx.Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + else: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " + f"an iterable of `str` with the same length as the number of datapoints." + ) + elif isinstance(colors, str): + if colors == "random": + data = np.random.rand(n_colors, 4) + data[:, -1] = alpha + else: + data = make_pygfx_colors(colors, n_colors) + else: + # assume it's a single color, use pygfx.Color to parse it + data = make_pygfx_colors(colors, n_colors) + + if alpha is not None: + if isinstance(alpha, float): + data[:, -1] = alpha + else: + raise TypeError("if alpha is provided it must be of type `float`") + + return to_gpu_supported_dtype(data) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py new file mode 100644 index 000000000..3727087cc --- /dev/null +++ b/fastplotlib/graphics/_positions_base.py @@ -0,0 +1,185 @@ +from typing import Any + +import numpy as np + +import pygfx +from ._base import Graphic +from ._features import ( + VertexPositions, + VertexColors, + UniformColor, + VertexCmap, + PointsSizesFeature, +) + + +class PositionsGraphic(Graphic): + """Base class for LineGraphic and ScatterGraphic""" + + @property + def data(self) -> VertexPositions: + """Get or set the vertex positions data""" + return self._data + + @data.setter + def data(self, value): + self._data[:] = value + + @property + def colors(self) -> VertexColors | pygfx.Color: + """Get or set the colors data""" + if isinstance(self._colors, VertexColors): + return self._colors + + elif isinstance(self._colors, UniformColor): + return self._colors.value + + @colors.setter + def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(self._colors, VertexColors): + self._colors[:] = value + + elif isinstance(self._colors, UniformColor): + self._colors.set_value(self, value) + + @property + def cmap(self) -> VertexCmap: + """Control the cmap, cmap transform, or cmap alpha""" + return self._cmap + + @cmap.setter + def cmap(self, name: str): + if self._cmap is None: + raise BufferError("Cannot use cmap with uniform_colors=True") + + self._cmap[:] = name + + def __init__( + self, + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_color: bool = False, + alpha: float = 1.0, + cmap: str | VertexCmap = None, + cmap_transform: np.ndarray = None, + isolated_buffer: bool = True, + *args, + **kwargs, + ): + if isinstance(data, VertexPositions): + self._data = data + else: + self._data = VertexPositions(data, isolated_buffer=isolated_buffer) + + if cmap_transform is not None and cmap is None: + raise ValueError("must pass `cmap` if passing `cmap_transform`") + + if cmap is not None: + # if a cmap is specified it overrides colors argument + if uniform_color: + raise TypeError("Cannot use cmap if uniform_color=True") + + if isinstance(cmap, str): + # make colors from cmap + if isinstance(colors, VertexColors): + # share buffer with existing colors instance for the cmap + self._colors = colors + self._colors._shared += 1 + else: + # create vertex colors buffer + self._colors = VertexColors("w", n_colors=self._data.value.shape[0]) + # make cmap using vertex colors buffer + self._cmap = VertexCmap( + self._colors, + cmap_name=cmap, + transform=cmap_transform, + alpha=alpha, + ) + elif isinstance(cmap, VertexCmap): + # use existing cmap instance + self._cmap = cmap + self._colors = cmap._vertex_colors + else: + raise TypeError( + "`cmap` argument must be a cmap name or an existing `VertexCmap` instance" + ) + else: + # no cmap given + if isinstance(colors, VertexColors): + # share buffer with existing colors instance + self._colors = colors + self._colors._shared += 1 + # blank colormap instance + self._cmap = VertexCmap( + self._colors, cmap_name=None, transform=None, alpha=alpha + ) + else: + if uniform_color: + if not isinstance(colors, str): # not a single color + if not len(colors) in [3, 4]: # not an RGB(A) array + raise TypeError( + "must pass a single color if using `uniform_colors=True`" + ) + self._colors = UniformColor(colors, alpha=alpha) + self._cmap = None + else: + self._colors = VertexColors( + colors, + n_colors=self._data.value.shape[0], + alpha=alpha, + ) + self._cmap = VertexCmap( + self._colors, cmap_name=None, transform=None, alpha=alpha + ) + + super().__init__(*args, **kwargs) + + def unshare_property(self, property: str): + """unshare a shared property. Experimental and untested!""" + if not isinstance(property, str): + raise TypeError + + f = getattr(self, property) + if f.shared == 0: + raise BufferError("Cannot detach an independent buffer") + + if property == "colors" and isinstance(property, VertexColors): + self._colors._buffer = pygfx.Buffer(self._colors.value.copy()) + self.world_object.geometry.colors = self._colors.buffer + self._colors._shared -= 1 + + elif property == "data": + self._data._buffer = pygfx.Buffer(self._data.value.copy()) + self.world_object.geometry.positions = self._data.buffer + self._data._shared -= 1 + + elif property == "sizes": + self._sizes._buffer = pygfx.Buffer(self._sizes.value.copy()) + self.world_object.geometry.positions = self._sizes.buffer + self._sizes._shared -= 1 + + def share_property( + self, property: VertexPositions | VertexColors | PointsSizesFeature + ): + """share a property from another graphic. Experimental and untested!""" + if isinstance(property, VertexPositions): + # TODO: check if this causes a memory leak + self._data._shared -= 1 + + self._data = property + self._data._shared += 1 + self.world_object.geometry.positions = self._data.buffer + + elif isinstance(property, VertexColors): + self._colors._shared -= 1 + + self._colors = property + self._colors._shared += 1 + self.world_object.geometry.colors = self._colors.buffer + + elif isinstance(property, PointsSizesFeature): + self._sizes._shared -= 1 + + self._sizes = property + self._sizes._shared += 1 + self.world_object.geometry.sizes = self._sizes.buffer diff --git a/fastplotlib/graphics/histogram.py b/fastplotlib/graphics/histogram.py deleted file mode 100644 index b78be39d3..000000000 --- a/fastplotlib/graphics/histogram.py +++ /dev/null @@ -1,114 +0,0 @@ -from warnings import warn -from typing import Union, Dict - -import numpy as np - -import pygfx - -from ._base import Graphic - - -class _HistogramBin(pygfx.Mesh): - def __int__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.bin_center: float = None - self.frequency: Union[int, float] = None - - -class HistogramGraphic(Graphic): - def __init__( - self, - data: np.ndarray = None, - bins: Union[int, str] = "auto", - pre_computed: Dict[str, np.ndarray] = None, - colors: np.ndarray = "w", - draw_scale_factor: float = 100.0, - draw_bin_width_scale: float = 1.0, - **kwargs, - ): - """ - Create a Histogram Graphic - - Parameters - ---------- - data: np.ndarray or None, optional - data to create a histogram from, can be ``None`` if pre-computed values are provided to ``pre_computed`` - - bins: int or str, default is "auto", optional - this is directly just passed to ``numpy.histogram`` - - pre_computed: dict in the form {"hist": vals, "bin_edges" : vals}, optional - pre-computed histogram values - - colors: np.ndarray, optional - - draw_scale_factor: float, default ``100.0``, optional - scale the drawing of the entire Graphic - - draw_bin_width_scale: float, default ``1.0`` - scale the drawing of the bin widths - - kwargs - passed to Graphic - """ - - if pre_computed is None: - self.hist, self.bin_edges = np.histogram(data, bins) - else: - if not set(pre_computed.keys()) == {"hist", "bin_edges"}: - raise ValueError( - "argument to `pre_computed` must be a `dict` with keys 'hist' and 'bin_edges'" - ) - if not all(isinstance(v, np.ndarray) for v in pre_computed.values()): - raise ValueError( - "argument to `pre_computed` must be a `dict` where the values are numpy.ndarray" - ) - self.hist, self.bin_edges = pre_computed["hist"], pre_computed["bin_edges"] - - self.bin_interval = (self.bin_edges[1] - self.bin_edges[0]) / 2 - self.bin_centers = (self.bin_edges + self.bin_interval)[:-1] - - # scale between 0 - draw_scale_factor - scaled_bin_edges = ( - (self.bin_edges - self.bin_edges.min()) / (np.ptp(self.bin_edges)) - ) * draw_scale_factor - - bin_interval_scaled = scaled_bin_edges[1] / 2 - # get the centers of the bins from the edges - x_positions_bins = (scaled_bin_edges + bin_interval_scaled)[:-1].astype( - np.float32 - ) - - n_bins = x_positions_bins.shape[0] - bin_width = (draw_scale_factor / n_bins) * draw_bin_width_scale - - self.hist = self.hist.astype(np.float32) - - for bad_val in [np.nan, np.inf, -np.inf]: - if bad_val in self.hist: - warn( - f"Problematic value <{bad_val}> found in histogram, replacing with zero" - ) - self.hist[self.hist == bad_val] = 0 - - data = np.vstack([x_positions_bins, self.hist]) - - super().__init__(data=data, colors=colors, n_colors=n_bins, **kwargs) - - self._world_object: pygfx.Group = pygfx.Group() - - for x_val, y_val, bin_center in zip( - x_positions_bins, self.hist, self.bin_centers - ): - geometry = pygfx.plane_geometry( - width=bin_width, - height=y_val, - ) - - material = pygfx.MeshBasicMaterial() - hist_bin_graphic = _HistogramBin(geometry, material) - hist_bin_graphic.position.set(x_val, (y_val) / 2, 0) - hist_bin_graphic.bin_center = bin_center - hist_bin_graphic.frequency = y_val - - self.world_object.add(hist_bin_graphic) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ce736dab2..d6576c12d 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,202 +1,77 @@ from typing import * -from math import ceil -from itertools import product import weakref -import numpy as np - import pygfx from ..utils import quick_min_max -from ._base import Graphic, Interaction +from ._base import Graphic from .selectors import LinearSelector, LinearRegionSelector from ._features import ( - ImageCmapFeature, - ImageDataFeature, - HeatmapDataFeature, - HeatmapCmapFeature, - to_gpu_supported_dtype, + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, ) -class _AddSelectorsMixin: - def add_linear_selector( - self, selection: int = None, padding: float = None, **kwargs - ) -> LinearSelector: - """ - Adds a :class:`.LinearSelector`. - - Parameters - ---------- - selection: int, optional - initial position of the selector - - padding: float, optional - pad the length of the selector - - kwargs: - passed to :class:`.LinearSelector` - - Returns - ------- - LinearSelector - - """ - - # default padding is 15% the height or width of the image - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) - - if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) - - selector = LinearSelector( - selection=selection, - limits=limits, - end_points=end_points, - parent=weakref.proxy(self), - **kwargs, - ) - - self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 - - return weakref.proxy(selector) - - def add_linear_region_selector( - self, padding: float = None, **kwargs - ) -> LinearRegionSelector: - """ - Add a :class:`.LinearRegionSelector`. - - Parameters - ---------- - padding: float, optional - Extends the linear selector along the y-axis to make it easier to interact with. - - kwargs: optional - passed to ``LinearRegionSelector`` +class _ImageTile(pygfx.Image): + """ + Similar to pygfx.Image, only difference is that it modifies the pick_info + by adding the data row start indices that correspond to this chunk of the big image + """ - Returns - ------- - LinearRegionSelector - linear selection graphic + def __init__( + self, + geometry, + material, + data_slice: tuple[slice, slice], + chunk_index: tuple[int, int], + **kwargs, + ): + super().__init__(geometry, material, **kwargs) - """ + self._data_slice = data_slice + self._chunk_index = chunk_index - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + def _wgpu_get_pick_info(self, pick_value): + pick_info = super()._wgpu_get_pick_info(pick_value) - # create selector - selector = LinearRegionSelector( - bounds=bounds_init, - limits=limits, - size=size, - origin=origin, - parent=weakref.proxy(self), - fill_color=(0, 0, 0.35, 0.2), - **kwargs, + data_row_start, data_col_start = ( + self.data_slice[0].start, + self.data_slice[1].start, ) - self._plot_area.add_graphic(selector, center=False) - # so that it is above this graphic - selector.position_z = self.position_z + 3 - - # PlotArea manages this for garbage collection etc. just like all other Graphics - # so we should only work with a proxy on the user-end - return weakref.proxy(selector) - - # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args(self, padding: float, **kwargs): - # computes initial bounds, limits, size and origin of linear selectors - data = self.data() - - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" - - if padding is None: - if axis == "x": - # based on number of rows - padding = int(data.shape[0] * 0.15) - elif axis == "y": - # based on number of columns - padding = int(data.shape[1] * 0.15) - - if axis == "x": - offset = self.position_x - # x limits, number of columns - limits = (offset, data.shape[1] - 1) - - # size is number of rows + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[0] + padding - - # initial position of the selector - # center row - position_y = data.shape[0] / 2 - - # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) - - # endpoints of the data range - # used by linear selector but not linear region - # padding, n_rows + padding - end_points = (0 - padding, data.shape[0] + padding) - else: - offset = self.position_y - # y limits - limits = (offset, data.shape[0] - 1) - - # width + padding - # used by LinearRegionSelector but not LinearSelector - size = data.shape[1] + padding - - # initial position of the selector - position_x = data.shape[1] / 2 + # add the actual data row and col start indices + x, y = pick_info["index"] + x += data_col_start + y += data_row_start + pick_info["index"] = (x, y) - # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) + xp, yp = pick_info["pixel_coord"] + xp += data_col_start + yp += data_row_start + pick_info["pixel_coord"] = (xp, yp) - # endpoints of the data range - # used by linear selector but not linear region - end_points = (0 - padding, data.shape[1] + padding) - - # initial bounds are 20% of the limits range - # used by LinearRegionSelector but not LinearSelector - bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) + # add row chunk and col chunk index to pick_info dict + return { + **pick_info, + "data_slice": self.data_slice, + "chunk_index": self.chunk_index, + } - return bounds_init, limits, size, origin, axis, end_points + @property + def data_slice(self) -> tuple[slice, slice]: + return self._data_slice - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area + @property + def chunk_index(self) -> tuple[int, int]: + return self._chunk_index -class ImageGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = {"data", "cmap", "present"} +class ImageGraphic(Graphic): + _features = {"data", "cmap", "vmin", "vmax", "interpolation", "cmap_interpolation"} def __init__( self, @@ -204,9 +79,9 @@ def __init__( vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs, ): """ @@ -216,8 +91,7 @@ def __init__( ---------- data: array-like array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` + | shape must be ``[x_dim, y_dim]`` vmin: int, optional minimum value for color scaling, calculated from data if not provided @@ -226,265 +100,289 @@ def __init__( maximum value for color scaling, calculated from data if not provided cmap: str, optional, default "plasma" - colormap to use to display the image data, ignored if data is RGB + colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" + isolated_buffer: bool, default True If True, initialize a buffer with the same shape as the input data and then set the data, useful if the data arrays are ready-only such as memmaps. If False, the input array is itself used as the buffer. - args: - additional arguments passed to Graphic - kwargs: additional keyword arguments passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the data buffer displayed in the ImageGraphic - - **cmap**: :class:`.ImageCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) - data = to_gpu_supported_dtype(data) + world_object = pygfx.Group() - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + # texture array that manages the textures on the GPU for displaying this image + self._data = TextureArray(data, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - texture = pygfx.Texture(buffer_init, dim=2) + # other graphic features + self._vmin = ImageVmin(vmin) + self._vmax = ImageVmax(vmax) - geometry = pygfx.Geometry(grid=texture) + self._cmap = ImageCmap(cmap) - self.cmap = ImageCmapFeature(self, cmap) + self._interpolation = ImageInterpolation(interpolation) + self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - # if data is RGB or RGBA - if data.ndim > 2: - material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), map_interpolation=filter, pick_write=True - ) - # if data is just 2D without color information, use colormap LUT + # use cmap if not RGB + if self._data.value.ndim == 2: + _map = self._cmap.texture else: - material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), - map=self.cmap(), - map_interpolation=filter, - pick_write=True, - ) + _map = None - world_object = pygfx.Image(geometry, material) + # one common material is used for every Texture chunk + self._material = pygfx.ImageBasicMaterial( + clim=(vmin, vmax), + map=_map, + interpolation=self._interpolation.value, + map_interpolation=self._cmap_interpolation.value, + pick_write=True, + ) - self._set_world_object(world_object) + # iterate through each texture chunk and create + # an _ImageTIle, offset the tile using the data indices + for texture, chunk_index, data_slice in self._data: - self.cmap.vmin = vmin - self.cmap.vmax = vmax + # create an ImageTile using the texture for this chunk + img = _ImageTile( + geometry=pygfx.Geometry(grid=texture), + material=self._material, + data_slice=data_slice, # used to parse pick_info + chunk_index=chunk_index, + ) - self.data = ImageDataFeature(self, data) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data + # row and column start index for this chunk + data_row_start = data_slice[0].start + data_col_start = data_slice[1].start - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + # offset tile position using the indices from the big data array + # that correspond to this chunk + img.world.x = data_col_start + img.world.y = data_row_start - def reset_feature(self, feature: str): - pass + world_object.add(img) + self._set_world_object(world_object) -class _ImageTile(pygfx.Image): - """ - Similar to pygfx.Image, only difference is that it contains a few properties to keep track of - row chunk index, column chunk index - """ + @property + def data(self) -> TextureArray: + """Get or set the image data""" + return self._data - def _wgpu_get_pick_info(self, pick_value): - pick_info = super()._wgpu_get_pick_info(pick_value) + @data.setter + def data(self, data): + self._data[:] = data - # add row chunk and col chunk index to pick_info dict - return { - **pick_info, - "row_chunk_index": self.row_chunk_index, - "col_chunk_index": self.col_chunk_index, - } + @property + def cmap(self) -> str: + """colormap name""" + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + self._cmap.set_value(self, name) @property - def row_chunk_index(self) -> int: - return self._row_chunk_index + def vmin(self) -> float: + """lower contrast limit""" + return self._vmin.value - @row_chunk_index.setter - def row_chunk_index(self, index: int): - self._row_chunk_index = index + @vmin.setter + def vmin(self, value: float): + self._vmin.set_value(self, value) @property - def col_chunk_index(self) -> int: - return self._col_chunk_index + def vmax(self) -> float: + """upper contrast limit""" + return self._vmax.value - @col_chunk_index.setter - def col_chunk_index(self, index: int): - self._col_chunk_index = index + @vmax.setter + def vmax(self, value: float): + self._vmax.set_value(self, value) + @property + def interpolation(self) -> str: + """image data interpolation method""" + return self._interpolation.value -class HeatmapGraphic(Graphic, Interaction, _AddSelectorsMixin): - feature_events = {"data", "cmap", "present"} + @interpolation.setter + def interpolation(self, value: str): + self._interpolation.set_value(self, value) - def __init__( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = "plasma", - filter: str = "nearest", - chunk_size: int = 8192, - isolated_buffer: bool = True, - *args, - **kwargs, - ): - """ - Create an Image Graphic + @property + def cmap_interpolation(self) -> str: + """cmap interpolation method""" + return self._cmap_interpolation.value - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` + @cmap_interpolation.setter + def cmap_interpolation(self, value: str): + self._cmap_interpolation.set_value(self, value) - vmin: int, optional - minimum value for color scaling, calculated from data if not provided + def reset_vmin_vmax(self): + """ + Reset the vmin, vmax by estimating it from the data - vmax: int, optional - maximum value for color scaling, calculated from data if not provided + Returns + ------- + None - cmap: str, optional, default "plasma" - colormap to use to display the data + """ - filter: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" + vmin, vmax = quick_min_max(self._data.value) + self.vmin = vmin + self.vmax = vmax - chunk_size: int, default 8192, max 8192 - chunk size for each tile used to make up the heatmap texture + def add_linear_selector( + self, selection: int = None, axis: str = "x", padding: float = None, **kwargs + ) -> LinearSelector: + """ + Adds a :class:`.LinearSelector`. - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer. + Parameters + ---------- + selection: int, optional + initial position of the selector - args: - additional arguments passed to Graphic + padding: float, optional + pad the length of the selector kwargs: - additional keyword arguments passed to Graphic + passed to :class:`.LinearSelector` - Features - -------- + Returns + ------- + LinearSelector + + """ - **data**: :class:`.HeatmapDataFeature` - Manages the data buffer displayed in the HeatmapGraphic + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError("`axis` must be one of 'x' | 'y'") - **cmap**: :class:`.HeatmapCmapFeature` - Manages the colormap + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene + if selection is None: + selection = limits[0] - """ + if selection < limits[0] or selection > limits[1]: + raise ValueError( + f"the passed selection: {selection} is beyond the limits: {limits}" + ) - super().__init__(*args, **kwargs) + selector = LinearSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), + **kwargs, + ) - if chunk_size > 8192: - raise ValueError("Maximum chunk size is 8192") + self._plot_area.add_graphic(selector, center=False) - data = to_gpu_supported_dtype(data) + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - # TODO: we need to organize and do this better - if isolated_buffer: - # initialize a buffer with the same shape as the input data - # we do not directly use the input data array as the buffer - # because if the input array is a read-only type, such as - # numpy memmaps, we would not be able to change the image data - buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) - else: - buffer_init = data + return weakref.proxy(selector) - row_chunks = range(ceil(data.shape[0] / chunk_size)) - col_chunks = range(ceil(data.shape[1] / chunk_size)) + def add_linear_region_selector( + self, + selection: tuple[float, float] = None, + axis: str = "x", + padding: float = 0.0, + fill_color=(0, 0, 0.35, 0.2), + **kwargs, + ) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. - chunks = list(product(row_chunks, col_chunks)) - # chunks is the index position of each chunk + Parameters + ---------- + selection: (float, float) + initial (min, max) of the selection - start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks] - stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs] + axis: "x" | "y" + axis the selector can move along - world_object = pygfx.Group() - self._set_world_object(world_object) + padding: float, default 100.0 + Extends the linear selector along the perpendicular axis to make it easier to interact with. - if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + kwargs + passed to ``LinearRegionSelector`` - self.cmap = HeatmapCmapFeature(self, cmap) - self._material = pygfx.ImageBasicMaterial( - clim=(vmin, vmax), - map=self.cmap(), - map_interpolation=filter, - pick_write=True, - ) + Returns + ------- + LinearRegionSelector + linear selection graphic - for start, stop, chunk in zip(start_ixs, stop_ixs, chunks): - row_start, col_start = start - row_stop, col_stop = stop + """ - # x and y positions of the Tile in world space coordinates - y_pos, x_pos = row_start, col_start + if axis == "x": + size = self._data.value.shape[0] + center = size / 2 + limits = (0, self._data.value.shape[1]) + elif axis == "y": + size = self._data.value.shape[1] + center = size / 2 + limits = (0, self._data.value.shape[0]) + else: + raise ValueError("`axis` must be one of 'x' | 'y'") - texture = pygfx.Texture( - buffer_init[row_start:row_stop, col_start:col_stop], dim=2 - ) - geometry = pygfx.Geometry(grid=texture) - # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) + # default padding is 25% the height or width of the image + if padding is None: + size *= 1.25 + else: + size += padding - img = _ImageTile(geometry, self._material) + if selection is None: + selection = limits[0], int(limits[1] * 0.25) - # row and column chunk index for this Tile - img.row_chunk_index = chunk[0] - img.col_chunk_index = chunk[1] + if padding is None: + size *= 1.25 - img.world.x = x_pos - img.world.y = y_pos + else: + size += padding - self.world_object.add(img) + selector = LinearRegionSelector( + selection=selection, + limits=limits, + size=size, + center=center, + axis=axis, + fill_color=fill_color, + parent=weakref.proxy(self), + **kwargs, + ) - self.data = HeatmapDataFeature(self, buffer_init) - # TODO: we need to organize and do this better - if isolated_buffer: - # if the buffer was initialized with zeros - # set it with the actual data - self.data = data + self._plot_area.add_graphic(selector, center=False) - def set_feature(self, feature: str, new_data: Any, indices: Any): - pass + # place above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) - def reset_feature(self, feature: str): - pass + return weakref.proxy(selector) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0371fe59b..d0a8cc336 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -5,26 +5,24 @@ import pygfx -from ..utils import parse_cmap_values -from ._base import Graphic, Interaction, PreviouslyModifiedData -from ._features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from ._positions_base import PositionsGraphic from .selectors import LinearRegionSelector, LinearSelector +from ._features import Thickness -class LineGraphic(Graphic, Interaction): - feature_events = {"data", "colors", "cmap", "thickness", "present"} +class LineGraphic(PositionsGraphic): + _features = {"data", "colors", "cmap", "thickness"} def __init__( self, data: Any, thickness: float = 2.0, colors: str | np.ndarray | Iterable = "w", + uniform_color: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: np.ndarray | Iterable = None, - z_position: float = None, - collection_index: int = None, - *args, + cmap_transform: np.ndarray | Iterable = None, + isolated_buffer: bool = True, **kwargs, ): """ @@ -42,101 +40,90 @@ def __init__( specify colors as a single human-readable string, a single RGBA array, or an iterable of strings or RGBA arrays - cmap: str, optional - apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors" - - cmap_values: 1D array-like or Iterable of numerical values, optional - if provided, these values are used to map the colors from the cmap + uniform_color: bool, default ``False`` + if True, uses a uniform buffer for the line color, + basically saves GPU VRAM when the entire line has a single color alpha: float, optional, default 1.0 alpha value for the colors - z_position: float, optional - z-axis position for placing the graphic + cmap: str, optional + apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors" - args - passed to Graphic + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap - kwargs + **kwargs passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. - - **thickness**: :class:`.ThicknessFeature` - Manages the thickness feature of the lines. - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` - """ - self.data = PointsDataFeature(self, data, collection_index=collection_index) - - if cmap is not None: - n_datapoints = self.data().shape[0] - - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) - - self.colors = ColorFeature( - self, - colors, - n_colors=self.data().shape[0], + super().__init__( + data=data, + colors=colors, + uniform_color=uniform_color, alpha=alpha, - collection_index=collection_index, - ) - - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values + cmap=cmap, + cmap_transform=cmap_transform, + isolated_buffer=isolated_buffer, + **kwargs, ) - super().__init__(*args, **kwargs) + self._thickness = Thickness(thickness) if thickness < 1.1: - material = pygfx.LineThinMaterial + MaterialCls = pygfx.LineThinMaterial else: - material = pygfx.LineMaterial - - self.thickness = ThicknessFeature(self, thickness) + MaterialCls = pygfx.LineMaterial + + if uniform_color: + geometry = pygfx.Geometry(positions=self._data.buffer) + material = MaterialCls( + thickness=self.thickness, + color_mode="uniform", + color=self.colors, + pick_write=True, + ) + else: + material = MaterialCls( + thickness=self.thickness, color_mode="vertex", pick_write=True + ) + geometry = pygfx.Geometry( + positions=self._data.buffer, colors=self._colors.buffer + ) - world_object: pygfx.Line = pygfx.Line( - # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()), - material=material( - thickness=self.thickness(), color_mode="vertex", pick_write=True - ), - ) + world_object: pygfx.Line = pygfx.Line(geometry=geometry, material=material) self._set_world_object(world_object) - if z_position is not None: - self.position_z = z_position + @property + def thickness(self) -> float: + """line thickness""" + return self._thickness.value + + @thickness.setter + def thickness(self, value: float): + self._thickness.set_value(self, value) def add_linear_selector( - self, selection: int = None, padding: float = 50, **kwargs + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs ) -> LinearSelector: """ Adds a linear selector. Parameters ---------- - selection: int - initial position of the selector + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided - padding: float - pad the length of the selector + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with. kwargs passed to :class:`.LinearSelector` @@ -147,38 +134,36 @@ def add_linear_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) + selection = bounds_init[0] selector = LinearSelector( selection=selection, limits=limits, - end_points=end_points, - parent=self, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 + + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, **kwargs + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, ) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -186,8 +171,14 @@ def add_linear_region_selector( Parameters ---------- - padding: float, default 100.0 - Extends the linear selector along the y-axis to make it easier to interact with. + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. kwargs passed to ``LinearRegionSelector`` @@ -199,118 +190,61 @@ def add_linear_region_selector( """ - ( - bounds_init, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init # create selector selector = LinearRegionSelector( - bounds=bounds_init, + selection=selection, limits=limits, size=size, - origin=origin, - parent=self, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - # so that it is below this graphic - selector.position_z = self.position_z - 1 + + # place selector below this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) # PlotArea manages this for garbage collection etc. just like all other Graphics # so we should only work with a proxy on the user-end return weakref.proxy(selector) # TODO: this method is a bit of a mess, can refactor later - def _get_linear_selector_init_args(self, padding: float, **kwargs): - # computes initial bounds, limits, size and origin of linear selectors - data = self.data() + def _get_linear_selector_init_args( + self, axis: str, padding + ) -> tuple[tuple[float, float], tuple[float, float], float, float]: + # computes args to create selectors + n_datapoints = self.data.value.shape[0] + value_25p = int(n_datapoints / 4) - if "axis" in kwargs.keys(): - axis = kwargs["axis"] - else: - axis = "x" + # remove any nans + data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)] if axis == "x": - offset = self.position_x - # x limits - limits = (data[0, 0] + offset, data[-1, 0] + offset) + # xvals + axis_vals = data[:, 0] - # height + padding - size = np.ptp(data[:, 1]) + padding + # yvals to get size and center + magn_vals = data[:, 1] + elif axis == "y": + axis_vals = data[:, 1] + magn_vals = data[:, 0] - # initial position of the selector - position_y = (data[:, 1].min() + data[:, 1].max()) / 2 + bounds_init = axis_vals[0], axis_vals[value_25p] + limits = axis_vals[0], axis_vals[-1] - # need y offset too for this - origin = (limits[0] - offset, position_y + self.position_y) + # width or height of selector + size = int(np.ptp(magn_vals) * 1.5 + padding) - # endpoints of the data range - # used by linear selector but not linear region - end_points = ( - self.data()[:, 1].min() - padding, - self.data()[:, 1].max() + padding, - ) - else: - offset = self.position_y - # y limits - limits = (data[0, 1] + offset, data[-1, 1] + offset) - - # width + padding - size = np.ptp(data[:, 0]) + padding - - # initial position of the selector - position_x = (data[:, 0].min() + data[:, 0].max()) / 2 - - # need x offset too for this - origin = (position_x + self.position_x, limits[0] - offset) - - end_points = ( - self.data()[:, 0].min() - padding, - self.data()[:, 0].max() + padding, - ) - - # initial bounds are 20% of the limits range - bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) - - return bounds_init, limits, size, origin, axis, end_points + # center of selector along the other axis + center = np.nanmean(magn_vals) - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - def set_feature(self, feature: str, new_data: Any, indices: Any = None): - if not hasattr(self, "_previous_data"): - self._previous_data = dict() - elif hasattr(self, "_previous_data"): - self.reset_feature(feature) - - feature_instance = getattr(self, feature) - if indices is not None: - previous = feature_instance[indices].copy() - feature_instance[indices] = new_data - else: - previous = feature_instance._data.copy() - feature_instance._set(new_data) - if feature in self._previous_data.keys(): - self._previous_data[feature].data = previous - self._previous_data[feature].indices = indices - else: - self._previous_data[feature] = PreviouslyModifiedData( - data=previous, indices=indices - ) - - def reset_feature(self, feature: str): - if feature not in self._previous_data.keys(): - return - - prev_ixs = self._previous_data[feature].indices - feature_instance = getattr(self, feature) - if prev_ixs is not None: - feature_instance[prev_ixs] = self._previous_data[feature].data - else: - feature_instance._set(self._previous_data[feature].data) + return bounds_init, limits, size, center diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index da74cc54e..92aad56b2 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -1,5 +1,4 @@ from typing import * -from copy import deepcopy import weakref import numpy as np @@ -7,27 +6,137 @@ import pygfx from ..utils import parse_cmap_values -from ._base import Interaction, PreviouslyModifiedData, GraphicCollection -from ._features import GraphicFeature +from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature from .line import LineGraphic from .selectors import LinearRegionSelector, LinearSelector -class LineCollection(GraphicCollection, Interaction): - child_type = LineGraphic.__name__ +class _LineCollectionProperties: + """Mix-in class for LineCollection properties""" + + @property + def colors(self) -> CollectionFeature: + """get or set colors of lines in the collection""" + return CollectionFeature(self.graphics, "colors") + + @colors.setter + def colors(self, values: str | np.ndarray | tuple[float] | list[float] | list[str]): + if isinstance(values, str): + # set colors of all lines to one str color + for g in self: + g.colors = values + return + + elif all(isinstance(v, str) for v in values): + # individual str colors for each line + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self.graphics, values): + g.colors = v + + return + + if isinstance(values, np.ndarray): + if values.ndim == 2: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + return + + elif len(values) == 4: + # assume RGBA + self.colors[:] = values + + else: + # assume individual colors for each + for g, v in zip(self, values): + g.colors = v + + @property + def data(self) -> CollectionFeature: + """get or set data of lines in the collection""" + return CollectionFeature(self.graphics, "data") + + @data.setter + def data(self, values): + for g, v in zip(self, values): + g.data = v + + @property + def cmap(self) -> CollectionFeature: + """ + Get or set a cmap along the line collection. + + Optionally set using a tuple ("cmap", , ) to set the transform and/or alpha. + Example: + + line_collection.cmap = ("jet", sine_transform_vals, 0.7) + + """ + return CollectionFeature(self.graphics, "cmap") + + @cmap.setter + def cmap(self, args): + if isinstance(args, str): + name = args + transform, alpha = None, 1.0 + if len(args) == 1: + name = args[0] + transform, alpha = None, None + + elif len(args) == 2: + name, transform = args + alpha = None + + elif len(args) == 3: + name, transform, alpha = args + + colors = parse_cmap_values( + n_colors=len(self), cmap_name=name, transform=transform + ) + colors[:, -1] = alpha + self.colors = colors + + @property + def thickness(self) -> np.ndarray: + """get or set the thickness of the lines""" + return np.asarray([g.thickness for g in self]) + + @thickness.setter + def thickness(self, values: np.ndarray | list[float]): + if not len(values) == len(self): + raise IndexError + + for g, v in zip(self, values): + g.thickness = v + + +class LineCollectionIndexer(CollectionIndexer, _LineCollectionProperties): + """Indexer for line collections""" + + pass + + +class LineCollection(GraphicCollection, _LineCollectionProperties): + _child_type = LineGraphic + _indexer = LineCollectionIndexer def __init__( self, - data: List[np.ndarray], - z_offset: Iterable[float | int] | float | int = None, - thickness: float | Iterable[float] = 2.0, - colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", + data: np.ndarray | List[np.ndarray], + thickness: float | Sequence[float] = 2.0, + colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Iterable[str] | str = None, - cmap_values: np.ndarray | List = None, + cmap: Sequence[str] | str = None, + cmap_transform: np.ndarray | List = None, name: str = None, - metadata: Iterable[Any] | np.ndarray = None, - *args, + names: list[str] = None, + metadata: Any = None, + metadatas: Sequence[Any] | np.ndarray = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, **kwargs, ): """ @@ -35,13 +144,11 @@ def __init__( Parameters ---------- - data: list of array-like or array - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] + data: list of array-like + List or array-like of multiple line data to plot - z_offset: Iterable of float or float, optional - | if ``float`` | ``int``, single offset will be used for all lines - | if ``list`` of ``float`` | ``int``, each value will apply to the individual lines + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines @@ -63,52 +170,58 @@ def __init__( .. note:: ``cmap`` overrides any arguments passed to ``colors`` - cmap_values: 1D array-like or Iterable of numerical values, optional + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap name: str, optional - name of the line collection + name of the line collection as a whole - metadata: Iterable or array - metadata associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - args - passed to GraphicCollection + metadata: Any + meatadata associated with the collection as a whole - kwargs - passed to GraphicCollection - - Features - -------- + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` - Collections support the same features as the underlying graphic. You just have to slice the selection. + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - See :class:`LineGraphic` details on the features. + kwargs_collection + kwargs for the collection, passed to GraphicCollection """ - super().__init__(name) + super().__init__(name=name, metadata=metadata, **kwargs) - if not isinstance(z_offset, (float, int)) and z_offset is not None: - if len(data) != len(z_offset): + if not isinstance(thickness, (float, int)): + if len(thickness) != len(data): raise ValueError( - "z_position must be a single float or an iterable with same length as data" + f"len(thickness) != len(data)\n" f"{len(thickness)} != {len(data)}" ) - if not isinstance(thickness, (float, int)): - if len(thickness) != len(data): + if names is not None: + if len(names) != len(data): raise ValueError( - "args must be a single float or an iterable with same length as data" + f"len(names) != len(data)\n" f"{len(names)} != {len(data)}" ) - if metadata is not None: - if len(metadata) != len(data): + if metadatas is not None: + if len(metadatas) != len(data): raise ValueError( - f"len(metadata) != len(data)\n" f"{len(metadata)} != {len(data)}" + f"len(metadata) != len(data)\n" f"{len(metadatas)} != {len(data)}" ) - self._cmap_values = cmap_values + if kwargs_lines is not None: + if len(kwargs_lines) != len(data): + raise ValueError( + f"len(kwargs_lines) != len(data)\n" + f"{len(kwargs_lines)} != {len(data)}" + ) + + self._cmap_transform = cmap_transform self._cmap_str = cmap # cmap takes priority over colors @@ -116,7 +229,7 @@ def __init__( # cmap across lines if isinstance(cmap, str): colors = parse_cmap_values( - n_colors=len(data), cmap_name=cmap, cmap_values=cmap_values + n_colors=len(data), cmap_name=cmap, transform=cmap_transform ) single_color = False cmap = None @@ -175,14 +288,12 @@ def __init__( "or must be a tuple/list of colors represented by a string with the same length as the data" ) + if kwargs_lines is None: + kwargs_lines = dict() + self._set_world_object(pygfx.Group()) for i, d in enumerate(data): - if isinstance(z_offset, list): - _z = z_offset[i] - else: - _z = z_offset - if isinstance(thickness, list): _s = thickness[i] else: @@ -199,66 +310,51 @@ def __init__( _cmap = cmap[i] _c = None - if metadata is not None: - _m = metadata[i] + if metadatas is not None: + _m = metadatas[i] else: _m = None + if names is not None: + _name = names[i] + else: + _name = None + lg = LineGraphic( data=d, thickness=_s, colors=_c, - z_position=_z, + uniform_color=uniform_colors, cmap=_cmap, - collection_index=i, + name=_name, metadata=_m, + isolated_buffer=isolated_buffer, + **kwargs_lines, ) - self.add_graphic(lg, reset_index=False) - - @property - def cmap(self) -> str: - return self._cmap_str - - @cmap.setter - def cmap(self, cmap: str): - colors = parse_cmap_values( - n_colors=len(self), cmap_name=cmap, cmap_values=self.cmap_values - ) - - for i, g in enumerate(self.graphics): - g.colors = colors[i] + self.add_graphic(lg) - self._cmap_str = cmap - - @property - def cmap_values(self) -> np.ndarray: - return self._cmap_values - - @cmap_values.setter - def cmap_values(self, values: np.ndarray | Iterable): - colors = parse_cmap_values( - n_colors=len(self), cmap_name=self.cmap, cmap_values=values - ) - - for i, g in enumerate(self.graphics): - g.colors = colors[i] - - self._cmap_values = values + def __getitem__(self, item) -> LineCollectionIndexer: + return super().__getitem__(item) def add_linear_selector( - self, selection: int = None, padding: float = 50, **kwargs + self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs ) -> LinearSelector: """ - Adds a :class:`.LinearSelector` . + Adds a linear selector. Parameters ---------- - selection: int - initial position of the selector + Parameters + ---------- + selection: float, optional + selected point on the linear selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on - padding: float - pad the length of the selector + 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` @@ -269,46 +365,51 @@ def add_linear_selector( """ - ( - bounds, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) if selection is None: - selection = limits[0] - - if selection < limits[0] or selection > limits[1]: - raise ValueError( - f"the passed selection: {selection} is beyond the limits: {limits}" - ) + selection = bounds_init[0] selector = LinearSelector( selection=selection, limits=limits, - end_points=end_points, - parent=self, + size=size, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z + 1 + + # place selector above this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1) return weakref.proxy(selector) def add_linear_region_selector( - self, padding: float = 100.0, **kwargs + self, + selection: tuple[float, float] = None, + padding: float = 0.0, + axis: str = "x", + **kwargs, ) -> LinearRegionSelector: """ - Add a :class:`.LinearRegionSelector` + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. Parameters ---------- - padding: float, default 100.0 - Extends the linear selector along the y-axis to make it easier to interact with. + selection: (float, float), optional + the starting bounds of the linear region selector, computed from data if not provided + + axis: str, default "x" + axis that the selector resides on + + padding: float, default 0.0 + Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with. kwargs passed to ``LinearRegionSelector`` @@ -320,155 +421,62 @@ def add_linear_region_selector( """ - ( - bounds, - limits, - size, - origin, - axis, - end_points, - ) = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, center = self._get_linear_selector_init_args( + axis, padding + ) + + if selection is None: + selection = bounds_init + # create selector selector = LinearRegionSelector( - bounds=bounds, + selection=selection, limits=limits, size=size, - origin=origin, - parent=self, + center=center, + axis=axis, + parent=weakref.proxy(self), **kwargs, ) self._plot_area.add_graphic(selector, center=False) - selector.position_z = self.position_z - 1 - - return weakref.proxy(selector) - def _get_linear_selector_init_args(self, padding, **kwargs): - bounds_init = list() - limits = list() - sizes = list() - origin = list() - end_points = list() - - for g in self.graphics: - ( - _bounds_init, - _limits, - _size, - _origin, - axis, - _end_points, - ) = g._get_linear_selector_init_args(padding=0, **kwargs) - - bounds_init.append(_bounds_init) - limits.append(_limits) - sizes.append(_size) - origin.append(_origin) - end_points.append(_end_points) - - # set the init bounds using the extents of the collection - b = np.vstack(bounds_init) - bounds = (b[:, 0].min(), b[:, 1].max()) - - # set the limits using the extents of the collection - limits = np.vstack(limits) - limits = (limits[:, 0].min(), limits[:, 1].max()) - - # stack endpoints - end_points = np.vstack(end_points) - # use the min endpoint for index 0, highest endpoint for index 1 - end_points = [ - end_points[:, 0].min() - padding, - end_points[:, 1].max() + padding, - ] - - # TODO: refactor this to use `LineStack.graphics[-1].position.y` - if isinstance(self, LineStack): - stack_offset = self.separation * len(sizes) - # sum them if it's a stack - size = sum(sizes) - # add the separations - size += stack_offset - - # a better way to get the max y value? - # graphics y-position + data y-max + padding - end_points[1] = ( - self.graphics[-1].position_y - + self.graphics[-1].data()[:, 1].max() - + padding - ) + # place selector below this graphic + selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1) - else: - # just the biggest one if not stacked - size = max(sizes) + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return weakref.proxy(selector) - size += padding + def _get_linear_selector_init_args(self, axis, padding): + # use bbox to get size and center + bbox = self.world_object.get_world_bounding_box() if axis == "x": - o = np.vstack(origin) - origin_y = (o[:, 1].min() + o[:, 1].max()) / 2 - origin = (limits[0], origin_y) - else: - o = np.vstack(origin) - origin_x = (o[:, 0].min() + o[:, 0].max()) / 2 - origin = (origin_x, limits[0]) - - return bounds, limits, size, origin, axis, end_points - - def _fpl_add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - def set_feature(self, feature: str, new_data: Any, indices: Any): - # if single value force to be an array of size 1 - if isinstance(indices, (np.integer, int)): - indices = np.array([indices]) - if not hasattr(self, "_previous_data"): - self._previous_data = dict() - elif hasattr(self, "_previous_data"): - if feature in self._previous_data.keys(): - # for now assume same index won't be changed with diff data - # I can't think of a usecase where we'd have to check the data too - # so unless there is a bug we keep it like this - if self._previous_data[feature].indices == indices: - return # nothing to change, and this allows bidirectional linking without infinite recursion - - self.reset_feature(feature) + xdata = np.array(self.data[:, 0]) + xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata)) + value_25p = (xmax - xmin) / 4 - # coll_feature = getattr(self[indices], feature) + bounds = (xmin, value_25p) + limits = (xmin, xmax) + # size from orthogonal axis + size = bbox[:, 1].ptp() * 1.5 + # center on orthogonal axis + center = bbox[:, 1].mean() - data = list() + elif axis == "y": + ydata = np.array(self.data[:, 1]) + xmin, xmax = (np.nanmin(ydata), np.nanmax(ydata)) + value_25p = (xmax - xmin) / 4 - for graphic in self.graphics[indices]: - feature_instance: GraphicFeature = getattr(graphic, feature) - data.append(feature_instance()) - - # later we can think about multi-index events - previous_data = deepcopy(data[0]) - - if feature in self._previous_data.keys(): - self._previous_data[feature].data = previous_data - self._previous_data[feature].indices = indices - else: - self._previous_data[feature] = PreviouslyModifiedData( - data=previous_data, indices=indices - ) - - # finally set the new data - # this MUST occur after setting the previous data attribute to prevent recursion - # since calling `feature._set()` triggers all the feature callbacks - feature_instance._set(new_data) - - def reset_feature(self, feature: str): - if feature not in self._previous_data.keys(): - return + bounds = (xmin, value_25p) + limits = (xmin, xmax) - # implemented for a single index at moment - prev_ixs = self._previous_data[feature].indices - coll_feature = getattr(self[prev_ixs], feature) + size = bbox[:, 0].ptp() * 1.5 + # center on orthogonal axis + center = bbox[:, 0].mean() - coll_feature.block_events(True) - coll_feature._set(self._previous_data[feature].data) - coll_feature.block_events(False) + return bounds, limits, size, center axes = {"x": 0, "y": 1, "z": 2} @@ -478,17 +486,19 @@ class LineStack(LineCollection): def __init__( self, data: List[np.ndarray], - z_offset: Iterable[float] | float = None, thickness: float | Iterable[float] = 2.0, colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w", alpha: float = 1.0, cmap: Iterable[str] | str = None, - cmap_values: np.ndarray | List = None, + cmap_transform: np.ndarray | List = None, name: str = None, - metadata: Iterable[Any] | np.ndarray = None, + 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", - *args, + kwargs_lines: list[dict] = None, **kwargs, ): """ @@ -496,13 +506,11 @@ def __init__( Parameters ---------- - data: list of array-like or array - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] + data: list of array-like + List or array-like of multiple line data to plot - z_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines @@ -514,6 +522,9 @@ def __init__( | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + alpha: float, optional + alpha value for colors, if colors is a ``str`` + cmap: Iterable of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines @@ -521,11 +532,20 @@ def __init__( .. note:: ``cmap`` overrides any arguments passed to ``colors`` - cmap_values: 1D array-like or Iterable of numerical values, optional + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap - metadata: Iterable or array - metadata associated with this collection, this is for the user to manage. + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. ``len(metadata)`` must be same as ``len(data)`` separation: float, default 10 @@ -534,43 +554,40 @@ def __init__( separation_axis: str, default "y" axis in which the line graphics in the stack should be separated - name: str, optional - name of the line stack - - kwargs - passed to LineCollection - - Features - -------- - Collections support the same features as the underlying graphic. You just have to slice the selection. + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - See :class:`LineGraphic` details on the features. + kwargs_collection + kwargs for the collection, passed to GraphicCollection """ super().__init__( data=data, - z_offset=z_offset, thickness=thickness, colors=colors, alpha=alpha, cmap=cmap, - cmap_values=cmap_values, - metadata=metadata, + cmap_transform=cmap_transform, name=name, - *args, + names=names, + metadata=metadata, + metadatas=metadatas, + isolated_buffer=isolated_buffer, + kwargs_lines=kwargs_lines, **kwargs, ) axis_zero = 0 for i, line in enumerate(self.graphics): if separation_axis == "x": - line.position_x = axis_zero + line.offset = (axis_zero, *line.offset[1:]) + elif separation_axis == "y": - line.position_y = axis_zero + line.offset = (line.offset[0], axis_zero, line.offset[2]) axis_zero = ( - axis_zero + line.data()[:, axes[separation_axis]].max() + separation + axis_zero + line.data.value[:, axes[separation_axis]].max() + separation ) self.separation = separation diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 8682df3d5..39d815c95 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -3,24 +3,24 @@ import numpy as np import pygfx -from ..utils import parse_cmap_values -from ._base import Graphic -from ._features import PointsDataFeature, ColorFeature, CmapFeature, PointsSizesFeature +from ._positions_base import PositionsGraphic +from ._features import PointsSizesFeature, UniformSize -class ScatterGraphic(Graphic): - feature_events = {"data", "sizes", "colors", "cmap", "present"} +class ScatterGraphic(PositionsGraphic): + _features = {"data", "sizes", "colors", "cmap"} def __init__( self, - data: np.ndarray, - sizes: float | np.ndarray | Iterable[float] = 1, - colors: str | np.ndarray | Iterable[str] = "w", + data: Any, + colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_color: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: np.ndarray | List = None, - z_position: float = 0.0, - *args, + cmap_transform: np.ndarray = None, + isolated_buffer: bool = True, + sizes: float | np.ndarray | Iterable[float] = 1, + uniform_size: bool = False, **kwargs, ): """ @@ -31,73 +31,92 @@ def __init__( data: array-like Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] - sizes: float or iterable of float, optional, default 1.0 - size of the scatter points - colors: str, array, or iterable, default "w" specify colors as a single human readable string, a single RGBA array, or an iterable of strings or RGBA arrays + uniform_color: bool, default False + if True, uses a uniform buffer for the scatter point colors, + basically saves GPU VRAM when the entire line has a single color + + alpha: float, optional, default 1.0 + alpha value for the colors + cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors" - cmap_values: 1D array-like or list of numerical values, optional + cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap - alpha: float, optional, default 1.0 - alpha value for the colors + isolated_buffer: bool, default True + whether the buffers should be isolated from the user input array. + Generally always ``True``, ``False`` is for rare advanced use. - z_position: float, optional - z-axis position for placing the graphic + sizes: float or iterable of float, optional, default 1.0 + size of the scatter points - args - passed to Graphic + uniform_size: bool, default False + if True, uses a uniform buffer for the scatter point sizes, + basically saves GPU VRAM when all scatter points are the same size kwargs passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. + """ - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` + super().__init__( + data=data, + colors=colors, + uniform_color=uniform_color, + alpha=alpha, + cmap=cmap, + cmap_transform=cmap_transform, + isolated_buffer=isolated_buffer, + **kwargs, + ) - """ - self.data = PointsDataFeature(self, data) - n_datapoints = self.data().shape[0] + n_datapoints = self.data.value.shape[0] - if cmap is not None: - colors = parse_cmap_values( - n_colors=n_datapoints, cmap_name=cmap, cmap_values=cmap_values - ) + geo_kwargs = {"positions": self._data.buffer} + material_kwargs = {"pick_write": True} - self.colors = ColorFeature(self, colors, n_colors=n_datapoints, alpha=alpha) - self.cmap = CmapFeature( - self, self.colors(), cmap_name=cmap, cmap_values=cmap_values - ) + if uniform_color: + material_kwargs["color_mode"] = "uniform" + material_kwargs["color"] = self.colors + else: + material_kwargs["color_mode"] = "vertex" + geo_kwargs["colors"] = self.colors.buffer - self.sizes = PointsSizesFeature(self, sizes) - super().__init__(*args, **kwargs) + if uniform_size: + material_kwargs["size_mode"] = "uniform" + self._sizes = UniformSize(sizes) + material_kwargs["size"] = self.sizes + else: + material_kwargs["size_mode"] = "vertex" + self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) + geo_kwargs["sizes"] = self.sizes.buffer world_object = pygfx.Points( - pygfx.Geometry( - positions=self.data(), sizes=self.sizes(), colors=self.colors() - ), - material=pygfx.PointsMaterial( - color_mode="vertex", size_mode="vertex", pick_write=True - ), + pygfx.Geometry(**geo_kwargs), + material=pygfx.PointsMaterial(**material_kwargs), ) self._set_world_object(world_object) - self.position_z = z_position + @property + def sizes(self) -> PointsSizesFeature | float: + """Get or set the scatter point size(s)""" + if isinstance(self._sizes, PointsSizesFeature): + return self._sizes + + elif isinstance(self._sizes, UniformSize): + return self._sizes.value + + @sizes.setter + def sizes(self, value): + if isinstance(self._sizes, PointsSizesFeature): + self._sizes[:] = value + + elif isinstance(self._sizes, UniformSize): + self._sizes.set_value(self, value) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 1fb0c453e..4f28f571c 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -2,11 +2,5 @@ from ._linear_region import LinearRegionSelector from ._polygon import PolygonSelector -from ._sync import Synchronizer -__all__ = [ - "LinearSelector", - "LinearRegionSelector", - "PolygonSelector", - "Synchronizer", -] +__all__ = ["LinearSelector", "LinearRegionSelector"] diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index f20eba4a0..0fc48058d 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -35,7 +35,11 @@ class MoveInfo: # Selector base class class BaseSelector(Graphic): - feature_events = ("selection",) + _features = {"selection"} + + @property + def axis(self) -> str: + return self._axis def __init__( self, @@ -45,7 +49,8 @@ def __init__( hover_responsive: Tuple[WorldObject, ...] = None, arrow_keys_modifier: str = None, axis: str = None, - name: str = None, + parent: Graphic = None, + **kwargs, ): if edges is None: edges = tuple() @@ -71,7 +76,7 @@ def __init__( for wo in self._hover_responsive: self._original_colors[wo] = wo.material.color - self.axis = axis + self._axis = axis # current delta in world coordinates self.delta: np.ndarray = None @@ -95,7 +100,9 @@ def __init__( self._pygfx_event = None - Graphic.__init__(self, name=name) + self._parent = parent + + Graphic.__init__(self, **kwargs) def get_selected_index(self): """Not implemented for this selector""" @@ -110,7 +117,7 @@ def get_selected_data(self): raise NotImplementedError def _get_source(self, graphic): - if self.parent is None and graphic is None: + if self._parent is None and graphic is None: raise AttributeError( "No Graphic to apply selector. " "You must either set a ``parent`` Graphic on the selector, or pass a graphic." @@ -120,7 +127,7 @@ def _get_source(self, graphic): if graphic is not None: source = graphic else: - source = self.parent + source = self._parent return source @@ -262,7 +269,7 @@ def _move_to_pointer(self, ev): """ Calculates delta just using current world object position and calls self._move_graphic(). """ - current_position: np.ndarray = self.position + current_position: np.ndarray = self.offset # middle mouse button clicks if ev.button != 3: @@ -348,8 +355,6 @@ def _key_down(self, ev): if ev.key not in key_bind_direction.keys(): return - # print(ev.key) - self._key_move_value = ev.key def _key_up(self, ev): diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 82e553f0a..22ba96a28 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -6,7 +6,8 @@ import pygfx from ...utils.gui import IS_JUPYTER -from .._base import Graphic, GraphicCollection +from .._base import Graphic +from .._collection_base import GraphicCollection from .._features._selection_features import LinearSelectionFeature from ._base_selector import BaseSelector @@ -17,6 +18,26 @@ class LinearSelector(BaseSelector): + @property + def parent(self) -> Graphic: + return self._parent + + @property + def selection(self) -> float: + """ + x or y value of selector's current position + """ + return self._selection.value + + @selection.setter + def selection(self, value: int): + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, value) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -35,14 +56,15 @@ def limits(self, values: Tuple[float, float]): # TODO: make `selection` arg in graphics data space not world space def __init__( self, - selection: int, - limits: Tuple[int, int], + selection: float, + limits: Sequence[float], + size: float, + center: float, axis: str = "x", parent: Graphic = None, - end_points: Tuple[int, int] = None, - arrow_keys_modifier: str = "Shift", + color: str | tuple = "w", thickness: float = 2.5, - color: Any = "w", + arrow_keys_modifier: str = "Shift", name: str = None, ): """ @@ -59,12 +81,12 @@ def __init__( axis: str, default "x" "x" | "y", the axis which the slider can move along + center: float + center offset of the selector on the orthogonal axis, by default the data mean + parent: Graphic parent graphic for this LineSelector - end_points: (int, int) - set length of slider by bounding it between two x-pos or two y-pos - arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None``. Double click on the selector first to enable the @@ -79,34 +101,23 @@ def __init__( name: str, optional name of line slider - Features - -------- - - selection: :class:`.LinearSelectionFeature` - ``selection()`` returns the current selector position in world coordinates. - Use ``get_selected_index()`` to get the currently selected index in data - space. - Use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for - event pick_info table - """ if len(limits) != 2: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") - self._limits = tuple(map(round, limits)) + self._limits = np.asarray(limits) - selection = round(selection) + end_points = [-size / 2, size / 2] if axis == "x": - xs = np.zeros(2) + xs = np.array([selection, selection]) ys = np.array(end_points) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) elif axis == "y": xs = np.array(end_points) - ys = np.zeros(2) + ys = np.array([selection, selection]) zs = np.zeros(2) line_data = np.column_stack([xs, ys, zs]) @@ -144,12 +155,15 @@ def __init__( self._move_info: dict = None - self.parent = parent - self._block_ipywidget_call = False self._handled_widgets = list() + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # init base selector BaseSelector.__init__( self, @@ -157,20 +171,28 @@ def __init__( hover_responsive=(line_inner, self.line_outer), arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, name=name, + offset=offset, ) self._set_world_object(world_object) - self.selection = LinearSelectionFeature( - self, axis=axis, value=selection, limits=self._limits + self._selection = LinearSelectionFeature( + axis=axis, value=selection, limits=self._limits ) - self.selection = selection + if self._parent is not None: + self.selection = selection + else: + self._selection.set_value(self, selection) + + # update any ipywidgets + self.add_event_handler(self._update_ipywidgets, "selection") def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = int(value) @@ -180,16 +202,13 @@ def _setup_ipywidget_slider(self, widget): # user changes widget -> linear selection changes widget.observe(self._ipywidget_callback, "value") - # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) - self._handled_widgets.append(widget) def _update_ipywidgets(self, ev): # update the ipywidget sliders when LinearSelector value changes self._block_ipywidget_call = True # prevent infinite recursion - value = ev.pick_info["new_data"] + value = ev.info["value"] # update all the handled slider widgets for widget in self._handled_widgets: if isinstance(widget, ipywidgets.IntSlider): @@ -200,7 +219,7 @@ def _update_ipywidgets(self, ev): self._block_ipywidget_call = False def _ipywidget_callback(self, change): - # update the LinearSelector if the ipywidget value changes + # update the LinearSelector when the ipywidget value changes if self._block_ipywidget_call or self._moving: return @@ -249,9 +268,9 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = int(self.selection()) + value = int(self.selection) slider = cls( min=self.limits[0], @@ -327,34 +346,32 @@ def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: def _get_selected_index(self, graphic): # the array to search for the closest value along that axis if self.axis == "x": - geo_positions = graphic.data()[:, 0] - offset = getattr(graphic, f"position_{self.axis}") - else: - geo_positions = graphic.data()[:, 1] - offset = getattr(graphic, f"position_{self.axis}") + data = graphic.data[:, 0] + elif self.axis == "y": + data = graphic.data[:, 1] - if "Line" in graphic.__class__.__name__: - # we want to find the index of the geometry position that is closest to the slider's geometry position - find_value = self.selection() - offset + if ( + "Line" in graphic.__class__.__name__ + or "Scatter" in graphic.__class__.__name__ + ): + # we want to find the index of the data closest to the slider position + find_value = self.selection # get closest data index to the world space position of the slider - idx = np.searchsorted(geo_positions, find_value, side="left") + idx = np.searchsorted(data, find_value, side="left") if idx > 0 and ( - idx == len(geo_positions) - or math.fabs(find_value - geo_positions[idx - 1]) - < math.fabs(find_value - geo_positions[idx]) + idx == len(data) + or math.fabs(find_value - data[idx - 1]) + < math.fabs(find_value - data[idx]) ): return round(idx - 1) else: return round(idx) - if ( - "Heatmap" in graphic.__class__.__name__ - or "Image" in graphic.__class__.__name__ - ): + if "Image" in graphic.__class__.__name__: # indices map directly to grid geometry for image data buffer - index = self.selection() - offset + index = self.selection return round(index) def _move_graphic(self, delta: np.ndarray): @@ -369,9 +386,9 @@ def _move_graphic(self, delta: np.ndarray): """ if self.axis == "x": - self.selection = self.selection() + delta[0] + self.selection = self.selection + delta[0] else: - self.selection = self.selection() + delta[1] + self.selection = self.selection + delta[1] def _fpl_cleanup(self): for widget in self._handled_widgets: diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 09c134800..ecc67b885 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -5,7 +5,8 @@ import pygfx from ...utils.gui import IS_JUPYTER -from .._base import Graphic, GraphicCollection +from .._base import Graphic +from .._collection_base import GraphicCollection from .._features._selection_features import LinearRegionSelectionFeature from ._base_selector import BaseSelector @@ -16,6 +17,35 @@ class LinearRegionSelector(BaseSelector): + @property + def parent(self) -> Graphic | None: + """graphic that the selector is associated with""" + return self._parent + + @property + def selection(self) -> Sequence[float] | List[Sequence[float]]: + """ + (min, max) of data value along selector's axis + """ + # TODO: This probably does not account for rotation since world.position + # does not account for rotation, we can do this later + + return self._selection.value.copy() + + # TODO: if no parent graphic is set, this just returns world positions + # but should we change it? + # return self._selection.value + + @selection.setter + def selection(self, selection: Sequence[float]): + # set (xmin, xmax), or (ymin, ymax) of the selector in data space + graphic = self._parent + + if isinstance(graphic, GraphicCollection): + pass + + self._selection.set_value(self, selection) + @property def limits(self) -> Tuple[float, float]: return self._limits @@ -29,54 +59,50 @@ def limits(self, values: Tuple[float, float]): self._limits = tuple( map(round, values) ) # if values are close to zero things get weird so round them - self.selection._limits = self._limits + self._selection._limits = self._limits def __init__( self, - bounds: Tuple[int, int], - limits: Tuple[int, int], - size: int, - origin: Tuple[int, int], + selection: Sequence[float], + limits: Sequence[float], + size: float, + center: float, axis: str = "x", parent: Graphic = None, resizable: bool = True, fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.8, 0), - edge_thickness: int = 3, + edge_color=(0.8, 0.6, 0), + edge_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. - Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. - - bounds[0], limits[0], and position[0] must be identical. - - Holding the right mouse button while dragging an edge will force the entire region selector to move. This is - a when using transparent fill areas due to ``pygfx`` picking limitations. + Allows sub-selecting data from a parent ``Graphic`` or from multiple Graphics. - **Note:** Events get very weird if the values of bounds, limits and origin are close to zero. If you need - a linear selector with small data, we recommend scaling the data and then using the selector. + Assumes that the data under the selector is a function of the axis on which the selector moves + along. Example: if the selector is along the x-axis, then there must be only one y-value for each + x-value, otherwise functions such as ``get_selected_data()`` do not make sense. Parameters ---------- - bounds: (int, int) - the initial bounds of the linear selector + selection: (float, float) + initial (min, max) x or y values - limits: (int, int) - (min limit, max limit) for the selector + limits: (float, float) + (min limit, max limit) within which the selector can move size: int height or width of the selector - origin: (int, int) - initial position of the selector + center: float + center offset of the selector on the orthogonal axis, by default the data mean axis: str, default "x" - "x" | "y", axis for the selector + "x" | "y", axis the selected can move on parent: Graphic, default ``None`` - associate this selector with a parent Graphic + associate this selector with a parent Graphic from which to fetch data or indices resizable: bool if ``True``, the edges can be dragged to resize the width of the linear selection @@ -87,6 +113,9 @@ def __init__( edge_color: str, array, or tuple edge color for the selector, passed to pygfx.Color + edge_thickness: float, default 8 + edge thickness + arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None`` @@ -94,46 +123,22 @@ def __init__( name: str name for this selector graphic - Features - -------- - - selection: :class:`.LinearRegionSelectionFeature` - ``selection()`` returns the current selector bounds in world coordinates. - Use ``get_selected_indices()`` to return the selected indices in data - space, and ``get_selected_data()`` to return the selected data. - Use ``selection.add_event_handler()`` to add callback functions that are - called when the LinearSelector selection changes. See feature class for - event pick_info table. - """ # lots of very close to zero values etc. so round them, otherwise things get weird - bounds = tuple(map(round, bounds)) - self._limits = tuple(map(round, limits)) - origin = tuple(map(round, origin)) + if not len(selection) == 2: + raise ValueError + + selection = np.asarray(selection) + + if not len(limits) == 2: + raise ValueError + + self._limits = np.asarray(limits) # TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods # TODO: so we can worry about the sanity checks later - # if axis == "x": - # if limits[0] != origin[0] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[0] != bounds[0]\n" - # f"{limits[0]} != {origin[0]} != {bounds[0]}" - # ) - # - # elif axis == "y": - # # initial y-position is position[1] - # if limits[0] != origin[1] != bounds[0]: - # raise ValueError( - # f"limits[0] != position[1] != bounds[0]\n" - # f"{limits[0]} != {origin[1]} != {bounds[0]}" - # ) - - self.parent = parent - - # world object for this will be a group - # basic mesh for the fill area of the selector - # line for each edge of the selector + group = pygfx.Group() if axis == "x": @@ -152,89 +157,69 @@ def __init__( # the fill of the selection self.fill = mesh - self.fill.world.position = (*origin, -2) + # no x, y offsets for linear region selector + # everything is done by setting the mesh data + # and line positions + self.fill.world.position = (0, 0, -2) group.add(self.fill) self._resizable = resizable if axis == "x": - # position data for the left edge line - left_line_data = np.array( - [ - [origin[0], (-size / 2) + origin[1], 0.5], - [origin[0], (size / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - # position data for the right edge line - right_line_data = np.array( - [ - [bounds[1], (-size / 2) + origin[1], 0.5], - [bounds[1], (size / 2) + origin[1], 0.5], - ] - ).astype(np.float32) - - right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), + # just some data to initialize the edge lines + init_line_data = np.array([[0, -size / 2, 0], [0, size / 2, 0]]).astype( + np.float32 ) - self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) - elif axis == "y": - # position data for the left edge line - bottom_line_data = np.array( - [ - [(-size / 2) + origin[0], origin[1], 0.5], - [(size / 2) + origin[0], origin[1], 0.5], - ] - ).astype(np.float32) - - bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - # position data for the right edge line - top_line_data = np.array( + # just some line data to initialize y axis edge lines + init_line_data = np.array( [ - [(-size / 2) + origin[0], bounds[1], 0.5], - [(size / 2) + origin[0], bounds[1], 0.5], + [-size / 2, 0, 0], + [size / 2, 0, 0], ] ).astype(np.float32) - top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data), - pygfx.LineMaterial( - thickness=edge_thickness, color=edge_color, pick_write=True - ), - ) - - self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) - else: raise ValueError("axis argument must be one of 'x' or 'y'") + line0 = pygfx.Line( + pygfx.Geometry( + positions=init_line_data.copy() + ), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + line1 = pygfx.Line( + pygfx.Geometry( + positions=init_line_data.copy() + ), # copy so the line buffer is isolated + pygfx.LineMaterial( + thickness=edge_thickness, color=edge_color, pick_write=True + ), + ) + + self.edges: Tuple[pygfx.Line, pygfx.Line] = (line0, line1) + # add the edge lines for edge in self.edges: - edge.world.z = -1 + edge.world.z = -0.5 group.add(edge) + # TODO: if parent offset changes, we should set the selector offset too + # TODO: add check if parent is `None`, will throw error otherwise + if axis == "x": + offset = (parent.offset[0], center, 0) + elif axis == "y": + offset = (center, parent.offset[1], 0) + # set the initial bounds of the selector - self.selection = LinearRegionSelectionFeature( - self, bounds, axis=axis, limits=self._limits + # compensate for any offset from the parent graphic + # selection feature only works in world space, not data space + self._selection = LinearRegionSelectionFeature( + selection, axis=axis, limits=self._limits ) self._handled_widgets = list() @@ -248,17 +233,22 @@ def __init__( hover_responsive=self.edges, arrow_keys_modifier=arrow_keys_modifier, axis=axis, + parent=parent, name=name, + offset=offset, ) self._set_world_object(group) + self.selection = selection + def get_selected_data( self, graphic: Graphic = None - ) -> Union[np.ndarray, List[np.ndarray], None]: + ) -> Union[np.ndarray, List[np.ndarray]]: """ Get the ``Graphic`` data bounded by the current selection. - Returns a view of the full data array. + Returns a view of the data array. + If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. @@ -269,15 +259,16 @@ def get_selected_data( Parameters ---------- - graphic: Graphic, optional + graphic: Graphic, optional, default ``None`` if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` Returns ------- - np.ndarray, List[np.ndarray], or None + np.ndarray or List[np.ndarray] view or list of views of the full array, returns ``None`` if selection is empty """ + source = self._get_source(graphic) ixs = self.get_selected_indices(source) @@ -290,40 +281,47 @@ def get_selected_data( for i, g in enumerate(source.graphics): if ixs[i].size == 0: - data_selections.append(None) + data_selections.append( + np.array([], dtype=np.float32).reshape(0, 3) + ) else: - s = slice(ixs[i][0], ixs[i][-1]) - data_selections.append(g.data.buffer.data[s]) + s = slice( + ixs[i][0], ixs[i][-1] + 1 + ) # add 1 because these are direct indices + # slices n_datapoints dim + data_selections.append(g.data[s]) - return source[:].data[s] - # just for one Line graphic + return source.data[s] else: if ixs.size == 0: - return None + # empty selection + return np.array([], dtype=np.float32).reshape(0, 3) - s = slice(ixs[0], ixs[-1]) - return source.data.buffer.data[s] + s = slice( + ixs[0], ixs[-1] + 1 + ) # add 1 to end because these are direct indices + # slices n_datapoints dim + # slice with min, max is faster than using all the indices + return source.data[s] + + if "Image" in source.__class__.__name__: + s = slice(ixs[0], ixs[-1] + 1) - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): - s = slice(ixs[0], ixs[-1]) if self.axis == "x": - return source.data()[:, s] + # slice columns + return source.data[:, s] + elif self.axis == "y": - return source.data()[s] + # slice rows + return source.data[s] def get_selected_indices( self, graphic: Graphic = None ) -> Union[np.ndarray, List[np.ndarray]]: """ Returns the indices of the ``Graphic`` data bounded by the current selection. - This is useful because the ``bounds`` min and max are not necessarily the same - as the Line Geometry positions x-vals or y-vals. For example, if if you used a - np.linspace(0, 100, 1000) for xvals in your line, then you will have 1,000 - x-positions. If the selection ``bounds`` are set to ``(0, 10)``, the returned - indices would be ``(0, 100)``. + + These are the data indices along the selector's "axis" which correspond to the data under the selector. Parameters ---------- @@ -333,51 +331,45 @@ def get_selected_indices( Returns ------- Union[np.ndarray, List[np.ndarray]] - data indices of the selection, list of np.ndarray if graphic is LineCollection + data indices of the selection, list of np.ndarray if graphic is a collection """ + # we get the indices from the source graphic source = self._get_source(graphic) - # if the graphic position is not at (0, 0) then the bounds must be offset - offset = getattr(source, f"position_{self.selection.axis}") - offset_bounds = tuple(v - offset for v in self.selection()) - - # need them to be int to use as indices - offset_bounds = tuple(map(int, offset_bounds)) - - if self.selection.axis == "x": + # get the offset of the source graphic + if self.axis == "x": dim = 0 - else: + elif self.axis == "y": dim = 1 - if "Line" in source.__class__.__name__: - # now we need to map from graphic space to data space - # we can have more than 1 datapoint between two integer locations in the world space + # selector (min, max) data values along axis + bounds = self.selection + + if ( + "Line" in source.__class__.__name__ + or "Scatter" in source.__class__.__name__ + ): + # gets indices corresponding to n_datapoints dim + # data is [n_datapoints, xyz], so we return + # indices that can be used to slice `n_datapoints` if isinstance(source, GraphicCollection): ixs = list() for g in source.graphics: - # map for each graphic in the collection - g_ixs = np.where( - (g.data()[:, dim] >= offset_bounds[0]) - & (g.data()[:, dim] <= offset_bounds[1]) - )[0] + # indices for each graphic in the collection + data = g.data[:, dim] + g_ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] ixs.append(g_ixs) else: # map this only this graphic - ixs = np.where( - (source.data()[:, dim] >= offset_bounds[0]) - & (source.data()[:, dim] <= offset_bounds[1]) - )[0] + data = source.data[:, dim] + ixs = np.where((data >= bounds[0]) & (data <= bounds[1]))[0] return ixs - if ( - "Heatmap" in source.__class__.__name__ - or "Image" in source.__class__.__name__ - ): + if "Image" in source.__class__.__name__: # indices map directly to grid geometry for image data buffer - ixs = np.arange(*self.selection(), dtype=int) - return ixs + return np.arange(*bounds, dtype=int) def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): """ @@ -410,9 +402,9 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): cls = getattr(ipywidgets, kind) - value = self.selection() + value = self.selection if "Int" in kind: - value = tuple(map(int, self.selection())) + value = tuple(map(int, self.selection)) slider = cls( min=self.limits[0], @@ -457,7 +449,7 @@ def add_ipywidget_handler(self, widget, step: Union[int, float] = None): def _setup_ipywidget_slider(self, widget): # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection() + value = self.selection if isinstance(widget, ipywidgets.IntSlider): value = tuple(map(int, value)) @@ -468,7 +460,7 @@ def _setup_ipywidget_slider(self, widget): widget.observe(self._ipywidget_callback, "value") # user changes linear selection -> widget changes - self.selection.add_event_handler(self._update_ipywidgets) + self.add_event_handler(self._update_ipywidgets, "selection") self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") @@ -502,43 +494,39 @@ def _set_slider_layout(self, *args): widget.layout = ipywidgets.Layout(width=f"{w}px") def _move_graphic(self, delta: np.ndarray): - # add delta to current bounds to get new positions - if self.selection.axis == "x": - # min and max of current bounds, i.e. the edges - xmin, xmax = self.selection() + # add delta to current min, max to get new positions + if self.axis == "x": + # add x value + new_min, new_max = self.selection + delta[0] - # new left bound position - bound0_new = xmin + delta[0] - - # new right bound position - bound1_new = xmax + delta[0] - else: - # min and max of current bounds, i.e. the edges - ymin, ymax = self.selection() + elif self.axis == "y": + # add y value + new_min, new_max = self.selection + delta[1] - # new bottom bound position - bound0_new = ymin + delta[1] - - # new top bound position - bound1_new = ymax + delta[1] - - # move entire selector if source was fill + # move entire selector if event source was fill if self._move_info.source == self.fill: - # set the new bounds - self.selection = (bound0_new, bound1_new) + # prevent weird shrinkage of selector if one edge is already at the limit + if self.selection[0] == self.limits[0] and new_min < self.limits[0]: + # self._move_end(None) # TODO: cancel further movement to prevent weird asynchronization with pointer + return + if self.selection[1] == self.limits[1] and new_max > self.limits[1]: + # self._move_end(None) + return + + # move entire selector + self._selection.set_value(self, (new_min, new_max)) return - # if selector is not resizable do nothing + # if selector is not resizable return if not self._resizable: return - # if resizable, move edges + # if event source was an edge and selector is resizable, + # move the edge that caused the event if self._move_info.source == self.edges[0]: # change only left or bottom bound - self.selection = (bound0_new, self.selection()[1]) + self._selection.set_value(self, (new_min, self._selection.value[1])) elif self._move_info.source == self.edges[1]: # change only right or top bound - self.selection = (self.selection()[0], bound1_new) - else: - return + self._selection.set_value(self, (self.selection[0], new_max)) diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py deleted file mode 100644 index ce903aab8..000000000 --- a/fastplotlib/graphics/selectors/_sync.py +++ /dev/null @@ -1,90 +0,0 @@ -from . import LinearSelector -from typing import * - - -class Synchronizer: - def __init__( - self, *selectors: LinearSelector, key_bind: Union[str, None] = "Shift" - ): - """ - Synchronize the movement of `Selectors`. Selectors will move in sync only when the selected `"key_bind"` is - used during the mouse movement event. Valid key binds are: ``"Control"``, ``"Shift"`` and ``"Alt"``. - If ``key_bind`` is ``None`` then the selectors will always be synchronized. - - Parameters - ---------- - selectors - selectors to synchronize - - key_bind: str, default ``"Shift"`` - one of ``"Control"``, ``"Shift"`` and ``"Alt"`` or ``None`` - """ - self._selectors = list() - self.key_bind = key_bind - - for s in selectors: - self.add(s) - - self.block_event = False - - self.enabled: bool = True - - @property - def selectors(self): - """Selectors managed by the Synchronizer""" - return self._selectors - - def add(self, selector): - """add a selector""" - selector.selection.add_event_handler(self._handle_event) - self._selectors.append(selector) - - def remove(self, selector): - """remove a selector""" - selector.selection.remove_event_handler(self._handle_event) - self._selectors.remove(selector) - - def clear(self): - for i in range(len(self.selectors)): - self.remove(self.selectors[0]) - - def _handle_event(self, ev): - if self.block_event: - # because infinite recursion - return - - if not self.enabled: - return - - self.block_event = True - - source = ev.pick_info["graphic"] - delta = ev.pick_info["delta"] - pygfx_ev = ev.pick_info["pygfx_event"] - - # only moves when modifier is used - if pygfx_ev is None: - self.block_event = False - return - - if self.key_bind is not None: - if self.key_bind not in pygfx_ev.modifiers: - self.block_event = False - return - - if delta is not None: - self._move_selectors(source, delta) - - self.block_event = False - - def _move_selectors(self, source, delta): - for s in self.selectors: - # must use == and not is to compare Graphics because they are weakref proxies! - if s == source: - # if it's the source, since it has already moved - continue - - s._move_graphic(delta) - - def __del__(self): - self.clear() diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 49b4ac4be..fcee6129b 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -1,22 +1,35 @@ -from typing import * import pygfx import numpy as np from ._base import Graphic +from ._features import ( + TextData, + FontSize, + TextFaceColor, + TextOutlineColor, + TextOutlineThickness, +) class TextGraphic(Graphic): + _features = { + "text", + "font_size", + "face_color", + "outline_color", + "outline_thickness", + } + def __init__( self, text: str, - position: Tuple[int] = (0, 0, 0), - size: int = 14, - face_color: Union[str, np.ndarray] = "w", - outline_color: Union[str, np.ndarray] = "w", - outline_thickness=0, + font_size: float | int = 14, + face_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_color: str | np.ndarray | list[float] | tuple[float] = "w", + outline_thickness: float = 0.0, screen_space: bool = True, + offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - *args, **kwargs, ): """ @@ -25,13 +38,10 @@ def __init__( Parameters ---------- text: str - display text - - position: int tuple, default (0, 0, 0) - int tuple indicating location of text in scene + text to display - size: int, default 10 - text size + font_size: float | int, default 10 + font size face_color: str or array, default "w" str or RGBA array to set the color of the text @@ -39,14 +49,14 @@ def __init__( outline_color: str or array, default "w" str or RGBA array to set the outline color of the text - outline_thickness: int, default 0 - text outline thickness + outline_thickness: float, default 0 + relative outline thickness, value between 0.0 - 0.5 screen_space: bool = True - whether the text is rendered in screen space, in contrast to world space + if True, text size is in screen space, if False the text size is in data space - name: str, optional - name of graphic, passed to Graphic + offset: (float, float, float), default (0, 0, 0) + places the text at this location anchor: str, default "middle-center" position of the origin of the text @@ -54,94 +64,80 @@ def __init__( * Vertical values: "top", "middle", "baseline", "bottom" * Horizontal values: "left", "center", "right" + + **kwargs + passed to Graphic + """ - super().__init__(*args, **kwargs) - self._text = text + super().__init__(**kwargs) + + self._text = TextData(text) + self._font_size = FontSize(font_size) + self._face_color = TextFaceColor(face_color) + self._outline_color = TextOutlineColor(outline_color) + self._outline_thickness = TextOutlineThickness(outline_thickness) world_object = pygfx.Text( pygfx.TextGeometry( - text=str(text), - font_size=size, + text=self.text, + font_size=self.font_size, screen_space=screen_space, anchor=anchor, ), pygfx.TextMaterial( - color=face_color, - outline_color=outline_color, - outline_thickness=outline_thickness, + color=self.face_color, + outline_color=self.outline_color, + outline_thickness=self.outline_thickness, pick_write=True, ), ) self._set_world_object(world_object) - self.world_object.position = position + self.offset = offset @property - def text(self): - """Returns the text of this graphic.""" - return self._text + def text(self) -> str: + """the text displayed""" + return self._text.value @text.setter def text(self, text: str): - """Set the text of this graphic.""" - if not isinstance(text, str): - raise ValueError("Text must be of type str.") - - self._text = text - self.world_object.geometry.set_text(self._text) + self._text.set_value(self, text) @property - def text_size(self): - """Returns the text size of this graphic.""" - return self.world_object.geometry.font_size + def font_size(self) -> float | int: + """ "text font size""" + return self._font_size.value - @text_size.setter - def text_size(self, size: Union[int, float]): - """Set the text size of this graphic.""" - if not (isinstance(size, int) or isinstance(size, float)): - raise ValueError("Text size must be of type int or float") - - self.world_object.geometry.font_size = size + @font_size.setter + def font_size(self, size: float | int): + self._font_size.set_value(self, size) @property - def face_color(self): - """Returns the face color of this graphic.""" - return self.world_object.material.color + def face_color(self) -> pygfx.Color: + """text face color""" + return self._face_color.value @face_color.setter - def face_color(self, color: Union[str, np.ndarray]): - """Set the face color of this graphic.""" - if not (isinstance(color, str) or isinstance(color, np.ndarray)): - raise ValueError("Face color must be of type str or np.ndarray") - - color = pygfx.Color(color) - - self.world_object.material.color = color + def face_color(self, color: str | np.ndarray | list[float] | tuple[float]): + self._face_color.set_value(self, color) @property - def outline_size(self): - """Returns the outline size of this graphic.""" - return self.world_object.material.outline_thickness + def outline_thickness(self) -> float: + """text outline thickness""" + return self._outline_thickness.value - @outline_size.setter - def outline_size(self, size: Union[int, float]): - """Set the outline size of this text graphic.""" - if not (isinstance(size, int) or isinstance(size, float)): - raise ValueError("Outline size must be of type int or float") - - self.world_object.material.outline_thickness = size + @outline_thickness.setter + def outline_thickness(self, thickness: float): + self._outline_thickness.set_value(self, thickness) @property - def outline_color(self): - """Returns the outline color of this graphic.""" - return self.world_object.material.outline_color + def outline_color(self) -> pygfx.Color: + """text outline color""" + return self._outline_color.value @outline_color.setter - def outline_color(self, color: Union[str, np.ndarray]): - """Set the outline color of this graphic""" - if not (isinstance(color, str) or isinstance(color, np.ndarray)): - raise ValueError("Outline color must be of type str or np.ndarray") - - self.world_object.material.outline_color = color + def outline_color(self, color: str | np.ndarray | list[float] | tuple[float]): + self._outline_color.set_value(self, color) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 1c7439613..2c157db8f 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -128,7 +128,6 @@ def __init__( # if controller instances have been specified for each subplot if controllers is not None: - # one controller for all subplots if isinstance(controllers, pygfx.Controller): controllers = [controllers] * len(self) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 9f82cfed5..387549ade 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -28,18 +28,17 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: # only return a proxy to the real graphic return weakref.proxy(graphic) - def add_heatmap( + def add_image( self, data: Any, vmin: int = None, vmax: int = None, cmap: str = "plasma", - filter: str = "nearest", - chunk_size: int = 8192, + interpolation: str = "nearest", + cmap_interpolation: str = "linear", isolated_buffer: bool = True, - *args, **kwargs - ) -> HeatmapGraphic: + ) -> ImageGraphic: """ Create an Image Graphic @@ -48,7 +47,6 @@ def add_heatmap( ---------- data: array-like array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested | shape must be ``[x_dim, y_dim]`` vmin: int, optional @@ -60,107 +58,20 @@ def add_heatmap( cmap: str, optional, default "plasma" colormap to use to display the data - filter: str, optional, default "nearest" + interpolation: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" - chunk_size: int, default 8192, max 8192 - chunk size for each tile used to make up the heatmap texture - - isolated_buffer: bool, default True - If True, initialize a buffer with the same shape as the input data and then - set the data, useful if the data arrays are ready-only such as memmaps. - If False, the input array is itself used as the buffer. - - args: - additional arguments passed to Graphic - - kwargs: - additional keyword arguments passed to Graphic - - Features - -------- - - **data**: :class:`.HeatmapDataFeature` - Manages the data buffer displayed in the HeatmapGraphic - - **cmap**: :class:`.HeatmapCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - - - """ - return self._create_graphic( - HeatmapGraphic, - data, - vmin, - vmax, - cmap, - filter, - chunk_size, - isolated_buffer, - *args, - **kwargs - ) - - def add_image( - self, - data: Any, - vmin: int = None, - vmax: int = None, - cmap: str = "plasma", - filter: str = "nearest", - isolated_buffer: bool = True, - *args, - **kwargs - ) -> ImageGraphic: - """ - - Create an Image Graphic - - Parameters - ---------- - data: array-like - array-like, usually numpy.ndarray, must support ``memoryview()`` - Tensorflow Tensors also work **probably**, but not thoroughly tested - | shape must be ``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]`` - - vmin: int, optional - minimum value for color scaling, calculated from data if not provided - - vmax: int, optional - maximum value for color scaling, calculated from data if not provided - - cmap: str, optional, default "plasma" - colormap to use to display the image data, ignored if data is RGB - - filter: str, optional, default "nearest" - interpolation filter, one of "nearest" or "linear" + cmap_interpolation: str, optional, default "linear" + colormap interpolation method, one of "nearest" or "linear" isolated_buffer: bool, default True If True, initialize a buffer with the same shape as the input data and then set the data, useful if the data arrays are ready-only such as memmaps. If False, the input array is itself used as the buffer. - args: - additional arguments passed to Graphic - kwargs: additional keyword arguments passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the data buffer displayed in the ImageGraphic - - **cmap**: :class:`.ImageCmapFeature` - Manages the colormap - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene - """ return self._create_graphic( @@ -169,24 +80,27 @@ def add_image( vmin, vmax, cmap, - filter, + interpolation, + cmap_interpolation, isolated_buffer, - *args, **kwargs ) def add_line_collection( self, - data: List[numpy.ndarray], - z_offset: Union[Iterable[float], float] = None, - thickness: Union[float, Iterable[float]] = 2.0, - colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", + data: Union[numpy.ndarray, List[numpy.ndarray]], + thickness: Union[float, Sequence[float]] = 2.0, + colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w", + uniform_colors: bool = False, alpha: float = 1.0, - cmap: Union[Iterable[str], str] = None, - cmap_values: Union[numpy.ndarray, List] = None, + cmap: Union[Sequence[str], str] = None, + cmap_transform: Union[numpy.ndarray, List] = None, name: str = None, - metadata: Union[Iterable[Any], numpy.ndarray] = None, - *args, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, + kwargs_lines: list[dict] = None, **kwargs ) -> LineCollection: """ @@ -195,13 +109,11 @@ def add_line_collection( Parameters ---------- - data: list of array-like or array - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] + data: list of array-like + List or array-like of multiple line data to plot - z_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines @@ -223,43 +135,45 @@ def add_line_collection( .. note:: ``cmap`` overrides any arguments passed to ``colors`` - cmap_values: 1D array-like or Iterable of numerical values, optional + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap name: str, optional - name of the line collection - - metadata: Iterable or array - metadata associated with this collection, this is for the user to manage. - ``len(metadata)`` must be same as ``len(data)`` + name of the line collection as a whole - args - passed to GraphicCollection + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` - kwargs - passed to GraphicCollection + metadata: Any + meatadata associated with the collection as a whole - Features - -------- + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. + ``len(metadata)`` must be same as ``len(data)`` - Collections support the same features as the underlying graphic. You just have to slice the selection. + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - See :class:`LineGraphic` details on the features. + kwargs_collection + kwargs for the collection, passed to GraphicCollection """ return self._create_graphic( LineCollection, data, - z_offset, thickness, colors, + uniform_colors, alpha, cmap, - cmap_values, + cmap_transform, name, + names, metadata, - *args, + metadatas, + isolated_buffer, + kwargs_lines, **kwargs ) @@ -268,12 +182,11 @@ def add_line( data: Any, thickness: float = 2.0, colors: Union[str, numpy.ndarray, Iterable] = "w", + uniform_color: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: Union[numpy.ndarray, Iterable] = None, - z_position: float = None, - collection_index: int = None, - *args, + cmap_transform: Union[numpy.ndarray, Iterable] = None, + isolated_buffer: bool = True, **kwargs ) -> LineGraphic: """ @@ -292,43 +205,23 @@ def add_line( specify colors as a single human-readable string, a single RGBA array, or an iterable of strings or RGBA arrays - cmap: str, optional - apply a colormap to the line instead of assigning colors manually, this - overrides any argument passed to "colors" - - cmap_values: 1D array-like or Iterable of numerical values, optional - if provided, these values are used to map the colors from the cmap + uniform_color: bool, default ``False`` + if True, uses a uniform buffer for the line color, + basically saves GPU VRAM when the entire line has a single color alpha: float, optional, default 1.0 alpha value for the colors - z_position: float, optional - z-axis position for placing the graphic + cmap: str, optional + apply a colormap to the line instead of assigning colors manually, this + overrides any argument passed to "colors" - args - passed to Graphic + cmap_transform: 1D array-like of numerical values, optional + if provided, these values are used to map the colors from the cmap - kwargs + **kwargs passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. - - **thickness**: :class:`.ThicknessFeature` - Manages the thickness feature of the lines. - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` - """ return self._create_graphic( @@ -336,29 +229,30 @@ def add_line( data, thickness, colors, + uniform_color, alpha, cmap, - cmap_values, - z_position, - collection_index, - *args, + cmap_transform, + isolated_buffer, **kwargs ) def add_line_stack( self, data: List[numpy.ndarray], - z_offset: Union[Iterable[float], float] = None, thickness: Union[float, Iterable[float]] = 2.0, colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w", alpha: float = 1.0, cmap: Union[Iterable[str], str] = None, - cmap_values: Union[numpy.ndarray, List] = None, + cmap_transform: Union[numpy.ndarray, List] = None, name: str = None, - metadata: Union[Iterable[Any], numpy.ndarray] = None, + names: list[str] = None, + metadata: Any = None, + metadatas: Union[Sequence[Any], numpy.ndarray] = None, + isolated_buffer: bool = True, separation: float = 10.0, separation_axis: str = "y", - *args, + kwargs_lines: list[dict] = None, **kwargs ) -> LineStack: """ @@ -367,13 +261,11 @@ def add_line_stack( Parameters ---------- - data: list of array-like or array - List of line data to plot, each element must be a 1D, 2D, or 3D numpy array - if elements are 2D, interpreted as [y_vals, n_lines] + data: list of array-like + List or array-like of multiple line data to plot - z_offset: Iterable of float or float, optional - | if ``float``, single offset will be used for all lines - | if ``list`` of ``float``, each value will apply to the individual lines + | if ``list`` each item in the list must be a 1D, 2D, or 3D numpy array + | if array-like, must be of shape [n_lines, n_points_line, y | xy | xyz] thickness: float or Iterable of float, default 2.0 | if ``float``, single thickness will be used for all lines @@ -385,6 +277,9 @@ def add_line_stack( | if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...] | if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line + alpha: float, optional + alpha value for colors, if colors is a ``str`` + cmap: Iterable of str or str, optional | if ``str``, single cmap will be used for all lines | if ``list`` of ``str``, each cmap will apply to the individual lines @@ -392,11 +287,20 @@ def add_line_stack( .. note:: ``cmap`` overrides any arguments passed to ``colors`` - cmap_values: 1D array-like or Iterable of numerical values, optional + cmap_transform: 1D array-like of numerical values, optional if provided, these values are used to map the colors from the cmap - metadata: Iterable or array - metadata associated with this collection, this is for the user to manage. + name: str, optional + name of the line collection as a whole + + names: list[str], optional + names of the individual lines in the collection, ``len(names)`` must equal ``len(data)`` + + metadata: Any + metadata associated with the collection as a whole + + metadatas: Iterable or array + metadata for each individual line associated with this collection, this is for the user to manage. ``len(metadata)`` must be same as ``len(data)`` separation: float, default 10 @@ -405,48 +309,45 @@ def add_line_stack( separation_axis: str, default "y" axis in which the line graphics in the stack should be separated - name: str, optional - name of the line stack - - kwargs - passed to LineCollection - Features - -------- + kwargs_lines: list[dict], optional + list of kwargs passed to the individual lines, ``len(kwargs_lines)`` must equal ``len(data)`` - Collections support the same features as the underlying graphic. You just have to slice the selection. - - See :class:`LineGraphic` details on the features. + kwargs_collection + kwargs for the collection, passed to GraphicCollection """ return self._create_graphic( LineStack, data, - z_offset, thickness, colors, alpha, cmap, - cmap_values, + cmap_transform, name, + names, metadata, + metadatas, + isolated_buffer, separation, separation_axis, - *args, + kwargs_lines, **kwargs ) def add_scatter( self, - data: numpy.ndarray, - sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, - colors: Union[str, numpy.ndarray, Iterable[str]] = "w", + data: Any, + colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w", + uniform_color: bool = False, alpha: float = 1.0, cmap: str = None, - cmap_values: Union[numpy.ndarray, List] = None, - z_position: float = 0.0, - *args, + cmap_transform: numpy.ndarray = None, + isolated_buffer: bool = True, + sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, + uniform_size: bool = False, **kwargs ) -> ScatterGraphic: """ @@ -458,73 +359,64 @@ def add_scatter( data: array-like Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3] - sizes: float or iterable of float, optional, default 1.0 - size of the scatter points - colors: str, array, or iterable, default "w" specify colors as a single human readable string, a single RGBA array, or an iterable of strings or RGBA arrays + uniform_color: bool, default False + if True, uses a uniform buffer for the scatter point colors, + basically saves GPU VRAM when the entire line has a single color + + alpha: float, optional, default 1.0 + alpha value for the colors + cmap: str, optional apply a colormap to the scatter instead of assigning colors manually, this overrides any argument passed to "colors" - cmap_values: 1D array-like or list of numerical values, optional + cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap - alpha: float, optional, default 1.0 - alpha value for the colors + isolated_buffer: bool, default True + whether the buffers should be isolated from the user input array. + Generally always ``True``, ``False`` is for rare advanced use. - z_position: float, optional - z-axis position for placing the graphic + sizes: float or iterable of float, optional, default 1.0 + size of the scatter points - args - passed to Graphic + uniform_size: bool, default False + if True, uses a uniform buffer for the scatter point sizes, + basically saves GPU VRAM when all scatter points are the same size kwargs passed to Graphic - Features - -------- - - **data**: :class:`.ImageDataFeature` - Manages the line [x, y, z] positions data buffer, allows regular and fancy indexing. - - **colors**: :class:`.ColorFeature` - Manages the color buffer, allows regular and fancy indexing. - - **cmap**: :class:`.CmapFeature` - Manages the cmap, wraps :class:`.ColorFeature` to add additional functionality relevant to cmaps. - - **present**: :class:`.PresentFeature` - Control the presence of the Graphic in the scene, set to ``True`` or ``False`` - """ return self._create_graphic( ScatterGraphic, data, - sizes, colors, + uniform_color, alpha, cmap, - cmap_values, - z_position, - *args, + cmap_transform, + isolated_buffer, + sizes, + uniform_size, **kwargs ) def add_text( self, text: str, - position: Tuple[int] = (0, 0, 0), - size: int = 14, - face_color: Union[str, numpy.ndarray] = "w", - outline_color: Union[str, numpy.ndarray] = "w", - outline_thickness=0, + font_size: float | int = 14, + face_color: str | numpy.ndarray | list[float] | tuple[float] = "w", + outline_color: str | numpy.ndarray | list[float] | tuple[float] = "w", + outline_thickness: float = 0.0, screen_space: bool = True, + offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - *args, **kwargs ) -> TextGraphic: """ @@ -534,13 +426,10 @@ def add_text( Parameters ---------- text: str - display text + text to display - position: int tuple, default (0, 0, 0) - int tuple indicating location of text in scene - - size: int, default 10 - text size + font_size: float | int, default 10 + font size face_color: str or array, default "w" str or RGBA array to set the color of the text @@ -548,14 +437,14 @@ def add_text( outline_color: str or array, default "w" str or RGBA array to set the outline color of the text - outline_thickness: int, default 0 - text outline thickness + outline_thickness: float, default 0 + relative outline thickness, value between 0.0 - 0.5 screen_space: bool = True - whether the text is rendered in screen space, in contrast to world space + if True, text size is in screen space, if False the text size is in data space - name: str, optional - name of graphic, passed to Graphic + offset: (float, float, float), default (0, 0, 0) + places the text at this location anchor: str, default "middle-center" position of the origin of the text @@ -564,17 +453,20 @@ def add_text( * Vertical values: "top", "middle", "baseline", "bottom" * Horizontal values: "left", "center", "right" + **kwargs + passed to Graphic + + """ return self._create_graphic( TextGraphic, text, - position, - size, + font_size, face_color, outline_color, outline_thickness, screen_space, + offset, anchor, - *args, **kwargs ) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 6ff07a748..d8e0adebc 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -12,6 +12,7 @@ from ._utils import create_controller from ..graphics._base import Graphic +from ..graphics._collection_base import GraphicCollection from ..graphics.selectors._base_selector import BaseSelector from ..legends import Legend @@ -469,14 +470,14 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis # for perspective projections we assume the user wants full 3D control - graphic.position_z = len(self) + graphic.offset = (*graphic.offset[:-1], len(self)) def insert_graphic( self, graphic: Graphic, center: bool = True, index: int = 0, - z_position: int = None, + auto_offset: int = None, ): """ Insert graphic into scene at given position ``index`` in stored graphics. @@ -493,8 +494,8 @@ def insert_graphic( index: int, default 0 Index to insert graphic. - z_position: int, default None - z axis position to place Graphic. If ``None``, uses value of `index` argument + auto_offset: bool, default True + If True and using an orthographic projection, sets z-axis offset of graphic to `index` """ if index > len(self._graphics): @@ -511,10 +512,8 @@ def insert_graphic( if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis # for perspective projections we assume the user wants full 3D control - if z_position is None: - graphic.position_z = index - else: - graphic.position_z = z_position + if auto_offset: + graphic.offset = (*graphic.offset[:-1], index) def _add_or_insert_graphic( self, diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index a541c9d78..059307e6b 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -149,7 +149,7 @@ def set_title(self, text: str): if self._title_graphic is not None: self._title_graphic.text = text else: - tg = TextGraphic(text=text, size=18) + tg = TextGraphic(text=text, font_size=18) self._title_graphic = tg self.docks["top"].size = 35 diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 561863b0c..73752ba5e 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -91,8 +91,8 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray: max_colors = cmap.shape[0] if n_colors > cmap.shape[0]: raise ValueError( - f"You have requested <{n_colors}> but only <{max_colors} existing for the " - f"chosen cmap: <{cmap}>" + f"You have requested <{n_colors}> colors but only <{max_colors}> exist for the " + f"chosen cmap: <{name}>" ) return cmap[:n_colors] @@ -239,7 +239,7 @@ def normalize_min_max(a): def parse_cmap_values( n_colors: int, cmap_name: str, - cmap_values: np.ndarray | list[int | float] = None, + transform: np.ndarray | list[int | float] = None, ) -> np.ndarray: """ @@ -251,28 +251,25 @@ def parse_cmap_values( cmap_name: str colormap name - cmap_values: np.ndarray | List[int | float], optional - cmap values + transform: np.ndarray | List[int | float], optional + cmap transform Returns ------- """ - if cmap_values is None: - # use the cmap values linearly just along the collection indices - # for example, if len(data) = 10 and the cmap is "jet", then it will - # linearly go from blue to red from data[0] to data[-1] + if transform is None: colors = make_colors(n_colors, cmap_name) return colors else: - if not isinstance(cmap_values, np.ndarray): - cmap_values = np.array(cmap_values) + if not isinstance(transform, np.ndarray): + transform = np.array(transform) - # use the values within cmap_values to set the color of the corresponding data - # each individual data[i] has its color based on the "relative cmap_value intensity" - if len(cmap_values) != n_colors: + # use the of the cmap_transform to set the color of the corresponding data + # each individual data[i] has its color based on the transform values + if len(transform) != n_colors: raise ValueError( - f"len(cmap_values) != len(data): {len(cmap_values)} != {n_colors}" + f"len(cmap_values) != len(data): {len(transform)} != {n_colors}" ) colormap = get_cmap(cmap_name) @@ -280,23 +277,23 @@ def parse_cmap_values( n_colors = colormap.shape[0] - 1 if cmap_name in QUALITATIVE_CMAPS: - # check that cmap_values are and within the number of colors `n_colors` + # check that cmap_transform are and within the number of colors `n_colors` # do not scale, use directly - if not np.issubdtype(cmap_values.dtype, np.integer): + if not np.issubdtype(transform.dtype, np.integer): raise TypeError( - f" cmap_values should be used with qualitative colormaps, the dtype you " - f"have passed is {cmap_values.dtype}" + f" `cmap_transform` values should be used with qualitative colormaps, " + f"the dtype you have passed is {transform.dtype}" ) - if max(cmap_values) > n_colors: + if max(transform) > n_colors: raise IndexError( f"You have chosen the qualitative colormap <'{cmap_name}'> which only has " - f"<{n_colors}> colors, which is lower than the max value of your `cmap_values`." + f"<{n_colors}> colors, which is lower than the max value of your `cmap_transform`." f"Choose a cmap with more colors, or a non-quantitative colormap." ) - norm_cmap_values = cmap_values + norm_cmap_values = transform else: # scale between 0 - n_colors so we can just index the colormap as a LUT - norm_cmap_values = (normalize_min_max(cmap_values) * n_colors).astype(int) + norm_cmap_values = (normalize_min_max(transform) * n_colors).astype(int) # use colormap as LUT to map the cmap_values to the colormap index colors = np.vstack([colormap[val] for val in norm_cmap_values]) diff --git a/fastplotlib/utils/gui.py b/fastplotlib/utils/gui.py index b59c7799b..1941674ee 100644 --- a/fastplotlib/utils/gui.py +++ b/fastplotlib/utils/gui.py @@ -44,7 +44,6 @@ def _notebook_print_banner(): - from ipywidgets import Image from IPython.display import display diff --git a/fastplotlib/widgets/histogram_lut.py b/fastplotlib/widgets/histogram_lut.py index 971bc1a28..a3edffcbd 100644 --- a/fastplotlib/widgets/histogram_lut.py +++ b/fastplotlib/widgets/histogram_lut.py @@ -52,29 +52,30 @@ def __init__( origin = (hist_scaled.max() / 2, 0) self._linear_region_selector = LinearRegionSelector( - bounds=bounds, + selection=bounds, limits=limits, size=size, - origin=origin, + center=origin[0], axis="y", edge_thickness=8, + parent=self._histogram_line, ) # there will be a small difference with the histogram edges so this makes them both line up exactly self._linear_region_selector.selection = ( - image_graphic.cmap.vmin, - image_graphic.cmap.vmax, + self._image_graphic.vmin, + self._image_graphic.vmax, ) - self._vmin = self.image_graphic.cmap.vmin - self._vmax = self.image_graphic.cmap.vmax + self._vmin = self.image_graphic.vmin + self._vmax = self.image_graphic.vmax vmin_str, vmax_str = self._get_vmin_vmax_str() self._text_vmin = TextGraphic( text=vmin_str, - size=16, - position=(0, 0), + font_size=16, + offset=(0, 0, 0), anchor="top-left", outline_color="black", outline_thickness=1, @@ -84,8 +85,8 @@ def __init__( self._text_vmax = TextGraphic( text=vmax_str, - size=16, - position=(0, 0), + font_size=16, + offset=(0, 0, 0), anchor="bottom-left", outline_color="black", outline_thickness=1, @@ -105,17 +106,15 @@ def __init__( self.world_object.local.scale_x *= -1 - self._text_vmin.position_x = -120 - self._text_vmin.position_y = self._linear_region_selector.selection()[0] + self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) - self._text_vmax.position_x = -120 - self._text_vmax.position_y = self._linear_region_selector.selection()[1] + self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) - self._linear_region_selector.selection.add_event_handler( - self._linear_region_handler + self._linear_region_selector.add_event_handler( + self._linear_region_handler, "selection" ) - self.image_graphic.cmap.add_event_handler(self._image_cmap_handler) + self.image_graphic.add_event_handler(self._image_cmap_handler, "vmin", "vmax") def _get_vmin_vmax_str(self) -> tuple[str, str]: if self.vmin < 0.001 or self.vmin > 99_999: @@ -198,16 +197,13 @@ def _calculate_histogram(self, data): def _linear_region_handler(self, ev): # must use world coordinate values directly from selection() # otherwise the linear region bounds jump to the closest bin edges - vmin, vmax = self._linear_region_selector.selection() + selected_ixs = self._linear_region_selector.selection + vmin, vmax = selected_ixs[0], selected_ixs[1] vmin, vmax = vmin / self._scale_factor, vmax / self._scale_factor self.vmin, self.vmax = vmin, vmax def _image_cmap_handler(self, ev): - self.vmin, self.vmax = ev.pick_info["vmin"], ev.pick_info["vmax"] - - def _block_events(self, b: bool): - self.image_graphic.cmap.block_events(b) - self._linear_region_selector.selection.block_events(b) + setattr(self, ev.type, ev.info["value"]) @property def vmin(self) -> float: @@ -215,22 +211,24 @@ def vmin(self) -> float: @vmin.setter def vmin(self, value: float): - self._block_events(True) + self.image_graphic.block_events = True + self._linear_region_selector.block_events = True # must use world coordinate values directly from selection() # otherwise the linear region bounds jump to the closest bin edges self._linear_region_selector.selection = ( value * self._scale_factor, - self._linear_region_selector.selection()[1], + self._linear_region_selector.selection[1], ) - self.image_graphic.cmap.vmin = value + self.image_graphic.vmin = value - self._block_events(False) + self.image_graphic.block_events = False + self._linear_region_selector.block_events = False self._vmin = value vmin_str, vmax_str = self._get_vmin_vmax_str() - self._text_vmin.position_y = self._linear_region_selector.selection()[0] + self._text_vmin.offset = (-120, self._linear_region_selector.selection[0], 0) self._text_vmin.text = vmin_str @property @@ -239,22 +237,25 @@ def vmax(self) -> float: @vmax.setter def vmax(self, value: float): - self._block_events(True) + self.image_graphic.block_events = True + self._linear_region_selector.block_events = True # must use world coordinate values directly from selection() # otherwise the linear region bounds jump to the closest bin edges self._linear_region_selector.selection = ( - self._linear_region_selector.selection()[0], + self._linear_region_selector.selection[0], value * self._scale_factor, ) - self.image_graphic.cmap.vmax = value - self._block_events(False) + self.image_graphic.vmax = value + + self.image_graphic.block_events = False + self._linear_region_selector.block_events = False self._vmax = value vmin_str, vmax_str = self._get_vmin_vmax_str() - self._text_vmax.position_y = self._linear_region_selector.selection()[1] + self._text_vmax.offset = (-120, self._linear_region_selector.selection[1], 0) self._text_vmax.text = vmax_str def set_data(self, data, reset_vmin_vmax: bool = True): @@ -262,12 +263,12 @@ def set_data(self, data, reset_vmin_vmax: bool = True): line_data = np.column_stack([hist_scaled, edges_flanked]) - self._histogram_line.data = line_data + # set x and y vals + self._histogram_line.data[:, :2] = line_data bounds = (edges[0], edges[-1]) limits = (edges_flanked[0], edges_flanked[-11]) origin = (hist_scaled.max() / 2, 0) - # self.linear_region.fill.world.position = (*origin, -2) if reset_vmin_vmax: # reset according to the new data @@ -275,9 +276,11 @@ def set_data(self, data, reset_vmin_vmax: bool = True): self._linear_region_selector.selection = bounds else: # don't change the current selection - self._block_events(True) + self.image_graphic.block_events = True + self._linear_region_selector.block_events = True self._linear_region_selector.limits = limits - self._block_events(False) + self.image_graphic.block_events = False + self._linear_region_selector.block_events = False self._data = weakref.proxy(data) @@ -297,14 +300,14 @@ def image_graphic(self, graphic): if self._image_graphic is not None: # cleanup events from current image graphic - self._image_graphic.cmap.remove_event_handler(self._image_cmap_handler) + self._image_graphic.remove_event_handler(self._image_cmap_handler) self._image_graphic = graphic - self.image_graphic.cmap.add_event_handler(self._image_cmap_handler) + self.image_graphic.add_event_handler(self._image_cmap_handler) def disconnect_image_graphic(self): - self._image_graphic.cmap.remove_event_handler(self._image_cmap_handler) + self._image_graphic.remove_event_handler(self._image_cmap_handler) del self._image_graphic # self._image_graphic = None diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 2a4dc31b4..df9b46b55 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -366,7 +366,6 @@ def __init__( if isinstance(data, list): # verify that it's a list of np.ndarray if all([_is_arraylike(d) for d in data]): - # Grid computations if figure_shape is None: figure_shape = calculate_figure_shape(len(data)) @@ -755,7 +754,7 @@ def reset_vmin_vmax(self): Reset the vmin and vmax w.r.t. the full data """ for ig in self.managed_graphics: - ig.cmap.reset_vmin_vmax() + ig.reset_vmin_vmax() def reset_vmin_vmax_frame(self): """ @@ -773,7 +772,7 @@ def reset_vmin_vmax_frame(self): hlut = subplot.docks["right"]["histogram_lut"] # set the data using the current image graphic data - hlut.set_data(subplot["image_widget_managed"].data()) + hlut.set_data(subplot["image_widget_managed"].data.value) def set_data( self, diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 2a480d884..3f45d9007 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -69,7 +69,7 @@ def generate_add_graphics_methods(): f.write(f" {class_name.__init__.__doc__}\n") f.write(' """\n') f.write( - f" return self._create_graphic({class_name.__name__}, {s}*args, **kwargs)\n\n" + f" return self._create_graphic({class_name.__name__}, {s} **kwargs)\n\n" ) f.close() diff --git a/setup.py b/setup.py index b50a6a9bf..3ba77201d 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,6 @@ "ipywidgets>=8.0.0,<9", "sphinx-copybutton", "sphinx-design", - "nbsphinx", "pandoc", "jupyterlab", "sidecar", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/events.py b/tests/events.py new file mode 100644 index 000000000..ea160dec3 --- /dev/null +++ b/tests/events.py @@ -0,0 +1,91 @@ +from functools import partial +import pytest +import numpy as np +from numpy import testing as npt +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent + + +def make_positions_data() -> np.ndarray: + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + return np.column_stack([xs, ys]) + + +def make_line_graphic() -> fpl.LineGraphic: + return fpl.LineGraphic(make_positions_data()) + + +def make_scatter_graphic() -> fpl.ScatterGraphic: + return fpl.ScatterGraphic(make_positions_data()) + + +event_instance: FeatureEvent = None + + +def event_handler(event): + global event_instance + event_instance = event + + +decorated_event_instance: FeatureEvent = None + + +@pytest.mark.parametrize("graphic", [make_line_graphic(), make_scatter_graphic()]) +def test_positions_data_event(graphic: fpl.LineGraphic | fpl.ScatterGraphic): + global decorated_event_instance + global event_instance + + value = np.cos(np.linspace(0, 10 * np.pi, 10))[3:8] + + info = {"key": (slice(3, 8, None), 1), "value": value} + + expected = FeatureEvent(type="data", info=info) + + def validate(graphic, handler, expected_feature_event, event_to_test): + assert expected_feature_event.type == event_to_test.type + assert expected_feature_event.info["key"] == event_to_test.info["key"] + + npt.assert_almost_equal( + expected_feature_event.info["value"], event_to_test.info["value"] + ) + + # should only have one event handler + assert graphic._event_handlers["data"] == {handler} + + # make sure wrappers are correct + wrapper_map = tuple(graphic._event_handler_wrappers["data"])[0] + assert wrapper_map[0] is handler + assert isinstance(wrapper_map[1], partial) + assert wrapper_map[1].func == graphic._handle_event + assert wrapper_map[1].args[0] is handler + + # test remove handler + graphic.remove_event_handler(handler, "data") + assert len(graphic._event_handlers["click"]) == 0 + assert len(graphic._event_handler_wrappers["click"]) == 0 + assert len(graphic.world_object._event_handlers["click"]) == 0 + + # reset data + graphic.data[:, :-1] = make_positions_data() + event_to_test = None + + # test decorated function + @graphic.add_event_handler("data") + def decorated_handler(event): + global decorated_event_instance + decorated_event_instance = event + + # test decorated + graphic.data[3:8, 1] = value + validate(graphic, decorated_handler, expected, decorated_event_instance) + + # test regular + graphic.add_event_handler(event_handler, "data") + graphic.data[3:8, 1] = value + + validate(graphic, event_handler, expected, event_instance) + + event_instance = None diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py new file mode 100644 index 000000000..252c6e5c3 --- /dev/null +++ b/tests/test_colors_buffer_manager.py @@ -0,0 +1,250 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import VertexColors, FeatureEvent +from .utils import ( + generate_slice_indices, + assert_pending_uploads, + generate_color_inputs, + generate_positions_spiral_data, +) + + +def make_colors_buffer() -> VertexColors: + colors = VertexColors(colors="w", n_colors=10) + return colors + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +@pytest.mark.parametrize( + "color_input", + [ + *generate_color_inputs("r"), + *generate_color_inputs("g"), + *generate_color_inputs("b"), + ], +) +def test_create_buffer(color_input): + colors = VertexColors(colors=color_input, n_colors=10) + truth = np.repeat([pygfx.Color(color_input)], 10, axis=0) + npt.assert_almost_equal(colors[:], truth) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +def test_int(test_graphic): + # setting single points + if test_graphic: + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + colors = graphic.colors + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "colors") + else: + colors = make_colors_buffer() + + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + + colors[3] = "r" + npt.assert_almost_equal(colors[3], [1.0, 0.0, 0.0, 1.0]) + assert colors.buffer._gfx_pending_uploads[-1] == (3, 1) + + if test_graphic: + # test event + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == 3 + npt.assert_almost_equal( + EVENT_RETURN_VALUE.info["value"], np.array([[1, 0, 0, 1]]) + ) + assert EVENT_RETURN_VALUE.info["user_value"] == "r" + + colors[6] = [0.0, 1.0, 1.0, 1.0] + npt.assert_almost_equal(colors[6], [0.0, 1.0, 1.0, 1.0]) + + colors[7] = (0.0, 1.0, 1.0, 1.0) + npt.assert_almost_equal(colors[6], [0.0, 1.0, 1.0, 1.0]) + + colors[8] = np.array([1, 0, 1, 1]) + npt.assert_almost_equal(colors[8], [1.0, 0.0, 1.0, 1.0]) + + colors[2] = [1, 0, 1, 0.5] + npt.assert_almost_equal(colors[2], [1.0, 0.0, 1.0, 0.5]) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(0, 16)] +) +def test_tuple(test_graphic, slice_method): + # setting entire array manually + if test_graphic: + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + colors = graphic.colors + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "colors") + else: + colors = make_colors_buffer() + + s = slice_method["slice"] + indices = slice_method["indices"] + others = slice_method["others"] + + # set all RGBA vals + colors[s, :] = 0.5 + truth = np.repeat([[0.5, 0.5, 0.5, 0.5]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + + if test_graphic: + # test event + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == (s, slice(None)) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], truth) + assert EVENT_RETURN_VALUE.info["user_value"] == 0.5 + + # check others are not modified + others_truth = np.repeat([[1.0, 1.0, 1.0, 1.0]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + if test_graphic: + # test setter + graphic.colors = "w" + else: + colors[:] = [1, 1, 1, 1] + truth = np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0) + npt.assert_almost_equal(colors[:], truth) + + if test_graphic: + # test event + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == slice(None) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], truth) + assert EVENT_RETURN_VALUE.info["user_value"] == "w" + + # set just R values + colors[s, 0] = 0.5 + truth = np.repeat([[0.5, 1.0, 1.0, 1.0]], repeats=len(indices), axis=0) + # check others not modified + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0)) + + # set green and blue + colors[s, 1:-1] = 0.7 + truth = np.repeat([[1.0, 0.7, 0.7, 1.0]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0)) + + # set only alpha + colors[s, -1] = 0.2 + truth = np.repeat([[1.0, 1.0, 1.0, 0.2]], repeats=len(indices), axis=0) + npt.assert_almost_equal(colors[indices], truth) + npt.assert_almost_equal(colors[others], others_truth) + + +@pytest.mark.parametrize("color_input", generate_color_inputs("red")) +# skip testing with int since that results in shape [1, 4] with np.repeat, int tested in independent unit test +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(1, 16)] +) +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +def test_slice(color_input, slice_method: dict, test_graphic: bool): + # slicing only first dim + if test_graphic: + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + colors = graphic.colors + + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "colors") + else: + colors = make_colors_buffer() + + # TODO: placeholder until I make a testing figure where we draw frames only on call + colors.buffer._gfx_pending_uploads.clear() + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + colors[s] = color_input + truth = np.repeat([pygfx.Color(color_input)], repeats=len(indices), axis=0) + # check that correct indices are modified + npt.assert_almost_equal(colors[s], truth) + npt.assert_almost_equal(colors[indices], truth) + + # check event + if test_graphic: + global EVENT_RETURN_VALUE + + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == s + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"], s) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], truth) + if isinstance(color_input, str): + assert EVENT_RETURN_VALUE.info["user_value"] == color_input + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["user_value"], color_input) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(colors.buffer, offset, size) + + # check that others are not touched + others_truth = np.repeat([[1.0, 1.0, 1.0, 1.0]], repeats=len(others), axis=0) + npt.assert_almost_equal(colors[others], others_truth) + + # reset + colors[:] = (1, 1, 1, 1) + npt.assert_almost_equal(colors[:], np.repeat([[1.0, 1.0, 1.0, 1.0]], 10, axis=0)) diff --git a/tests/test_common_features.py b/tests/test_common_features.py new file mode 100644 index 000000000..332ac71ae --- /dev/null +++ b/tests/test_common_features.py @@ -0,0 +1,282 @@ +import numpy +import numpy as np +from numpy import testing as npt +import pytest + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent, Name, Offset, Rotation, Visible + + +def make_graphic(kind: str, **kwargs): + match kind: + case "image": + return fpl.ImageGraphic(np.random.rand(10, 10), **kwargs) + case "line": + return fpl.LineGraphic(np.random.rand(10), **kwargs) + case "scatter": + return fpl.ScatterGraphic( + np.column_stack([np.random.rand(10), np.random.rand(10)]), **kwargs + ) + case "text": + return fpl.TextGraphic("bah", **kwargs) + + +graphic_kinds = [ + "image", + "line", + "scatter", + "text", +] + + +RETURN_EVENT_VALUE: FeatureEvent = None +DECORATED_EVENT_VALUE: FeatureEvent = None + + +def return_event(ev: FeatureEvent): + global RETURN_EVENT_VALUE + RETURN_EVENT_VALUE = ev + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_name(graphic): + assert graphic.name is None + + graphic.add_event_handler(return_event, "name") + + graphic.name = "new_name" + + assert graphic.name == "new_name" + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "name" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + assert RETURN_EVENT_VALUE.info["value"] == "new_name" + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "name") + assert len(graphic._event_handlers["name"]) == 0 + + graphic.name = "new_name2" + + assert RETURN_EVENT_VALUE is None + assert graphic.name == "new_name2" + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("name") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.name = "test_dec" + assert graphic.name == "test_dec" + + assert DECORATED_EVENT_VALUE.type == "name" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] == "test_dec" + + +@pytest.mark.parametrize( + "graphic", [make_graphic(k, name="init_name") for k in graphic_kinds] +) +def test_name_init(graphic): + assert graphic.name == "init_name" + + graphic.name = "new_name" + + assert graphic.name == "new_name" + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_offset(graphic): + npt.assert_almost_equal(graphic.offset, (0.0, 0.0, 0.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (0.0, 0.0, 0.0)) + + graphic.add_event_handler(return_event, "offset") + + graphic.offset = (1.0, 2.0, 3.0) + + npt.assert_almost_equal(graphic.offset, (1.0, 2.0, 3.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (1.0, 2.0, 3.0)) + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "offset" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + npt.assert_almost_equal(RETURN_EVENT_VALUE.info["value"], (1.0, 2.0, 3.0)) + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "offset") + assert len(graphic._event_handlers["offset"]) == 0 + + graphic.offset = (4, 5, 6) + + assert RETURN_EVENT_VALUE is None + npt.assert_almost_equal(graphic.offset, (4.0, 5.0, 6.0)) + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("offset") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.offset = (7, 8, 9) + npt.assert_almost_equal(graphic.offset, (7.0, 8.0, 9.0)) + + assert DECORATED_EVENT_VALUE.type == "offset" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] == (7.0, 8.0, 9.0) + + +@pytest.mark.parametrize( + "graphic", [make_graphic(k, offset=(3.0, 4.0, 5.0)) for k in graphic_kinds] +) +def test_offset_init(graphic): + npt.assert_almost_equal(graphic.offset, (3.0, 4.0, 5.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (3.0, 4.0, 5.0)) + + graphic.offset = (6.0, 7.0, 8.0) + + npt.assert_almost_equal(graphic.offset, (6.0, 7.0, 8.0)) + npt.assert_almost_equal(graphic.world_object.world.position, (6.0, 7.0, 8.0)) + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_rotation(graphic): + npt.assert_almost_equal(graphic.rotation, (0, 0, 0, 1)) + npt.assert_almost_equal(graphic.world_object.world.rotation, (0, 0, 0, 1)) + + graphic.add_event_handler(return_event, "rotation") + + graphic.rotation = (0.0, 0.0, 0.30001427, 0.95393471) + + npt.assert_almost_equal(graphic.rotation, (0.0, 0.0, 0.30001427, 0.95393471)) + npt.assert_almost_equal( + graphic.world_object.world.rotation, (0.0, 0.0, 0.30001427, 0.95393471) + ) + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "rotation" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + npt.assert_almost_equal( + RETURN_EVENT_VALUE.info["value"], (0.0, 0.0, 0.30001427, 0.95393471) + ) + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "rotation") + assert len(graphic._event_handlers["rotation"]) == 0 + + graphic.rotation = (0, 0, 0, 1) + + assert RETURN_EVENT_VALUE is None + npt.assert_almost_equal(graphic.rotation, (0, 0, 0, 1)) + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("rotation") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.rotation = (0, 0, 0.6, 0.8) + npt.assert_almost_equal(graphic.rotation, (0, 0, 0.6, 0.8)) + + assert DECORATED_EVENT_VALUE.type == "rotation" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] == (0, 0, 0.6, 0.8) + + +@pytest.mark.parametrize( + "graphic", + [ + make_graphic(k, rotation=(0.0, 0.0, 0.30001427, 0.95393471)) + for k in graphic_kinds + ], +) +def test_rotation(graphic): + npt.assert_almost_equal(graphic.rotation, (0.0, 0.0, 0.30001427, 0.95393471)) + npt.assert_almost_equal( + graphic.world_object.world.rotation, (0.0, 0.0, 0.30001427, 0.95393471) + ) + + graphic.rotation = (0, 0.0, 0.6, 0.8) + + npt.assert_almost_equal(graphic.rotation, (0, 0.0, 0.6, 0.8)) + npt.assert_almost_equal(graphic.world_object.world.rotation, (0, 0.0, 0.6, 0.8)) + + +@pytest.mark.parametrize("graphic", [make_graphic(k) for k in graphic_kinds]) +def test_visible(graphic): + assert graphic.visible is True + assert graphic.world_object.visible is True + + graphic.add_event_handler(return_event, "rotation") + + graphic.visible = False + assert graphic.visible is False + assert graphic.world_object.visible is False + + global RETURN_EVENT_VALUE + + assert RETURN_EVENT_VALUE.type == "visible" + assert RETURN_EVENT_VALUE.graphic is graphic + assert RETURN_EVENT_VALUE.target is graphic.world_object + assert RETURN_EVENT_VALUE.info["value"] is False + + # check removing event handler + RETURN_EVENT_VALUE = None + graphic.remove_event_handler(return_event, "visible") + assert len(graphic._event_handlers["visible"]) == 0 + + graphic.visible = True + + assert RETURN_EVENT_VALUE is None + assert graphic.visible is True + + # check adding event with decorator + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = None + + @graphic.add_event_handler("visible") + def decorated_handler(ev): + global DECORATED_EVENT_VALUE + DECORATED_EVENT_VALUE = ev + + graphic.visible = False + assert graphic.visible is False + + assert DECORATED_EVENT_VALUE.type == "visible" + assert DECORATED_EVENT_VALUE.graphic is graphic + assert DECORATED_EVENT_VALUE.target is graphic.world_object + assert DECORATED_EVENT_VALUE.info["value"] is False + + +@pytest.mark.parametrize( + "graphic", [make_graphic(k, visible=False) for k in graphic_kinds] +) +def test_visible(graphic): + assert graphic.visible is False + assert graphic.world_object.visible is False + + graphic.visible = True + assert graphic.visible is True + assert graphic.world_object.visible is True diff --git a/tests/test_figure.py b/tests/test_figure.py index 27b74c0b6..757b1eeae 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -6,21 +6,18 @@ def test_cameras_controller_properties(): - cameras = [ - ["2d", "3d", "3d"], - ["3d", "3d", "3d"] - ] + cameras = [["2d", "3d", "3d"], ["3d", "3d", "3d"]] controller_types = [ ["panzoom", "panzoom", "fly"], - ["orbit", "trackball", "panzoom"] + ["orbit", "trackball", "panzoom"], ] fig = fpl.Figure( shape=(2, 3), cameras=cameras, controller_types=controller_types, - canvas="offscreen" + canvas="offscreen", ) print(fig.canvas) @@ -34,13 +31,17 @@ def test_cameras_controller_properties(): for c1, c2 in zip(subplot_controllers, fig.controllers.ravel()): assert c1 is c2 - for camera_type, subplot_camera in zip(np.asarray(cameras).ravel(), fig.cameras.ravel()): + for camera_type, subplot_camera in zip( + np.asarray(cameras).ravel(), fig.cameras.ravel() + ): if camera_type == "2d": assert subplot_camera.fov == 0 else: assert subplot_camera.fov == 50 - for controller_type, subplot_controller in zip(np.asarray(controller_types).ravel(), fig.controllers.ravel()): + for controller_type, subplot_controller in zip( + np.asarray(controller_types).ravel(), fig.controllers.ravel() + ): match controller_type: case "panzoom": assert isinstance(subplot_controller, pygfx.PanZoomController) @@ -67,11 +68,7 @@ def test_cameras_controller_properties(): def test_controller_ids_int(): - ids = [ - [0, 1, 1], - [0, 2, 3], - [4, 1, 2] - ] + ids = [[0, 1, 1], [0, 2, 3], [4, 1, 2]] fig = fpl.Figure(shape=(3, 3), controller_ids=ids, canvas="offscreen") @@ -81,19 +78,13 @@ def test_controller_ids_int(): def test_controller_ids_int_change_controllers(): - ids = [ - [0, 1, 1], - [0, 2, 3], - [4, 1, 2] - ] + ids = [[0, 1, 1], [0, 2, 3], [4, 1, 2]] - cameras = [ - ["2d", "3d", "3d"], - ["2d", "3d", "2d"], - ["3d", "3d", "3d"] - ] + cameras = [["2d", "3d", "3d"], ["2d", "3d", "2d"], ["3d", "3d", "3d"]] - fig = fpl.Figure(shape=(3, 3), cameras=cameras, controller_ids=ids, canvas="offscreen") + fig = fpl.Figure( + shape=(3, 3), cameras=cameras, controller_ids=ids, canvas="offscreen" + ) assert isinstance(fig[0, 1].controller, pygfx.FlyController) @@ -101,30 +92,46 @@ def test_controller_ids_int_change_controllers(): fig[0, 1].controller = "panzoom" assert isinstance(fig[0, 1].controller, pygfx.PanZoomController) assert fig[0, 1].controller is fig[0, 2].controller is fig[2, 1].controller - assert set(fig[0, 1].controller.cameras) == {fig[0, 1].camera, fig[0, 2].camera, fig[2, 1].camera} + assert set(fig[0, 1].controller.cameras) == { + fig[0, 1].camera, + fig[0, 2].camera, + fig[2, 1].camera, + } # change to orbit fig[0, 1].controller = "orbit" assert isinstance(fig[0, 1].controller, pygfx.OrbitController) assert fig[0, 1].controller is fig[0, 2].controller is fig[2, 1].controller - assert set(fig[0, 1].controller.cameras) == {fig[0, 1].camera, fig[0, 2].camera, fig[2, 1].camera} + assert set(fig[0, 1].controller.cameras) == { + fig[0, 1].camera, + fig[0, 2].camera, + fig[2, 1].camera, + } def test_controller_ids_str(): - names = [ - ["a", "b", "c"], - ["d", "e", "f"] - ] + names = [["a", "b", "c"], ["d", "e", "f"]] - controller_ids = [ - ["a", "f"], - ["b", "d", "e"] - ] + controller_ids = [["a", "f"], ["b", "d", "e"]] - fig = fpl.Figure(shape=(2, 3), controller_ids=controller_ids, names=names, canvas="offscreen") + fig = fpl.Figure( + shape=(2, 3), controller_ids=controller_ids, names=names, canvas="offscreen" + ) - assert fig[0, 0].controller is fig[1, 2].controller is fig["a"].controller is fig["f"].controller - assert fig[0, 1].controller is fig[1, 0].controller is fig[1, 1].controller is fig["b"].controller is fig["d"].controller is fig["e"].controller + assert ( + fig[0, 0].controller + is fig[1, 2].controller + is fig["a"].controller + is fig["f"].controller + ) + assert ( + fig[0, 1].controller + is fig[1, 0].controller + is fig[1, 1].controller + is fig["b"].controller + is fig["d"].controller + is fig["e"].controller + ) # make sure subplot c is unique exclude_c = [fig[n].controller for n in ["a", "b", "d", "e", "f"]] @@ -137,22 +144,23 @@ def test_set_controllers_from_existing_controllers(): assert fig.controllers[:-1].size == 6 with pytest.raises(ValueError): - fig3 = fpl.Figure(shape=fig.shape, controllers=fig.controllers[:-1], canvas="offscreen") + fig3 = fpl.Figure( + shape=fig.shape, controllers=fig.controllers[:-1], canvas="offscreen" + ) for fig1_subplot, fig2_subplot in zip(fig, fig2): assert fig1_subplot.controller is fig2_subplot.controller - cameras = [ - [pygfx.PerspectiveCamera(), "3d"], - ["3d", "2d"] - ] + cameras = [[pygfx.PerspectiveCamera(), "3d"], ["3d", "2d"]] controllers = [ [pygfx.FlyController(cameras[0][0]), pygfx.TrackballController()], - [pygfx.OrbitController(), pygfx.PanZoomController()] + [pygfx.OrbitController(), pygfx.PanZoomController()], ] - fig = fpl.Figure(shape=(2, 2), cameras=cameras, controllers=controllers, canvas="offscreen") + fig = fpl.Figure( + shape=(2, 2), cameras=cameras, controllers=controllers, canvas="offscreen" + ) assert fig[0, 0].controller is controllers[0][0] assert fig[0, 1].controller is controllers[0][1] diff --git a/tests/test_image_graphic.py b/tests/test_image_graphic.py new file mode 100644 index 000000000..9f89e8aa8 --- /dev/null +++ b/tests/test_image_graphic.py @@ -0,0 +1,205 @@ +import numpy as np +from numpy import testing as npt +import imageio.v3 as iio + +import fastplotlib as fpl +from fastplotlib.graphics._features import FeatureEvent +from fastplotlib.utils import make_colors + +GRAY_IMAGE = iio.imread("imageio:camera.png") +RGB_IMAGE = iio.imread("imageio:astronaut.png") + + +COFFEE_IMAGE = iio.imread("imageio:coffee.png") + +# image cmap, vmin, vmax, interpolations +# new screenshot tests too for these when in graphics + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +def check_event(graphic, feature, value): + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.type == feature + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target == graphic.world_object + if isinstance(EVENT_RETURN_VALUE.info["value"], float): + # floating point error + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], value) + else: + assert EVENT_RETURN_VALUE.info["value"] == value + + +def check_set_slice( + data: np.ndarray, + image_graphic: fpl.ImageGraphic, + row_slice: slice, + col_slice: slice, +): + image_graphic.data[row_slice, col_slice] = 1 + data_values = image_graphic.data.value + npt.assert_almost_equal(data_values[row_slice, col_slice], 1) + + # make sure other vals unchanged + npt.assert_almost_equal(data_values[: row_slice.start], data[: row_slice.start]) + npt.assert_almost_equal(data_values[row_slice.stop :], data[row_slice.stop :]) + npt.assert_almost_equal( + data_values[:, : col_slice.start], data[:, : col_slice.start] + ) + npt.assert_almost_equal(data_values[:, col_slice.stop :], data[:, col_slice.stop :]) + + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.type == "data" + assert EVENT_RETURN_VALUE.graphic == image_graphic + assert EVENT_RETURN_VALUE.target == image_graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == (row_slice, col_slice) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], 1) + + +def test_gray(): + fig = fpl.Figure() + ig = fig[0, 0].add_image(GRAY_IMAGE) + assert isinstance(ig, fpl.ImageGraphic) + + ig.add_event_handler( + event_handler, + "data", + "cmap", + "vmin", + "vmax", + "interpolation", + "cmap_interpolation", + ) + + npt.assert_almost_equal(ig.data.value, GRAY_IMAGE) + + ig.cmap = "viridis" + assert ig.cmap == "viridis" + check_event(graphic=ig, feature="cmap", value="viridis") + + new_colors = make_colors(256, "viridis") + for child in ig.world_object.children: + npt.assert_almost_equal(child.material.map.data, new_colors) + + ig.cmap = "jet" + assert ig.cmap == "jet" + + new_colors = make_colors(256, "jet") + for child in ig.world_object.children: + npt.assert_almost_equal(child.material.map.data, new_colors) + + assert ig.interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.interpolation == "nearest" + + ig.interpolation = "linear" + assert ig.interpolation == "linear" + for child in ig.world_object.children: + assert child.material.interpolation == "linear" + check_event(graphic=ig, feature="interpolation", value="linear") + + assert ig.cmap_interpolation == "linear" + for child in ig.world_object.children: + assert child.material.map_interpolation == "linear" + + ig.cmap_interpolation = "nearest" + assert ig.cmap_interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.map_interpolation == "nearest" + check_event(graphic=ig, feature="cmap_interpolation", value="nearest") + + npt.assert_almost_equal(ig.vmin, GRAY_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, GRAY_IMAGE.max()) + + ig.vmin = 50 + assert ig.vmin == 50 + for child in ig.world_object.children: + assert child.material.clim == (50, ig.vmax) + check_event(graphic=ig, feature="vmin", value=50) + + ig.vmax = 100 + assert ig.vmax == 100 + for child in ig.world_object.children: + assert child.material.clim == (ig.vmin, 100) + check_event(graphic=ig, feature="vmax", value=100) + + # test reset + ig.reset_vmin_vmax() + npt.assert_almost_equal(ig.vmin, GRAY_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, GRAY_IMAGE.max()) + + check_set_slice(GRAY_IMAGE, ig, slice(100, 200), slice(200, 300)) + + # test setting all values + ig.data = 1 + npt.assert_almost_equal(ig.data.value, 1) + + +def test_rgb(): + fig = fpl.Figure() + ig = fig[0, 0].add_image(RGB_IMAGE) + assert isinstance(ig, fpl.ImageGraphic) + + ig.add_event_handler(event_handler, "data") + + npt.assert_almost_equal(ig.data.value, RGB_IMAGE) + + assert ig.interpolation == "nearest" + for child in ig.world_object.children: + assert child.material.interpolation == "nearest" + + ig.interpolation = "linear" + assert ig.interpolation == "linear" + for child in ig.world_object.children: + assert child.material.interpolation == "linear" + + npt.assert_almost_equal(ig.vmin, RGB_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, RGB_IMAGE.max()) + + ig.vmin = 50 + assert ig.vmin == 50 + for child in ig.world_object.children: + assert child.material.clim == (50, ig.vmax) + + ig.vmax = 100 + assert ig.vmax == 100 + for child in ig.world_object.children: + assert child.material.clim == (ig.vmin, 100) + + # test reset + ig.reset_vmin_vmax() + npt.assert_almost_equal(ig.vmin, RGB_IMAGE.min()) + npt.assert_almost_equal(ig.vmax, RGB_IMAGE.max()) + + check_set_slice(RGB_IMAGE, ig, slice(100, 200), slice(200, 300)) + + +def test_rgba(): + rgba = np.zeros(shape=(*COFFEE_IMAGE.shape[:2], 4), dtype=np.float32) + + fig = fpl.Figure() + ig = fig[0, 0].add_image(rgba) + assert isinstance(ig, fpl.ImageGraphic) + + npt.assert_almost_equal(ig.data.value, rgba) + + # fancy indexing + # set the blue values of some pixels with an alpha > 1 + ig.data[COFFEE_IMAGE[:, :, -1] > 200] = np.array([0.0, 0.0, 1.0, 0.6]).astype( + np.float32 + ) + + rgba[COFFEE_IMAGE[:, :, -1] > 200] = np.array([0.0, 0.0, 1.0, 0.6]).astype( + np.float32 + ) + + # check that fancy indexing works + npt.assert_almost_equal(ig.data.value, rgba) diff --git a/tests/test_positions_data_buffer_manager.py b/tests/test_positions_data_buffer_manager.py new file mode 100644 index 000000000..de9d179d8 --- /dev/null +++ b/tests/test_positions_data_buffer_manager.py @@ -0,0 +1,208 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import fastplotlib as fpl +from fastplotlib.graphics._features import VertexPositions, FeatureEvent +from .utils import ( + generate_slice_indices, + assert_pending_uploads, + generate_positions_spiral_data, +) + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +@pytest.mark.parametrize( + "data", [generate_positions_spiral_data(v) for v in ["y", "xy", "xyz"]] +) +def test_create_buffer(data): + points_data = VertexPositions(data) + + if data.ndim == 1: + # only y-vals specified + npt.assert_almost_equal(points_data[:, 1], generate_positions_spiral_data("y")) + # x-vals are auto generated just using arange + npt.assert_almost_equal(points_data[:, 0], np.arange(data.size)) + + elif data.shape[1] == 2: + # test 2D + npt.assert_almost_equal( + points_data[:, :-1], generate_positions_spiral_data("xy") + ) + npt.assert_almost_equal(points_data[:, -1], 0.0) + + elif data.shape[1] == 3: + # test 3D spiral + npt.assert_almost_equal(points_data[:], generate_positions_spiral_data("xyz")) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +def test_int(test_graphic): + # test setting single points + + data = generate_positions_spiral_data("xyz") + if test_graphic: + fig = fpl.Figure() + + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + points = graphic.data + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "data") + else: + points = VertexPositions(data) + + # set all x, y, z points, create a kink in the spiral + points[2] = 1.0 + npt.assert_almost_equal(points[2], 1.0) + # make sure other points are not affected + indices = list(range(10)) + indices.pop(2) + npt.assert_almost_equal(points[indices], data[indices]) + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == 2 + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], 1.0) + + # reset + if test_graphic: + graphic.data = data + else: + points[:] = data + npt.assert_almost_equal(points[:], data) + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + assert EVENT_RETURN_VALUE.info["key"] == slice(None) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], data) + + # just set y value + points[3, 1] = 1.0 + npt.assert_almost_equal(points[3, 1], 1.0) + # make sure others not modified + npt.assert_almost_equal(points[3, 0], data[3, 0]) + npt.assert_almost_equal(points[3, 2], data[3, 2]) + indices = list(range(10)) + indices.pop(3) + npt.assert_almost_equal(points[indices], data[indices]) + + +@pytest.mark.parametrize("test_graphic", [False, "line", "scatter"]) +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(1, 16)] +) # int tested separately +@pytest.mark.parametrize("test_axis", ["y", "xy", "xyz"]) +def test_slice(test_graphic, slice_method: dict, test_axis: str): + data = generate_positions_spiral_data("xyz") + + if test_graphic: + fig = fpl.Figure() + + if test_graphic == "line": + graphic = fig[0, 0].add_line(data=data) + + elif test_graphic == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + points = graphic.data + global EVENT_RETURN_VALUE + graphic.add_event_handler(event_handler, "data") + else: + points = VertexPositions(data) + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + # TODO: placeholder until I make a testing figure where we draw frames only on call + points.buffer._gfx_pending_uploads.clear() + + match test_axis: + case "y": + points[s, 1] = -data[s, 1] + npt.assert_almost_equal(points[s, 1], -data[s, 1]) + npt.assert_almost_equal(points[indices, 1], -data[indices, 1]) + # make sure other points are not modified + npt.assert_almost_equal( + points[others, 1], data[others, 1] + ) # other points in same dimension + npt.assert_almost_equal( + points[:, 2:], data[:, 2:] + ) # dimensions that are not sliced + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == (s, 1) + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"][0], s) + assert EVENT_RETURN_VALUE.info["key"][1] == 1 + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], -data[s, 1]) + + case "xy": + points[s, :-1] = -data[s, :-1] + npt.assert_almost_equal(points[s, :-1], -data[s, :-1]) + npt.assert_almost_equal(points[indices, :-1], -data[s, :-1]) + # make sure other points are not modified + npt.assert_almost_equal( + points[others, :-1], data[others, :-1] + ) # other points in the same dimensions + npt.assert_almost_equal( + points[:, -1], data[:, -1] + ) # dimensions that are not touched + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == (s, slice(None, -1, None)) + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"][0], s) + assert EVENT_RETURN_VALUE.info["key"][1] == slice(None, -1, None) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], -data[s, :-1]) + + case "xyz": + points[s] = -data[s] + npt.assert_almost_equal(points[s], -data[s]) + npt.assert_almost_equal(points[indices], -data[s]) + # make sure other points are not modified + npt.assert_almost_equal(points[others], data[others]) + + # check event + if test_graphic: + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target is graphic.world_object + if isinstance(s, slice): + assert EVENT_RETURN_VALUE.info["key"] == s + else: + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["key"], s) + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], -data[s]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(points.buffer, offset, size) diff --git a/tests/test_positions_graphics.py b/tests/test_positions_graphics.py new file mode 100644 index 000000000..d9c3a4871 --- /dev/null +++ b/tests/test_positions_graphics.py @@ -0,0 +1,446 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import ( + VertexPositions, + VertexColors, + VertexCmap, + UniformColor, + UniformSize, + PointsSizesFeature, + Thickness, + FeatureEvent, +) + +from .utils import ( + generate_positions_spiral_data, + generate_color_inputs, + MULTI_COLORS_TRUTH, + generate_slice_indices, + assert_pending_uploads, +) + + +TRUTH_CMAPS = { + "jet": np.array( + [ + [0.0, 0.0, 0.5, 1.0], + [0.0, 0.0, 0.99910873, 1.0], + [0.0, 0.37843138, 1.0, 1.0], + [0.0, 0.8333333, 1.0, 1.0], + [0.30044276, 1.0, 0.66729915, 1.0], + [0.65464896, 1.0, 0.31309298, 1.0], + [1.0, 0.90123457, 0.0, 1.0], + [1.0, 0.4945534, 0.0, 1.0], + [1.0, 0.08787218, 0.0, 1.0], + [0.5, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ), + "viridis": np.array( + [ + [0.267004, 0.004874, 0.329415, 1.0], + [0.281412, 0.155834, 0.469201, 1.0], + [0.244972, 0.287675, 0.53726, 1.0], + [0.190631, 0.407061, 0.556089, 1.0], + [0.147607, 0.511733, 0.557049, 1.0], + [0.119483, 0.614817, 0.537692, 1.0], + [0.20803, 0.718701, 0.472873, 1.0], + [0.421908, 0.805774, 0.35191, 1.0], + [0.699415, 0.867117, 0.175971, 1.0], + [0.993248, 0.906157, 0.143936, 1.0], + ], + dtype=np.float32, + ), +} + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +def test_sizes_slice(): + pass + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [None, *generate_color_inputs("b")]) +@pytest.mark.parametrize("uniform_color", [True, False]) +@pytest.mark.parametrize("alpha", [1.0, 0.5, 0.0]) +def test_uniform_color(graphic_type, colors, uniform_color, alpha): + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["colors", "uniform_color", "alpha"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + if uniform_color: + assert isinstance(graphic._colors, UniformColor) + assert isinstance(graphic.colors, pygfx.Color) + if colors is None: + # default white + assert graphic.colors == pygfx.Color([1, 1, 1, alpha]) + else: + # should be blue + assert graphic.colors == pygfx.Color([0, 0, 1, alpha]) + + # check pygfx material + npt.assert_almost_equal( + graphic.world_object.material.color, np.asarray(graphic.colors) + ) + else: + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + if colors is None: + # default white + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[1, 1, 1, alpha]], repeats=len(graphic.data), axis=0), + ) + else: + # blue + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[0, 0, 1, alpha]], repeats=len(graphic.data), axis=0), + ) + + # check geometry + npt.assert_almost_equal( + graphic.world_object.geometry.colors.data, graphic.colors.value + ) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize( + "data", [generate_positions_spiral_data(v) for v in ["y", "xy", "xyz"]] +) +def test_positions_graphics_data( + graphic_type, + data, +): + # tests with different ways of passing positions data, x, xy and xyz + fig = fpl.Figure() + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data) + + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data) + + assert isinstance(graphic._data, VertexPositions) + assert isinstance(graphic.data, VertexPositions) + + # n_datapoints must match + assert len(graphic.data.value) == len(data) + + # make sure data is correct + match data.shape[-1]: + case 1: # only y-vals given + npt.assert_almost_equal(graphic.data[:, 1], data) # y vals must match + npt.assert_almost_equal( + graphic.data[:, 0], np.arange(data.size) + ) # VertexData makes x-vals with arange + npt.assert_almost_equal(graphic.data[:, -1], 0) # z-vals must be zeros + case 2: # xy vals given + npt.assert_almost_equal(graphic.data[:, :-1], data) # x and y must match + npt.assert_almost_equal(graphic.data[:, -1], 0) # z-vals must be zero + case 3: # xyz vals given + npt.assert_almost_equal(graphic.data[:], data[:]) # everything must match + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) +@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("alpha", [None, 0.5, 0.0]) +def test_positions_graphic_vertex_colors( + graphic_type, + colors, + uniform_color, + alpha, +): + # test different ways of passing vertex colors + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["colors", "uniform_color", "alpha"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + if alpha is None: # default arg + alpha = 1 + + # color per vertex + # uniform colors is default False, or set to False + assert isinstance(graphic._colors, VertexColors) + assert isinstance(graphic.colors, VertexColors) + assert len(graphic.colors) == len(graphic.data) + + if colors is None: + # default + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[1, 1, 1, alpha]], repeats=len(graphic.data), axis=0), + ) + else: + if len(colors) != len(graphic.data): + # should be single red, regardless of input variant (i.e. str, array, RGBA tuple, etc. + npt.assert_almost_equal( + graphic.colors.value, + np.repeat([[1, 0, 0, alpha]], repeats=len(graphic.data), axis=0), + ) + else: + # multi colors + # use the truth for multi colors test that is pre-set + npt.assert_almost_equal(graphic.colors.value, MULTI_COLORS_TRUTH) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [None, *generate_color_inputs("r")]) +@pytest.mark.parametrize("uniform_color", [None, False]) +@pytest.mark.parametrize("cmap", ["jet"]) +@pytest.mark.parametrize( + "cmap_transform", [None, [3, 5, 2, 1, 0, 6, 9, 7, 4, 8], np.arange(9, -1, -1)] +) +@pytest.mark.parametrize("alpha", [None, 0.5, 0.0]) +def test_cmap( + graphic_type, + colors, + uniform_color, + cmap, + cmap_transform, + alpha, +): + # test different ways of passing cmap args + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["cmap", "cmap_transform", "colors", "uniform_color", "alpha"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + if alpha is None: + alpha = 1.0 + + truth = TRUTH_CMAPS[cmap].copy() + truth[:, -1] = alpha + + # permute if transform is provided + if cmap_transform is not None: + truth = truth[cmap_transform] + npt.assert_almost_equal(graphic.cmap.transform, cmap_transform) + + assert isinstance(graphic._cmap, VertexCmap) + + assert graphic.cmap.name == cmap + + # make sure buffer is identical + # cmap overrides colors argument + assert graphic.colors.buffer is graphic.cmap.buffer + + npt.assert_almost_equal(graphic.cmap.value, truth) + npt.assert_almost_equal(graphic.colors.value, truth) + + # test changing cmap but not transform + graphic.cmap = "viridis" + truth = TRUTH_CMAPS["viridis"].copy() + truth[:, -1] = alpha + + if cmap_transform is not None: + truth = truth[cmap_transform] + + assert graphic.cmap.name == "viridis" + npt.assert_almost_equal(graphic.cmap.value, truth) + npt.assert_almost_equal(graphic.colors.value, truth) + + # test changing transform + cmap_transform = np.random.rand(10) + + # cmap transform is internally normalized between 0 - 1 + cmap_transform_norm = cmap_transform.copy() + cmap_transform_norm -= cmap_transform.min() + cmap_transform_norm /= cmap_transform_norm.max() + cmap_transform_norm *= 255 + + truth = fpl.utils.get_cmap("viridis", alpha=alpha) + truth = np.vstack([truth[val] for val in cmap_transform_norm.astype(int)]) + + graphic.cmap.transform = cmap_transform + npt.assert_almost_equal(graphic.cmap.transform, cmap_transform) + + npt.assert_almost_equal(graphic.cmap.value, truth) + npt.assert_almost_equal(graphic.colors.value, truth) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("cmap", ["jet"]) +@pytest.mark.parametrize( + "colors", [None, *generate_color_inputs("multi")] +) # cmap arg overrides colors +@pytest.mark.parametrize( + "uniform_color", [True] # none of these will work with a uniform buffer +) +def test_incompatible_cmap_color_args(graphic_type, cmap, colors, uniform_color): + # test incompatible cmap args + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["cmap", "colors", "uniform_color"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + +@pytest.mark.parametrize("graphic_type", ["line", "scatter"]) +@pytest.mark.parametrize("colors", [*generate_color_inputs("multi")]) +@pytest.mark.parametrize( + "uniform_color", [True] # none of these will work with a uniform buffer +) +def test_incompatible_color_args(graphic_type, colors, uniform_color): + # test incompatible color args + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["colors", "uniform_color"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + if graphic_type == "line": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_line(data=data, **kwargs) + elif graphic_type == "scatter": + with pytest.raises(TypeError): + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + +@pytest.mark.parametrize("sizes", [None, 5.0, np.linspace(3, 8, 10, dtype=np.float32)]) +@pytest.mark.parametrize("uniform_size", [None, False]) +def test_sizes(sizes, uniform_size): + # test scatter sizes + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["sizes"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + assert isinstance(graphic.sizes, PointsSizesFeature) + assert isinstance(graphic._sizes, PointsSizesFeature) + assert len(data) == len(graphic.sizes) + + if sizes is None: + sizes = 1 # default sizes + + npt.assert_almost_equal(graphic.sizes.value, sizes) + npt.assert_almost_equal( + graphic.world_object.geometry.sizes.data, graphic.sizes.value + ) + + +@pytest.mark.parametrize("sizes", [None, 5.0]) +@pytest.mark.parametrize("uniform_size", [True]) +def test_uniform_size(sizes, uniform_size): + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["sizes", "uniform_size"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_scatter(data=data, **kwargs) + + assert isinstance(graphic.sizes, (float, int)) + assert isinstance(graphic._sizes, UniformSize) + + if sizes is None: + sizes = 1 # default sizes + + npt.assert_almost_equal(graphic.sizes, sizes) + npt.assert_almost_equal(graphic.world_object.material.size, sizes) + + # test changing size + graphic.sizes = 10.0 + assert isinstance(graphic.sizes, float) + assert isinstance(graphic._sizes, UniformSize) + assert graphic.sizes == 10.0 + + +@pytest.mark.parametrize("thickness", [None, 0.5, 5.0]) +def test_thickness(thickness): + fig = fpl.Figure() + + kwargs = dict() + for kwarg in ["thickness"]: + if locals()[kwarg] is not None: + # add to dict of arguments that will be passed + kwargs[kwarg] = locals()[kwarg] + + data = generate_positions_spiral_data("xy") + + graphic = fig[0, 0].add_line(data=data, **kwargs) + + if thickness is None: + thickness = 2.0 # default thickness + + assert isinstance(graphic._thickness, Thickness) + + assert graphic.thickness == thickness + assert graphic.world_object.material.thickness == thickness + + if thickness == 0.5: + assert isinstance(graphic.world_object.material, pygfx.LineThinMaterial) + + else: + assert isinstance(graphic.world_object.material, pygfx.LineMaterial) diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py new file mode 100644 index 000000000..0b34f9588 --- /dev/null +++ b/tests/test_sizes_buffer_manager.py @@ -0,0 +1,76 @@ +import numpy as np +from numpy import testing as npt +import pytest + +from fastplotlib.graphics._features import PointsSizesFeature +from .utils import generate_slice_indices, assert_pending_uploads + + +def generate_data(input_type: str) -> np.ndarray | float: + """ + Point sizes varying with a sine wave + + Parameters + ---------- + input_type: str + one of "sine", "cosine", or "float" + """ + if input_type == "float": + return 10.0 + xs = np.linspace(0, 10 * np.pi, 10) + + if input_type == "sine": + return np.abs(np.sin(xs)).astype(np.float32) + + if input_type == "cosine": + return np.abs(np.cos(xs)).astype(np.float32) + + +@pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]]) +def test_create_buffer(data): + sizes = PointsSizesFeature(data, n_datapoints=10) + + if isinstance(data, float): + npt.assert_almost_equal(sizes[:], generate_data("float")) + + elif isinstance(data, np.ndarray): + npt.assert_almost_equal(sizes[:], generate_data("sine")) + + +@pytest.mark.parametrize( + "slice_method", [generate_slice_indices(i) for i in range(0, 16)] +) +@pytest.mark.parametrize("user_input", ["float", "cosine"]) +def test_slice(slice_method: dict, user_input: str): + data = generate_data("sine") + + s = slice_method["slice"] + indices = slice_method["indices"] + offset = slice_method["offset"] + size = slice_method["size"] + others = slice_method["others"] + + sizes = PointsSizesFeature(data, n_datapoints=10) + + # TODO: placeholder until I make a testing figure where we draw frames only on call + sizes.buffer._gfx_pending_uploads.clear() + + match user_input: + case "float": + sizes[s] = 20.0 + truth = np.full(len(indices), 20.0) + npt.assert_almost_equal(sizes[s], truth) + npt.assert_almost_equal(sizes[indices], truth) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + case "cosine": + cosine = generate_data("cosine") + sizes[s] = cosine[s] + npt.assert_almost_equal(sizes[s], cosine[s]) + npt.assert_almost_equal(sizes[indices], cosine[s]) + # make sure other sizes not modified + npt.assert_almost_equal(sizes[others], data[others]) + + # make sure correct offset and size marked for pending upload + assert_pending_uploads(sizes.buffer, offset, size) diff --git a/tests/test_text_graphic.py b/tests/test_text_graphic.py new file mode 100644 index 000000000..a13dfe690 --- /dev/null +++ b/tests/test_text_graphic.py @@ -0,0 +1,101 @@ +from numpy import testing as npt + +import fastplotlib as fpl +from fastplotlib.graphics._features import ( + FeatureEvent, + TextData, + FontSize, + TextFaceColor, + TextOutlineColor, + TextOutlineThickness, +) + +import pygfx + + +def test_create_graphic(): + fig = fpl.Figure() + data = "lorem ipsum" + text = fig[0, 0].add_text(data) + + assert isinstance(text, fpl.TextGraphic) + + assert isinstance(text._text, TextData) + assert text.text == data + + assert text.font_size == 14 + assert isinstance(text._font_size, FontSize) + assert text.world_object.geometry.font_size == 14 + + assert text.face_color == pygfx.Color("w") + assert isinstance(text._face_color, TextFaceColor) + assert text.world_object.material.color == pygfx.Color("w") + + assert text.outline_color == pygfx.Color("w") + assert isinstance(text._outline_color, TextOutlineColor) + assert text.world_object.material.outline_color == pygfx.Color("w") + + assert text.outline_thickness == 0 + assert isinstance(text._outline_thickness, TextOutlineThickness) + assert text.world_object.material.outline_thickness == 0 + + +EVENT_RETURN_VALUE: FeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +def check_event(graphic, feature, value): + global EVENT_RETURN_VALUE + assert isinstance(EVENT_RETURN_VALUE, FeatureEvent) + assert EVENT_RETURN_VALUE.type == feature + assert EVENT_RETURN_VALUE.graphic == graphic + assert EVENT_RETURN_VALUE.target == graphic.world_object + if isinstance(EVENT_RETURN_VALUE.info["value"], float): + # floating point error + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], value) + else: + assert EVENT_RETURN_VALUE.info["value"] == value + + +def test_text_changes_events(): + fig = fpl.Figure() + data = "lorem ipsum" + text = fig[0, 0].add_text(data) + + text.add_event_handler( + event_handler, + "text", + "font_size", + "face_color", + "outline_color", + "outline_thickness", + ) + + text.text = "bah" + assert text.text == "bah" + # TODO: seems like there isn't a way in pygfx to get the current text as a str? + check_event(graphic=text, feature="text", value="bah") + + text.font_size = 10.0 + assert text.font_size == 10.0 + assert text.world_object.geometry.font_size == 10 + check_event(text, "font_size", 10) + + text.face_color = "r" + assert text.face_color == pygfx.Color("r") + assert text.world_object.material.color == pygfx.Color("r") + check_event(text, "face_color", pygfx.Color("r")) + + text.outline_color = "b" + assert text.outline_color == pygfx.Color("b") + assert text.world_object.material.outline_color == pygfx.Color("b") + check_event(text, "outline_color", pygfx.Color("b")) + + text.outline_thickness = 0.3 + npt.assert_almost_equal(text.outline_thickness, 0.3) + npt.assert_almost_equal(text.world_object.material.outline_thickness, 0.3) + check_event(text, "outline_thickness", 0.3) diff --git a/tests/test_texture_array.py b/tests/test_texture_array.py new file mode 100644 index 000000000..5aecf49a5 --- /dev/null +++ b/tests/test_texture_array.py @@ -0,0 +1,230 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import pygfx + +import fastplotlib as fpl +from fastplotlib.graphics._features import TextureArray, WGPU_MAX_TEXTURE_SIZE +from fastplotlib.graphics.image import _ImageTile + + +def make_data(n_rows: int, n_cols: int) -> np.ndarray: + """ + Makes a 2D array where the amplitude of the sine wave + is increasing along the y-direction (along rows), and + the wavelength is increasing along the x-axis (columns) + """ + xs = np.linspace(0, 1_000, n_cols) + + sine = np.sin(np.sqrt(xs)) + + return np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) + + +def check_texture_array( + data: np.ndarray, + ta: TextureArray, + buffer_size: int, + buffer_shape: tuple[int, int], + row_indices_size: int, + col_indices_size: int, + row_indices_values: np.ndarray, + col_indices_values: np.ndarray, +): + + npt.assert_almost_equal(ta.value, data) + + assert ta.buffer.size == buffer_size + assert ta.buffer.shape == buffer_shape + + assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()]) + + assert ta.row_indices.size == row_indices_size + assert ta.col_indices.size == col_indices_size + npt.assert_array_equal(ta.row_indices, row_indices_values) + npt.assert_array_equal(ta.col_indices, col_indices_values) + + # make sure chunking is correct + for texture, chunk_index, data_slice in ta: + assert ta.buffer[chunk_index] is texture + chunk_row, chunk_col = chunk_index + + data_row_start_index = chunk_row * WGPU_MAX_TEXTURE_SIZE + data_col_start_index = chunk_col * WGPU_MAX_TEXTURE_SIZE + + data_row_stop_index = min( + data.shape[0] - 1, data_row_start_index + WGPU_MAX_TEXTURE_SIZE + ) + data_col_stop_index = min( + data.shape[1] - 1, data_col_start_index + WGPU_MAX_TEXTURE_SIZE + ) + + row_slice = slice(data_row_start_index, data_row_stop_index) + col_slice = slice(data_col_start_index, data_col_stop_index) + + assert data_slice == (row_slice, col_slice) + + +def check_set_slice(data, ta, row_slice, col_slice): + ta[row_slice, col_slice] = 1 + npt.assert_almost_equal(ta[row_slice, col_slice], 1) + + # make sure other vals unchanged + npt.assert_almost_equal(ta[: row_slice.start], data[: row_slice.start]) + npt.assert_almost_equal(ta[row_slice.stop :], data[row_slice.stop :]) + npt.assert_almost_equal(ta[:, : col_slice.start], data[:, : col_slice.start]) + npt.assert_almost_equal(ta[:, col_slice.stop :], data[:, col_slice.stop :]) + + +def make_image_graphic(data) -> fpl.ImageGraphic: + fig = fpl.Figure() + return fig[0, 0].add_image(data) + + +def check_image_graphic(texture_array, graphic): + # make sure each ImageTile has the right texture + for (texture, chunk_index, data_slice), img in zip( + texture_array, graphic.world_object.children + ): + assert isinstance(img, _ImageTile) + assert img.geometry.grid is texture + assert img.world.x == data_slice[1].start + assert img.world.y == data_slice[0].start + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_small_texture(test_graphic): + # tests TextureArray with dims that requires only 1 texture + data = make_data(1_000, 1_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data=data, + ta=ta, + buffer_size=1, + buffer_shape=(1, 1), + row_indices_size=1, + col_indices_size=1, + row_indices_values=np.array([0]), + col_indices_values=np.array([0]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(50, 200), slice(600, 800)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_texture_at_limit(test_graphic): + # tests TextureArray with data that is 8192 x 8192 + data = make_data(WGPU_MAX_TEXTURE_SIZE, WGPU_MAX_TEXTURE_SIZE) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=1, + buffer_shape=(1, 1), + row_indices_size=1, + col_indices_size=1, + row_indices_values=np.array([0]), + col_indices_values=np.array([0]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(5000, 8000), slice(2000, 3000)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_wide(test_graphic): + data = make_data(10_000, 20_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=6, + buffer_shape=(2, 3), + row_indices_size=2, + col_indices_size=3, + row_indices_values=np.array([0, 8192]), + col_indices_values=np.array([0, 8192, 16384]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(6_000, 9_000), slice(12_000, 18_000)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_tall(test_graphic): + data = make_data(20_000, 10_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=6, + buffer_shape=(3, 2), + row_indices_size=3, + col_indices_size=2, + row_indices_values=np.array([0, 8192, 16384]), + col_indices_values=np.array([0, 8192]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(12_000, 18_000), slice(6_000, 9_000)) + + +@pytest.mark.parametrize("test_graphic", [False, True]) +def test_square(test_graphic): + data = make_data(20_000, 20_000) + + if test_graphic: + graphic = make_image_graphic(data) + ta = graphic.data + else: + ta = TextureArray(data) + + check_texture_array( + data, + ta=ta, + buffer_size=9, + buffer_shape=(3, 3), + row_indices_size=3, + col_indices_size=3, + row_indices_values=np.array([0, 8192, 16384]), + col_indices_values=np.array([0, 8192, 16384]), + ) + + if test_graphic: + check_image_graphic(ta, graphic) + + check_set_slice(data, ta, slice(12_000, 18_000), slice(16_000, 19_000)) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..6a25968e1 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,185 @@ +import numpy as np + +import pygfx + + +def generate_slice_indices(kind: int): + n_elements = 10 + a = np.arange(n_elements) + + match kind: + case 0: + # simplest, just int + s = 2 + indices = [2] + + case 1: + # everything, [:] + s = slice(None, None, None) + indices = list(range(10)) + + case 2: + # positive continuous range, [1:5] + s = slice(1, 5, None) + indices = [1, 2, 3, 4] + + case 3: + # positive stepped range, [2:8:2] + s = slice(2, 8, 2) + indices = [2, 4, 6] + + case 4: + # negative continuous range, [-5:] + s = slice(-5, None, None) + indices = [5, 6, 7, 8, 9] + + case 5: + # negative backwards, [-5::-1] + s = slice(-5, None, -1) + indices = [5, 4, 3, 2, 1, 0] + + case 5: + # negative backwards stepped, [-5::-2] + s = slice(-5, None, -2) + indices = [5, 3, 1] + + case 6: + # negative stepped forward[-5::2] + s = slice(-5, None, 2) + indices = [5, 7, 9] + + case 7: + # both negative, [-8:-2] + s = slice(-8, -2, None) + indices = [2, 3, 4, 5, 6, 7] + + case 8: + # both negative and stepped, [-8:2:2] + s = slice(-8, -2, 2) + indices = [2, 4, 6] + + case 9: + # positive, negative, negative, [8:-9:-2] + s = slice(8, -9, -2) + indices = [8, 6, 4, 2] + + case 10: + # only stepped forward, [::2] + s = slice(None, None, 2) + indices = [0, 2, 4, 6, 8] + + case 11: + # only stepped backward, [::-3] + s = slice(None, None, -3) + indices = [9, 6, 3, 0] + + case 12: + # list indices + s = [2, 5, 9] + indices = [2, 5, 9] + + case 13: + # bool indices + s = a > 5 + indices = [6, 7, 8, 9] + + case 14: + # list indices with negatives + s = [1, 4, -2] + indices = [1, 4, 8] + + case 15: + # array indices + s = np.array([1, 4, -7, 9]) + indices = [1, 4, 3, 9] + + others = [i for i in a if i not in indices] + + offset, size = (min(indices), np.ptp(indices) + 1) + + return { + "slice": s, + "indices": indices, + "others": others, + "offset": offset, + "size": size, + } + + +def assert_pending_uploads(buffer: pygfx.Buffer, offset: int, size: int): + upload_offset, upload_size = buffer._gfx_pending_uploads[-1] + # sometimes when slicing with step, it will over-estimate offset + # but it overestimates to upload 1 extra point so it's fine + assert (upload_offset == offset) or (upload_offset == offset - 1) + + # sometimes when slicing with step, it will over-estimate size + # but it overestimates to upload 1 extra point so it's fine + assert (upload_size == size) or (upload_size == size + 1) + + +def generate_positions_spiral_data(inputs: str) -> np.ndarray: + """ + Generates a spiral/spring + + Only 10 points so a very pointy spiral but easier to spot changes :D + """ + xs = np.linspace(0, 10 * np.pi, 10) + ys = np.sin(xs) + zs = np.cos(xs) + + match inputs: + case "y": + data = ys + + case "xy": + data = np.column_stack([xs, ys]) + + case "xyz": + data = np.column_stack([xs, ys, zs]) + + return data.astype(np.float32) + + +def generate_color_inputs( + name: str, +) -> list[str, np.ndarray, list, tuple] | list[str, np.ndarray]: + if name == "multi": + s = [ + "r", + "g", + "b", + "cyan", + "magenta", + "green", + "yellow", + "white", + "purple", + "orange", + ] + array = np.vstack([pygfx.Color(c) for c in s]) + return [s, array] + + color = pygfx.Color(name) + + s = name + a = np.array(color) + l = list(color) + t = tuple(color) + + return [s, a, l, t] + + +MULTI_COLORS_TRUTH = np.array( + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [0.0, 1.0, 1.0, 1.0], + [1.0, 0.0, 1.0, 1.0], + [0.0, 0.501960813999176, 0.0, 1.0], + [1.0, 1.0, 0.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + [0.501960813999176, 0.0, 0.501960813999176, 1.0], + [1.0, 0.6470588445663452, 0.0, 1.0], + ] +) pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy