diff --git a/docs/source/api/graphic_features/EdgeWidth.rst b/docs/source/api/graphic_features/EdgeWidth.rst new file mode 100644 index 000000000..ba912dc2a --- /dev/null +++ b/docs/source/api/graphic_features/EdgeWidth.rst @@ -0,0 +1,35 @@ +.. _api.EdgeWidth: + +EdgeWidth +********* + +========= +EdgeWidth +========= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: EdgeWidth_api + + EdgeWidth + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: EdgeWidth_api + + EdgeWidth.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: EdgeWidth_api + + EdgeWidth.add_event_handler + EdgeWidth.block_events + EdgeWidth.clear_event_handlers + EdgeWidth.remove_event_handler + EdgeWidth.set_value + diff --git a/docs/source/api/graphic_features/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst deleted file mode 100644 index f3f78b74b..000000000 --- a/docs/source/api/graphic_features/PointsSizesFeature.rst +++ /dev/null @@ -1,37 +0,0 @@ -.. _api.PointsSizesFeature: - -PointsSizesFeature -****************** - -================== -PointsSizesFeature -================== -.. currentmodule:: fastplotlib.graphics.features - -Constructor -~~~~~~~~~~~ -.. autosummary:: - :toctree: PointsSizesFeature_api - - PointsSizesFeature - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: PointsSizesFeature_api - - PointsSizesFeature.buffer - PointsSizesFeature.shared - PointsSizesFeature.value - -Methods -~~~~~~~ -.. autosummary:: - :toctree: PointsSizesFeature_api - - PointsSizesFeature.add_event_handler - PointsSizesFeature.block_events - PointsSizesFeature.clear_event_handlers - PointsSizesFeature.remove_event_handler - PointsSizesFeature.set_value - diff --git a/docs/source/api/graphic_features/UniformEdgeColor.rst b/docs/source/api/graphic_features/UniformEdgeColor.rst new file mode 100644 index 000000000..26489e6d7 --- /dev/null +++ b/docs/source/api/graphic_features/UniformEdgeColor.rst @@ -0,0 +1,35 @@ +.. _api.UniformEdgeColor: + +UniformEdgeColor +**************** + +================ +UniformEdgeColor +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformEdgeColor_api + + UniformEdgeColor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformEdgeColor_api + + UniformEdgeColor.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformEdgeColor_api + + UniformEdgeColor.add_event_handler + UniformEdgeColor.block_events + UniformEdgeColor.clear_event_handlers + UniformEdgeColor.remove_event_handler + UniformEdgeColor.set_value + diff --git a/docs/source/api/graphic_features/UniformMarker.rst b/docs/source/api/graphic_features/UniformMarker.rst new file mode 100644 index 000000000..56b6c2fa4 --- /dev/null +++ b/docs/source/api/graphic_features/UniformMarker.rst @@ -0,0 +1,35 @@ +.. _api.UniformMarker: + +UniformMarker +************* + +============= +UniformMarker +============= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformMarker_api + + UniformMarker + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformMarker_api + + UniformMarker.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformMarker_api + + UniformMarker.add_event_handler + UniformMarker.block_events + UniformMarker.clear_event_handlers + UniformMarker.remove_event_handler + UniformMarker.set_value + diff --git a/docs/source/api/graphic_features/UniformRotations.rst b/docs/source/api/graphic_features/UniformRotations.rst new file mode 100644 index 000000000..f834dbe20 --- /dev/null +++ b/docs/source/api/graphic_features/UniformRotations.rst @@ -0,0 +1,35 @@ +.. _api.UniformRotations: + +UniformRotations +**************** + +================ +UniformRotations +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: UniformRotations_api + + UniformRotations + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: UniformRotations_api + + UniformRotations.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: UniformRotations_api + + UniformRotations.add_event_handler + UniformRotations.block_events + UniformRotations.clear_event_handlers + UniformRotations.remove_event_handler + UniformRotations.set_value + diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst index fd1013620..57b9d6311 100644 --- a/docs/source/api/graphic_features/VertexCmap.rst +++ b/docs/source/api/graphic_features/VertexCmap.rst @@ -22,7 +22,6 @@ Properties VertexCmap.buffer VertexCmap.name - VertexCmap.shared VertexCmap.transform VertexCmap.value diff --git a/docs/source/api/graphic_features/VertexColors.rst b/docs/source/api/graphic_features/VertexColors.rst index d09da7a18..b72b7564a 100644 --- a/docs/source/api/graphic_features/VertexColors.rst +++ b/docs/source/api/graphic_features/VertexColors.rst @@ -21,7 +21,6 @@ Properties :toctree: VertexColors_api VertexColors.buffer - VertexColors.shared VertexColors.value Methods diff --git a/docs/source/api/graphic_features/VertexMarkers.rst b/docs/source/api/graphic_features/VertexMarkers.rst new file mode 100644 index 000000000..bea8dd346 --- /dev/null +++ b/docs/source/api/graphic_features/VertexMarkers.rst @@ -0,0 +1,37 @@ +.. _api.VertexMarkers: + +VertexMarkers +************* + +============= +VertexMarkers +============= +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexMarkers_api + + VertexMarkers + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexMarkers_api + + VertexMarkers.buffer + VertexMarkers.value + VertexMarkers.value_int + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexMarkers_api + + VertexMarkers.add_event_handler + VertexMarkers.block_events + VertexMarkers.clear_event_handlers + VertexMarkers.remove_event_handler + VertexMarkers.set_value + diff --git a/docs/source/api/graphic_features/VertexPointSizes.rst b/docs/source/api/graphic_features/VertexPointSizes.rst new file mode 100644 index 000000000..07f195f6d --- /dev/null +++ b/docs/source/api/graphic_features/VertexPointSizes.rst @@ -0,0 +1,36 @@ +.. _api.VertexPointSizes: + +VertexPointSizes +**************** + +================ +VertexPointSizes +================ +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPointSizes_api + + VertexPointSizes + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexPointSizes_api + + VertexPointSizes.buffer + VertexPointSizes.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexPointSizes_api + + VertexPointSizes.add_event_handler + VertexPointSizes.block_events + VertexPointSizes.clear_event_handlers + VertexPointSizes.remove_event_handler + VertexPointSizes.set_value + diff --git a/docs/source/api/graphic_features/VertexPositions.rst b/docs/source/api/graphic_features/VertexPositions.rst index d181f07b9..95480e1d4 100644 --- a/docs/source/api/graphic_features/VertexPositions.rst +++ b/docs/source/api/graphic_features/VertexPositions.rst @@ -21,7 +21,6 @@ Properties :toctree: VertexPositions_api VertexPositions.buffer - VertexPositions.shared VertexPositions.value Methods diff --git a/docs/source/api/graphic_features/VertexRotations.rst b/docs/source/api/graphic_features/VertexRotations.rst new file mode 100644 index 000000000..97cf5f4e2 --- /dev/null +++ b/docs/source/api/graphic_features/VertexRotations.rst @@ -0,0 +1,36 @@ +.. _api.VertexRotations: + +VertexRotations +*************** + +=============== +VertexRotations +=============== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: VertexRotations_api + + VertexRotations + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: VertexRotations_api + + VertexRotations.buffer + VertexRotations.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: VertexRotations_api + + VertexRotations.add_event_handler + VertexRotations.block_events + VertexRotations.clear_event_handlers + VertexRotations.remove_event_handler + VertexRotations.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index a2b4aec47..d008b5202 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -6,12 +6,18 @@ Graphic Features VertexColors UniformColor - UniformSize SizeSpace - Thickness VertexPositions - PointsSizesFeature VertexCmap + Thickness + VertexMarkers + UniformMarker + UniformEdgeColor + EdgeWidth + UniformRotations + VertexRotations + VertexPointSizes + UniformSize TextureArray ImageCmap ImageVmin diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 48d30d01f..7f4336abe 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -28,9 +28,16 @@ Properties ScatterGraphic.colors ScatterGraphic.data ScatterGraphic.deleted + ScatterGraphic.edge_colors + ScatterGraphic.edge_width ScatterGraphic.event_handlers + ScatterGraphic.image + ScatterGraphic.markers + ScatterGraphic.mode ScatterGraphic.name ScatterGraphic.offset + ScatterGraphic.point_rotation_mode + ScatterGraphic.point_rotations ScatterGraphic.right_click_menu ScatterGraphic.rotation ScatterGraphic.size_space diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index d61bff2ee..c1f5b89e0 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -39,11 +39,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -217,11 +217,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -236,6 +236,80 @@ cmap | value | str | new cmap to set at given slice | +----------+-------+--------------------------------+ +markers +^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced | ++----------+----------------------------------------------+------------------------------------------------+ +| value | str | np.ndarray[str] | new marker values for points that were changed | ++----------+----------------------------------------------+------------------------------------------------+ + +markers +^^^^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | str | None | new marker value | ++----------+------------+------------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+----------------+ +| dict key | type | description | ++==========+==================================================+================+ +| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color | ++----------+--------------------------------------------------+----------------+ + +edge_colors +^^^^^^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +edge_width +^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +image +^^^^^ + +**event info dict** + ++----------+--------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+======================================+==================================================+ +| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed | ++----------+--------------------------------------+--------------------------------------------------+ +| value | np.ndarray | float | new data values | ++----------+--------------------------------------+--------------------------------------------------+ + size_space ^^^^^^^^^^ @@ -247,6 +321,30 @@ size_space | value | str | 'screen' | 'world' | 'model' | +----------+------+------------------------------+ +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+-------+----------------+ +| dict key | type | description | ++==========+=======+================+ +| value | float | new edge_width | ++----------+-------+----------------+ + +point_rotations +^^^^^^^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+==================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------+ +| value | int | float | array-like | new rotation values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------+ + name ^^^^ @@ -868,11 +966,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ @@ -1022,11 +1120,11 @@ colors **event info dict** -+----------+-------------------+-----------------+ -| dict key | type | description | -+==========+===================+=================+ -| value | np.ndarray [RGBA] | new color value | -+----------+-------------------+-----------------+ ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ cmap ^^^^ diff --git a/examples/notebooks/test_gc.ipynb b/examples/notebooks/test_gc.ipynb index df08e7a2d..92000f27e 100644 --- a/examples/notebooks/test_gc.ipynb +++ b/examples/notebooks/test_gc.ipynb @@ -120,6 +120,11 @@ "\n", "for g in objects:\n", " for feature in g._features:\n", + " if not hasattr(g, f\"_{feature}\"):\n", + " continue\n", + "\n", + " if getattr(g, f\"_{feature}\") is None:\n", + " continue # not in the right mode to support this feature\n", " g.add_event_handler(feature_changed_handler, feature)" ] }, diff --git a/examples/scatter/scatter_image_as_points.py b/examples/scatter/scatter_image_as_points.py new file mode 100644 index 000000000..aeae30bd0 --- /dev/null +++ b/examples/scatter/scatter_image_as_points.py @@ -0,0 +1,55 @@ +""" +Scatter image as points +======================= + +Display a scatter using an image as the points. These are also called sprites. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +xs = np.linspace(0, 2 * np.pi, 10) + +# make sine and cosine data +sine = np.column_stack([xs, np.sin(xs)]) +cosine = np.column_stack([xs, np.cos(xs)]) + +# a simple image to display as the points +array = np.array([ + [1, 0, 1], + [0, 1, 0], + [1, 1, 1], +]) + +# load an image of Almar's cat +wikkie = np.flipud(iio.imread("imageio:wikkie.png")) + +figure = fpl.Figure(size=(700, 350)) + +scatter = figure[0, 0].add_scatter( + data=sine, + mode="image", # mode must be "image", otherwise the `image` arg is ignored and markers are used + image=array, + cmap="jet", # the image is multiplied by the scatter point colors if provided + sizes=25, +) + +scatter2 = figure[0, 0].add_scatter( + data=cosine, + mode="image", + image=wikkie, # if an RGB(A) image is provided and no colors are provided, then the image is shown as-is + sizes=40, +) + +figure.show() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py index 03b0ee67e..b9df16026 100644 --- a/examples/scatter/scatter_iris.py +++ b/examples/scatter/scatter_iris.py @@ -6,24 +6,36 @@ """ # test_example = true -# sphinx_gallery_pygfx_docs = 'hidden' +# sphinx_gallery_pygfx_docs = 'screenshot' import fastplotlib as fpl import numpy as np -from pathlib import Path -import sys +from sklearn.cluster import AgglomerativeClustering +from sklearn import datasets + figure = fpl.Figure(size=(700, 560)) -current_file = Path(sys.argv[0]).resolve() +data, target = datasets.load_iris(return_X_y=True) +data = data[:, :2] # use only first 2 features + +# map target class to scatter point marker +markers_map = {0: "o", 1: "s", 2: "+"} +markers = list(map(markers_map.get, target)) -data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy") -data = np.load(data_path) +agg = AgglomerativeClustering(n_clusters=3) +agg.fit_predict(data) -n_points = 50 -colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points +clusters_labels = agg.labels_ -scatter = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors) +scatter = figure[0, 0].add_scatter( + data=data, + sizes=10, + alpha=0.7, + cmap="tab10", + cmap_transform=clusters_labels, + markers=markers, +) figure.show() diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py new file mode 100644 index 000000000..abddffee0 --- /dev/null +++ b/examples/scatter/scatter_validate.py @@ -0,0 +1,77 @@ +""" +Scatter validation +================== + +Example that shows some scatter plot features for test validation. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + +xs = np.linspace(0, 2 * np.pi, 10) + +# make sine and cosine data +sine = np.column_stack([xs, np.sin(xs)]) +cosine = np.column_stack([xs, np.cos(xs)]) + +# a simple image to display as the points +array = np.array([ + [1, 0, 1], + [0, 1, 0], + [1, 1, 1], +]) + +# load an image of Almar's cat +wikkie = np.flipud(iio.imread("imageio:wikkie.png")) + +figure = fpl.Figure( + size=(700, 560) +) + +figure[0, 0].add_scatter(sine) + +# combinations of per-point markers, colors and edge colors +figure[0, 0].add_scatter( + sine, + colors=["magenta"] * 3 + ["cyan"] * 3 + ["yellow"] * 3 + ["purple"], + uniform_edge_color=False, + edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"], + markers=list("osD+x^v<>*"), + edge_width=2.0, + sizes=20, + uniform_size=True, +) + + +# per-point rotations +figure[0, 0].add_scatter( + sine, + markers="^", + sizes=20, + point_rotation_mode="vertex", + point_rotations=xs, + uniform_size=True, + offset=(0, 1, 0) +) + + +# point sizes +figure[0, 0].add_scatter( + sine, + markers="s", + sizes=xs * 5, + offset=(0, 2, 0) +) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/screenshots/imgui_basic.png b/examples/screenshots/imgui_basic.png index cf98b035c..32e1b52c2 100644 --- a/examples/screenshots/imgui_basic.png +++ b/examples/screenshots/imgui_basic.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56bfbc50bc1b625d3e1d580a747bfac9b3af44a5cb6df78d75c7cc6c8a0a30ea -size 35926 +oid sha256:5194566726b85eb2e5cfbe04785f86698f0bfe1f0bd4cc39bbca1102f5da655b +size 35790 diff --git a/examples/screenshots/no-imgui-scatter_cmap_iris.png b/examples/screenshots/no-imgui-scatter_cmap_iris.png index d2b7fe814..60ef9f37c 100644 --- a/examples/screenshots/no-imgui-scatter_cmap_iris.png +++ b/examples/screenshots/no-imgui-scatter_cmap_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52f8ca8276e89b37811d9c6e63479adafebc336035e7fe4d6c0bc2e7020737d3 -size 44151 +oid sha256:4dccc0e78ec14b320491155fe4d3bed0b0acebc6bf25ca1d569ebffd84e74cf1 +size 42745 diff --git a/examples/screenshots/no-imgui-scatter_colorslice_iris.png b/examples/screenshots/no-imgui-scatter_colorslice_iris.png index 014376df7..baa7189b6 100644 --- a/examples/screenshots/no-imgui-scatter_colorslice_iris.png +++ b/examples/screenshots/no-imgui-scatter_colorslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:341b2b24ec810ccc6dde388c8ecef42a6022b78270087c2a1c85b22d79b9bc84 -size 28191 +oid sha256:c337c47f63837df1ee22801321e092341d00e241e3624916d4a5bd7a99280419 +size 24092 diff --git a/examples/screenshots/no-imgui-scatter_dataslice_iris.png b/examples/screenshots/no-imgui-scatter_dataslice_iris.png index 1134126b9..4b8f048fe 100644 --- a/examples/screenshots/no-imgui-scatter_dataslice_iris.png +++ b/examples/screenshots/no-imgui-scatter_dataslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6e09668ba3aa56f5b5b77fa9749982e89e5a974af8f11364896c3811f8629e8 -size 26590 +oid sha256:a40aa8b1b9a57aaf41bbf0d79f7772d34851558a34a47cb1c30847035d0302a4 +size 24451 diff --git a/examples/screenshots/no-imgui-scatter_image_as_points.png b/examples/screenshots/no-imgui-scatter_image_as_points.png new file mode 100644 index 000000000..4ed688e7d --- /dev/null +++ b/examples/screenshots/no-imgui-scatter_image_as_points.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d8d71ee1382ce87b25afeeb2759d1e8474699f5c86a93cffacb5c7b22e787a0 +size 58639 diff --git a/examples/screenshots/no-imgui-scatter_iris.png b/examples/screenshots/no-imgui-scatter_iris.png index 5f25e6247..5cec5446d 100644 --- a/examples/screenshots/no-imgui-scatter_iris.png +++ b/examples/screenshots/no-imgui-scatter_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0f086b794b91cec140f2bf63decc31cdff1798f62aa9ac3b5fab0763361ab8a -size 25954 +oid sha256:d473448543c094e30fb4fbf602c6a5a84995ac671b1fc05a34bd1a4ee9cb1734 +size 30225 diff --git a/examples/screenshots/no-imgui-scatter_size.png b/examples/screenshots/no-imgui-scatter_size.png index 8e2ac0323..cf5b140d7 100644 --- a/examples/screenshots/no-imgui-scatter_size.png +++ b/examples/screenshots/no-imgui-scatter_size.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61fbbf5ed203e94bfa2d5e7d6fd62a01200177b7fd2c5499e491935a6eb6586a -size 41926 +oid sha256:bf652dda41ab68d04fd98bb1a1569a2d704333181d0018957403c238ab8e8e4d +size 43341 diff --git a/examples/screenshots/no-imgui-scatter_validate.png b/examples/screenshots/no-imgui-scatter_validate.png new file mode 100644 index 000000000..59919b51e --- /dev/null +++ b/examples/screenshots/no-imgui-scatter_validate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a2646bb946948ba5ce9af0e0f0adaca745c39a2770ca27af7fcd0830350fd1b +size 19108 diff --git a/examples/screenshots/scatter_cmap_iris.png b/examples/screenshots/scatter_cmap_iris.png index 57faa7feb..74fae9f55 100644 --- a/examples/screenshots/scatter_cmap_iris.png +++ b/examples/screenshots/scatter_cmap_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5fdce90b719794c08edb2210dfedb0a8229438435cfe502e569c27e3d7dba230 -size 45582 +oid sha256:641ff08c68450320aaa31f53035d69938e4e0347273e576fc2847e3eb050e1f5 +size 44445 diff --git a/examples/screenshots/scatter_colorslice_iris.png b/examples/screenshots/scatter_colorslice_iris.png index 4ac153675..821784a26 100644 --- a/examples/screenshots/scatter_colorslice_iris.png +++ b/examples/screenshots/scatter_colorslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f207f84a7ff2acc7cf28e798dc01f063c0d3a9a53db103561e01e038ce45cf97 -size 29644 +oid sha256:6df93416cee2dc13b5435ef56a0dcfe02e3b8d087ca28077693c0cf270881bf5 +size 25438 diff --git a/examples/screenshots/scatter_dataslice_iris.png b/examples/screenshots/scatter_dataslice_iris.png index d59514086..55f376161 100644 --- a/examples/screenshots/scatter_dataslice_iris.png +++ b/examples/screenshots/scatter_dataslice_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b89bf9a1563fdbc0076a058e2d7d21a5d508c7579f6c1c46871de1c608949af3 -size 27833 +oid sha256:7c49cb755a020fedc9de46256357321a2bc4170a57ba96030cdbd4f0abffec6d +size 25765 diff --git a/examples/screenshots/scatter_image_as_points.png b/examples/screenshots/scatter_image_as_points.png new file mode 100644 index 000000000..d2a3e1821 --- /dev/null +++ b/examples/screenshots/scatter_image_as_points.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69d84b6e7da872fc6cce3838fb1eb2ad87fe5d846962ef264d8390904ca53abb +size 60287 diff --git a/examples/screenshots/scatter_iris.png b/examples/screenshots/scatter_iris.png index be2872c3c..8c6f8d402 100644 --- a/examples/screenshots/scatter_iris.png +++ b/examples/screenshots/scatter_iris.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62cc26b015cd588b7a40146aa464efc451d8acaee39e1f0031a586b37f1304fe -size 27352 +oid sha256:5d78bbf60c03baf857413ca0fa0c189a5c9cdf90a7c0a1bc443b6ea7fa4d8f6b +size 31881 diff --git a/examples/screenshots/scatter_size.png b/examples/screenshots/scatter_size.png index ca413760a..9da829f65 100644 --- a/examples/screenshots/scatter_size.png +++ b/examples/screenshots/scatter_size.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20fa7dd6e09d8c4deccf3cf7166c73d36f194c7a4f6cb24fc605e9571e886cce -size 44273 +oid sha256:8351d9c26f034e04d5ce7b205337e48b6e827a0ee9ecd52bf69935af7d79f9af +size 47048 diff --git a/examples/screenshots/scatter_validate.png b/examples/screenshots/scatter_validate.png new file mode 100644 index 000000000..1ce5e0f1d --- /dev/null +++ b/examples/screenshots/scatter_validate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:680542f32e591dbf0426e5f550e26ab7a031aae52a6303528ccde7fae524124e +size 20288 diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py index 105325b3d..dd3a3260d 100644 --- a/examples/tests/test_examples.py +++ b/examples/tests/test_examples.py @@ -163,7 +163,7 @@ def test_example_screenshots(module, prep_environment): rgb = normalize_image(rgb) ref_img = normalize_image(ref_img) - similar, rmse = image_similarity(rgb, ref_img, threshold=0.05) + similar, rmse = image_similarity(rgb, ref_img) update_diffs(module.stem, similar, rgb, ref_img) assert similar, ( diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 81694de33..a4f3e9a67 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -335,6 +335,13 @@ def decorator(_callback): if t in self._features.keys(): # fpl feature event feature = getattr(self, f"_{t}") + + if feature is None: + # feature is None in the graphic's current mode, probably is a scatter graphic + raise AttributeError( + f"{self} does not have the passed feature: '{t}' in its current mode." + ) + feature.add_event_handler(_callback_wrapper) else: # wrap pygfx event diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 143c4cc85..73520cc84 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Sequence import numpy as np @@ -9,7 +9,6 @@ VertexColors, UniformColor, VertexCmap, - PointsSizesFeature, SizeSpace, ) @@ -36,7 +35,7 @@ def colors(self) -> VertexColors | pygfx.Color: return self._colors.value @colors.setter - def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]): + def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): if isinstance(self._colors, VertexColors): self._colors[:] = value diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index eb834b674..95e4d321f 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -1,13 +1,21 @@ from ._positions_graphics import ( VertexColors, UniformColor, - UniformSize, SizeSpace, - Thickness, VertexPositions, - PointsSizesFeature, VertexCmap, ) +from ._line import Thickness +from ._scatter import ( + VertexMarkers, + UniformMarker, + UniformEdgeColor, + EdgeWidth, + UniformRotations, + VertexRotations, + VertexPointSizes, + UniformSize, +) from ._image import ( TextureArray, ImageCmap, @@ -54,12 +62,18 @@ __all__ = [ "VertexColors", "UniformColor", - "UniformSize", "SizeSpace", - "Thickness", "VertexPositions", - "PointsSizesFeature", "VertexCmap", + "Thickness", + "VertexMarkers", + "UniformMarker", + "UniformEdgeColor", + "EdgeWidth", + "UniformRotations", + "VertexRotations", + "VertexPointSizes", + "UniformSize", "TextureArray", "ImageCmap", "ImageVmin", diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 153f6aad2..5dec9f1e5 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -49,7 +49,8 @@ def __init__(self, type: str, info: dict): class GraphicFeature: - def __init__(self, **kwargs): + def __init__(self, property_name: str, **kwargs): + self._property_name = property_name self._event_handlers = list() self._block_events = False @@ -139,10 +140,9 @@ def __init__( data: NDArray | pygfx.Buffer, buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer", isolated_buffer: bool = True, - texture_dim: int = 2, **kwargs, ): - super().__init__() + super().__init__(**kwargs) if isolated_buffer and not isinstance(data, pygfx.Resource): # useful if data is read-only, example: memmaps bdata = np.zeros(data.shape, dtype=data.dtype) @@ -157,9 +157,6 @@ def __init__( self._buffer = data elif buffer_type == "buffer": self._buffer = pygfx.Buffer(bdata) - elif buffer_type == "texture": - # TODO: placeholder, not currently used since TextureArray is used specifically for Image graphics - self._buffer = pygfx.Texture(bdata, dim=texture_dim) else: raise ValueError( "`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'" @@ -167,8 +164,6 @@ def __init__( self._event_handlers: list[callable] = list() - self._shared: int = 0 - @property def value(self) -> np.ndarray: """numpy array object representing the data managed by this buffer""" @@ -183,11 +178,6 @@ def buffer(self) -> pygfx.Buffer | pygfx.Texture: """managed buffer""" return self._buffer - @property - def shared(self) -> int: - """Number of graphics that share this buffer""" - return self._shared - @property def __array_interface__(self): raise BufferError( diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py index e203be68d..b2b99cc49 100644 --- a/fastplotlib/graphics/features/_common.py +++ b/fastplotlib/graphics/features/_common.py @@ -1,20 +1,20 @@ +from typing import Sequence + import numpy as np -import pygfx from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance class Name(GraphicFeature): - property_name = "name" event_info_spec = [ {"dict key": "value", "type": "str", "description": "user provided name"}, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "name"): """Graphic name""" self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -30,12 +30,11 @@ def set_value(self, graphic, value: str): self._value = value - event = GraphicFeatureEvent(type="name", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Offset(GraphicFeature): - property_name = "offset" event_info_spec = [ { "dict key": "value", @@ -44,7 +43,9 @@ class Offset(GraphicFeature): }, ] - def __init__(self, value: np.ndarray | list | tuple): + def __init__( + self, value: np.ndarray | Sequence[float], property_name: str = "offset" + ): """Offset position of the graphic, [x, y, z]""" self._validate(value) @@ -53,7 +54,7 @@ def __init__(self, value: np.ndarray | list | tuple): # set values self._value[:] = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if not len(value) == 3: @@ -64,7 +65,7 @@ def value(self) -> np.ndarray: return self._value @block_reentrance - def set_value(self, graphic, value: np.ndarray | list | tuple): + def set_value(self, graphic, value: np.ndarray | Sequence[float]): self._validate(value) value = np.asarray(value) @@ -76,12 +77,11 @@ def set_value(self, graphic, value: np.ndarray | list | tuple): # set value of existing feature value array self._value[:] = value - event = GraphicFeatureEvent(type="offset", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Rotation(GraphicFeature): - property_name = "offset" event_info_spec = [ { "dict key": "value", @@ -90,7 +90,9 @@ class Rotation(GraphicFeature): }, ] - def __init__(self, value: np.ndarray | list | tuple): + def __init__( + self, value: np.ndarray | Sequence[float], property_name: str = "rotation" + ): """Graphic rotation quaternion""" self._validate(value) @@ -98,7 +100,7 @@ def __init__(self, value: np.ndarray | list | tuple): self._value = np.zeros(4) self._value[:] = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if not len(value) == 4: @@ -111,7 +113,7 @@ def value(self) -> np.ndarray: return self._value @block_reentrance - def set_value(self, graphic, value: np.ndarray | list | tuple): + def set_value(self, graphic, value: np.ndarray | Sequence[float]): self._validate(value) value = np.asarray(value) @@ -124,21 +126,20 @@ def set_value(self, graphic, value: np.ndarray | list | tuple): # set value of existing feature value array self._value[:] = value - event = GraphicFeatureEvent(type="rotation", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Alpha(GraphicFeature): """The alpha value (i.e. opacity) of a graphic.""" - property_name = "alpha" event_info_spec = [ {"dict key": "value", "type": "float", "description": "new alpha value"}, ] - def __init__(self, value: float): + def __init__(self, value: float, property_name: str = "alpha"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> float: @@ -156,21 +157,20 @@ def set_value(self, graphic, value: float): self._value = value - event = GraphicFeatureEvent(type="alpha", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class AlphaMode(GraphicFeature): """The alpha-mode value of a graphic (i.e. how alpha is handled by the renderer).""" - property_name = "alpha_mode" event_info_spec = [ {"dict key": "value", "type": "str", "description": "new alpha mode"}, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "alpha_mode"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -188,21 +188,20 @@ def set_value(self, graphic, value: str): self._value = value - event = GraphicFeatureEvent(type="alpha_mode", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class Visible(GraphicFeature): """Access or change the visibility.""" - property_name = "visible" event_info_spec = [ {"dict key": "value", "type": "bool", "description": "new visibility bool"}, ] - def __init__(self, value: bool): + def __init__(self, value: bool, property_name: str = "visible"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> bool: @@ -213,7 +212,7 @@ def set_value(self, graphic, value: bool): graphic.world_object.visible = value self._value = value - event = GraphicFeatureEvent(type="visible", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -222,7 +221,6 @@ class Deleted(GraphicFeature): Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted """ - property_name = "deleted" event_info_spec = [ { "dict key": "value", @@ -231,9 +229,9 @@ class Deleted(GraphicFeature): }, ] - def __init__(self, value: bool): + def __init__(self, value: bool, property_name: str = "deleted"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> bool: @@ -242,5 +240,5 @@ def value(self) -> bool: @block_reentrance def set_value(self, graphic, value: bool): self._value = value - event = GraphicFeatureEvent(type="deleted", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index 559e62c69..648f79bc8 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -33,8 +33,8 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True): - super().__init__() + def __init__(self, data, isolated_buffer: bool = True, property_name: str = "data"): + super().__init__(property_name=property_name) data = self._fix_data(data) @@ -154,7 +154,9 @@ def __setitem__(self, key, value): for texture in self.buffer.ravel(): texture.update_range((0, 0, 0), texture.size) - event = GraphicFeatureEvent("data", info={"key": key, "value": value}) + event = GraphicFeatureEvent( + self._property_name, info={"key": key, "value": value} + ) self._call_event_handlers(event) def __len__(self): @@ -172,9 +174,9 @@ class ImageVmin(GraphicFeature): }, ] - def __init__(self, value: float): + def __init__(self, value: float, property_name: str = "vmin"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> float: @@ -186,7 +188,7 @@ def set_value(self, graphic, value: float): graphic._material.clim = (value, vmax) self._value = value - event = GraphicFeatureEvent(type="vmin", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -201,9 +203,9 @@ class ImageVmax(GraphicFeature): }, ] - def __init__(self, value: float): + def __init__(self, value: float, property_name: str = "vmax"): self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> float: @@ -215,7 +217,7 @@ def set_value(self, graphic, value: float): graphic._material.clim = (vmin, value) self._value = value - event = GraphicFeatureEvent(type="vmax", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -230,10 +232,10 @@ class ImageCmap(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "cmap"): self._value = value self.texture = get_cmap_texture(value) - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -246,7 +248,7 @@ def set_value(self, graphic, value: str): graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) self._value = value - event = GraphicFeatureEvent(type="cmap", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -261,10 +263,10 @@ class ImageInterpolation(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "interpolation"): self._validate(value) self._value = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if value not in ["nearest", "linear"]: @@ -296,10 +298,10 @@ class ImageCmapInterpolation(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "cmap_interpolation"): self._validate(value) self._value = value - super().__init__() + super().__init__(property_name=property_name) def _validate(self, value): if value not in ["nearest", "linear"]: @@ -320,5 +322,5 @@ def set_value(self, graphic, value: str): graphic._material.map.mag_filter = value self._value = value - event = GraphicFeatureEvent(type="cmap_interpolation", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_line.py b/fastplotlib/graphics/features/_line.py new file mode 100644 index 000000000..792cb7832 --- /dev/null +++ b/fastplotlib/graphics/features/_line.py @@ -0,0 +1,28 @@ +from ._base import ( + GraphicFeature, + GraphicFeatureEvent, + block_reentrance, +) + + +class Thickness(GraphicFeature): + event_info_spec = [ + {"dict key": "value", "type": "float", "description": "new thickness value"}, + ] + + def __init__(self, value: float, property_name: str = "thickness"): + self._value = value + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + value = float(value) + graphic.world_object.material.thickness = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_positions_graphics.py b/fastplotlib/graphics/features/_positions_graphics.py index 21202cdf3..ae57e77d7 100644 --- a/fastplotlib/graphics/features/_positions_graphics.py +++ b/fastplotlib/graphics/features/_positions_graphics.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Sequence import numpy as np import pygfx @@ -17,7 +17,6 @@ class VertexColors(BufferManager): - property_name = "colors" event_info_spec = [ { "dict key": "key", @@ -38,16 +37,17 @@ class VertexColors(BufferManager): def __init__( self, - colors: str | np.ndarray | tuple[float] | list[float] | list[str], + colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], n_colors: int, isolated_buffer: bool = True, + property_name: str = "colors", ): """ Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic` Parameters ---------- - colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float + colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str] specify colors as a single human-readable string, RGBA array, or an iterable of strings or RGBA arrays @@ -57,13 +57,15 @@ def __init__( """ data = parse_colors(colors, n_colors) - super().__init__(data=data, isolated_buffer=isolated_buffer) + super().__init__( + data=data, isolated_buffer=isolated_buffer, property_name=property_name + ) @block_reentrance def __setitem__( self, key: int | slice | np.ndarray[int | bool] | tuple[slice, ...], - user_value: str | np.ndarray | tuple[float] | list[float] | list[str], + user_value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str], ): user_key = key @@ -137,7 +139,7 @@ def __setitem__( "user_value": user_value, } - event = GraphicFeatureEvent("colors", info=event_info) + event = GraphicFeatureEvent(self._property_name, info=event_info) self._call_event_handlers(event) def __len__(self): @@ -145,63 +147,41 @@ def __len__(self): class UniformColor(GraphicFeature): - property_name = "colors" event_info_spec = [ { "dict key": "value", - "type": "np.ndarray [RGBA]", + "type": "str | pygfx.Color | np.ndarray | Sequence[float]", "description": "new color value", }, ] - def __init__(self, value: str | np.ndarray | tuple | list | pygfx.Color): + def __init__( + self, + value: str | pygfx.Color | np.ndarray | Sequence[float], + property_name: str = "colors", + ): """Manages uniform color for line or scatter material""" self._value = pygfx.Color(value) - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> pygfx.Color: return self._value @block_reentrance - def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color): + def set_value( + self, graphic, value: str | pygfx.Color | np.ndarray | Sequence[float] + ): value = pygfx.Color(value) graphic.world_object.material.color = value self._value = value - event = GraphicFeatureEvent(type="colors", info={"value": value}) - self._call_event_handlers(event) - - -class UniformSize(GraphicFeature): - property_name = "sizes" - event_info_spec = [ - {"dict key": "value", "type": "float", "description": "new size value"}, - ] - - def __init__(self, value: int | float): - """Manages uniform size for scatter material""" - - self._value = float(value) - super().__init__() - - @property - def value(self) -> float: - return self._value - - @block_reentrance - def set_value(self, graphic, value: float | int): - value = float(value) - graphic.world_object.material.size = value - self._value = value - - event = GraphicFeatureEvent(type="sizes", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class SizeSpace(GraphicFeature): - property_name = "size_space" event_info_spec = [ { "dict key": "value", @@ -210,11 +190,11 @@ class SizeSpace(GraphicFeature): }, ] - def __init__(self, value: str): + def __init__(self, value: str, property_name: str = "size_space"): """Manages the coordinate space for scatter/line graphic""" self._value = value - super().__init__() + super().__init__(property_name=property_name) @property def value(self) -> str: @@ -233,12 +213,11 @@ def set_value(self, graphic, value: str): graphic.world_object.material.size_space = value self._value = value - event = GraphicFeatureEvent(type="size_space", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) class VertexPositions(BufferManager): - property_name = "data" event_info_spec = [ { "dict key": "key", @@ -252,14 +231,18 @@ class VertexPositions(BufferManager): }, ] - def __init__(self, data: Any, isolated_buffer: bool = True): + def __init__( + self, data: Any, isolated_buffer: bool = True, property_name: str = "data" + ): """ Manages the vertex positions buffer shown in the graphic. Supports fancy indexing if the data array also supports it. """ data = self._fix_data(data) - super().__init__(data, isolated_buffer=isolated_buffer) + super().__init__( + data, isolated_buffer=isolated_buffer, property_name=property_name + ) def _fix_data(self, data): # data = to_gpu_supported_dtype(data) @@ -293,115 +276,13 @@ def __setitem__( # determine offset and size for GPU upload self._update_range(key) - self._emit_event("data", key, value) - - def __len__(self): - return len(self.buffer.data) - - -class PointsSizesFeature(BufferManager): - property_name = "sizes" - event_info_spec = [ - { - "dict key": "key", - "type": "slice, index (int) or numpy-like fancy index", - "description": "key at which point sizes were indexed/sliced", - }, - { - "dict key": "value", - "type": "int | float | array-like", - "description": "new size values for points that were changed", - }, - ] - - def __init__( - self, - sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], - n_datapoints: int, - isolated_buffer: bool = True, - ): - """ - Manages sizes buffer of scatter points. - """ - sizes = self._fix_sizes(sizes, n_datapoints) - super().__init__(data=sizes, isolated_buffer=isolated_buffer) - - def _fix_sizes( - self, - sizes: int | float | np.ndarray | list[int | float] | tuple[int | float], - n_datapoints: int, - ): - if np.issubdtype(type(sizes), np.number): - # single value given - sizes = np.full( - n_datapoints, sizes, dtype=np.float32 - ) # force it into a float to avoid weird gpu errors - - elif isinstance( - sizes, (np.ndarray, tuple, list) - ): # if it's not a ndarray already, make it one - sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 - if (sizes.ndim != 1) or (sizes.size != n_datapoints): - raise ValueError( - f"sequence of `sizes` must be 1 dimensional with " - f"the same length as the number of datapoints" - ) - - else: - raise TypeError( - "sizes must be a single , , or a sequence (array, list, tuple) of int" - "or float with the length equal to the number of datapoints" - ) - - if np.count_nonzero(sizes < 0) > 1: - raise ValueError( - "All sizes must be positive numbers greater than or equal to 0.0." - ) - - return sizes - - @block_reentrance - def __setitem__( - self, - key: int | slice | np.ndarray[int | bool] | list[int | bool], - value: int | float | np.ndarray | list[int | float] | tuple[int | float], - ): - # this is a very simple 1D buffer, no parsing required, directly set buffer - self.buffer.data[key] = value - self._update_range(key) - - self._emit_event("sizes", key, value) + self._emit_event(self._property_name, key, value) def __len__(self): return len(self.buffer.data) -class Thickness(GraphicFeature): - property_name = "thickness" - event_info_spec = [ - {"dict key": "value", "type": "float", "description": "new thickness value"}, - ] - - def __init__(self, value: float): - self._value = value - super().__init__() - - @property - def value(self) -> float: - return self._value - - @block_reentrance - def set_value(self, graphic, value: float): - value = float(value) - graphic.world_object.material.thickness = value - self._value = value - - event = GraphicFeatureEvent(type="thickness", info={"value": value}) - self._call_event_handlers(event) - - class VertexCmap(BufferManager): - property_name = "cmap" event_info_spec = [ { "dict key": "key", @@ -420,13 +301,14 @@ def __init__( vertex_colors: VertexColors, cmap_name: str | None, transform: np.ndarray | None, + property_name: str = "colors", ): """ Sliceable colormap feature, manages a VertexColors instance and provides a way to set colormaps with arbitrary transforms """ - super().__init__(data=vertex_colors.buffer) + super().__init__(data=vertex_colors.buffer, property_name=property_name) self._vertex_colors = vertex_colors self._cmap_name = cmap_name @@ -478,7 +360,7 @@ def __setitem__(self, key: slice, cmap_name): # TODO: should we block vertex_colors from emitting an event? # Because currently this will result in 2 emitted events, one # for cmap and another from the colors - self._emit_event("cmap", key, cmap_name) + self._emit_event(self._property_name, key, cmap_name) @property def name(self) -> str: diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py new file mode 100644 index 000000000..16671ef89 --- /dev/null +++ b/fastplotlib/graphics/features/_scatter.py @@ -0,0 +1,574 @@ +from typing import Sequence + +import numpy as np +import pygfx + +from ._base import ( + GraphicFeature, + BufferManager, + GraphicFeatureEvent, + block_reentrance, +) + + +marker_names = { + # MPL + "o": "circle", + "s": "square", + "D": "diamond", + "+": "plus", + "x": "cross", + "^": "triangle_up", + "<": "triangle_left", + ">": "triangle_right", + "v": "triangle_down", + "*": "asterisk6", + # Unicode + "●": "circle", + "○": "ring", + "■": "square", + "♦": "diamond", + "♥": "heart", + "♠": "spade", + "♣": "club", + "✳": "asterisk6", + "▲": "triangle_up", + "▼": "triangle_down", + "◀": "triangle_left", + "▶": "triangle_right", + # Emojis (these may look like their plaintext variants in your editor) + "❤️": "heart", + "♠️": "spade", + "♣️": "club", + "♦️": "diamond", + "💎": "diamond", + "💍": "ring", + "✳️": "asterisk6", + "📍": "pin", +} + + +def user_input_to_marker(name): + resolved_name = marker_names.get(name, name).lower() + if resolved_name not in pygfx.MarkerShape: + raise ValueError( + f"markers must be a string in: {list(pygfx.MarkerShape) + list(marker_names.keys())}, not {name!r}" + ) + + return resolved_name + + +def validate_user_markers_array(markers): + # make sure all markers are valid + # need to validate before converting to ints because + # we can't use control flow in the vectorized function + unique_values = np.unique(markers) + for m in unique_values: + user_input_to_marker(m) + + +# fast vectorized function to convert array of user markers to the standardized strings +# TODO: can probably use search-sorted for this too +vectorized_user_markers_to_std_markers = np.vectorize(marker_names.get, otypes=[" array of int +# see: https://github.com/pygfx/pygfx/issues/1215 +# Prepare for searchsorted +def init_searchsorted(markers_mapping): + keys = np.array(list(markers_mapping.keys())) + vals = np.array(list(markers_mapping.values())) + + order = np.argsort(keys) + keys = keys[order] + vals = vals[order] + + return keys, vals + + +marker_int_searchsorted_keys, marker_int_searchsorted_vals = init_searchsorted( + marker_int_mapping +) + + +def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]): + # Vectorized lookup + indices = np.searchsorted(marker_int_searchsorted_keys, markers_str_array) + return marker_int_searchsorted_vals[indices] + + +class VertexMarkers(BufferManager): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which markers were indexed/sliced", + }, + { + "dict key": "value", + "type": "str | np.ndarray[str]", + "description": "new marker values for points that were changed", + }, + ] + + def __init__( + self, + markers: str | Sequence[str] | np.ndarray, + n_datapoints: int, + property_name: str = "markers", + ): + """ + Manages the markers buffer for the scatter points. Supports fancy indexing. + """ + + # first validate then allocate buffers + + if isinstance(markers, str): + markers = user_input_to_marker(markers) + + elif isinstance(markers, (tuple, list, np.ndarray)): + validate_user_markers_array(markers) + + # allocate buffers + markers_int_array = np.zeros(n_datapoints, dtype=np.int32) + + marker_str_length = max(map(len, list(pygfx.MarkerShape))) + + self._markers_readable_array = np.empty( + n_datapoints, dtype=f" np.ndarray[str]: + """numpy array of per-vertex marker shapes in human-readable form""" + return self._markers_readable_array + + @property + def value_int(self) -> np.ndarray[np.int32]: + """numpy array of the actual int32 buffer that represents per-vertex marker shapes on the GPU""" + return self.buffer.data + + def _set_markers_arrays(self, key, value, n_markers): + if isinstance(value, str): + # set markers at these indices to this value + m = user_input_to_marker(value) + self._markers_readable_array[key] = m + self.value_int[key] = marker_int_mapping[m] + + elif isinstance(value, (np.ndarray, list, tuple)): + if n_markers != len(value): + raise IndexError( + f"Must provide one marker value, or an array/list/tuple of marker values with the same length " + f"as the slice. You have provided the slice: {key}, which refers to {n_markers} markers, but " + f"provided {len(value)} new marker values. You must provide 1 or {n_markers} values." + ) + + validate_user_markers_array(value) + + new_markers_human_readable = vectorized_user_markers_to_std_markers(value) + new_markers_int = searchsorted_markers_to_int_array( + new_markers_human_readable + ) + + self._markers_readable_array[key] = new_markers_human_readable + self.value_int[key] = new_markers_int + else: + raise TypeError( + "new markers value must be a str, Sequence or np.ndarray of new marker values" + ) + + @block_reentrance + def __setitem__( + self, + key: int | slice | list[int | bool] | np.ndarray[int | bool], + value: str | Sequence[str] | np.ndarray[str], + ): + if isinstance(key, int): + if key >= self.value.size: + raise IndexError(f"index : {key} out of bounds: {self.value.size}") + + if not isinstance(value, str): + # only a single marker should be provided if changing one at one index + raise TypeError( + f"you must provide a marker value if providing a single index, " + f"you have passed index: {key} and value: {value}" + ) + + m = user_input_to_marker(value) + self._markers_readable_array[key] = m + self.value_int[key] = marker_int_mapping[m] + + elif isinstance(key, slice): + # find the number of new markers by converting slice to range and then parse markers + start, stop, step = key.indices(self.value.size) + + n_markers = len(range(start, stop, step)) + self._set_markers_arrays(key, value, n_markers) + + elif isinstance(key, (list, np.ndarray)): + key = np.asarray(key) # convert to array if list + + if key.dtype == bool: + # make sure len is same + if not key.size == self.buffer.data.shape[0]: + raise IndexError( + f"Length of array for fancy indexing must match number of datapoints.\n" + f"There are {len(self.buffer.data.shape[0])} datapoints and you have passed " + f"a bool array of size: {key.size}" + ) + + n_markers = np.count_nonzero(key) + self._set_markers_arrays(key, value, n_markers) + + # if it's an array of int + elif np.issubdtype(key.dtype, np.integer): + if key.size > self.buffer.data.shape[0]: + raise IndexError( + f"Length of array for fancy indexing must be <= n_datapoints. " + f"There are: {self.buffer.data.shape[0]} datapoints, you have passed an " + f"integer array for fancy indexing of size: {key.size}" + ) + n_markers = key.size + self._set_markers_arrays(key, value, n_markers) + + else: + # fancy indexing doesn't make sense with non-integer types + raise TypeError( + f"can only using integer or booleans arrays for fancy indexing, your array is of type: {key.dtype}" + ) + + else: + raise TypeError( + f"Can only set markers by slicing/indexing using the one of the following types: " + f"int | slice | list[int | bool] | np.ndarray[int | bool], you have passed" + f"sliced using the following type: {type(key)}" + ) + + # _update_range handles parsing the key to + # determine offset and size for GPU upload + self._update_range(key) + + self._emit_event(self._property_name, key, value) + + def __len__(self): + return len(self.buffer.data) + + +class UniformMarker(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | None", + "description": "new marker value", + }, + ] + + def __init__(self, marker: str, property_name: str = "markers"): + """Manages evented uniform buffer for scatter marker""" + + self._value = user_input_to_marker(marker) + super().__init__(property_name=property_name) + + @property + def value(self) -> str: + return self._value + + @block_reentrance + def set_value(self, graphic, value: str): + value = user_input_to_marker(value) + graphic.world_object.material.marker = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class UniformEdgeColor(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | np.ndarray | pygfx.Color | Sequence[float]", + "description": "new edge_color", + }, + ] + + def __init__( + self, + edge_color: str | np.ndarray | pygfx.Color | Sequence[float], + property_name: str = "edge_colors", + ): + """Manages evented uniform buffer for scatter marker edge_color""" + + self._value = pygfx.Color(edge_color) + super().__init__(property_name=property_name) + + @property + def value(self) -> pygfx.Color: + return self._value + + @block_reentrance + def set_value( + self, graphic, value: str | np.ndarray | pygfx.Color | Sequence[float] + ): + graphic.world_object.material.edge_color = pygfx.Color(value) + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class EdgeWidth(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new edge_width", + }, + ] + + def __init__(self, edge_width: float, property_name: str = "edge_width"): + """Manages evented uniform buffer for scatter marker edge_width""" + + self._value = edge_width + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic.world_object.material.edge_width = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class UniformRotations(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new edge_width", + }, + ] + + def __init__(self, edge_width: float, property_name: str = "point_rotations"): + """Manages evented uniform buffer for scatter marker rotation""" + + self._value = edge_width + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic.world_object.material.rotations = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +class VertexRotations(BufferManager): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which point rotations were indexed/sliced", + }, + { + "dict key": "value", + "type": "int | float | array-like", + "description": "new rotation values for points that were changed", + }, + ] + + def __init__( + self, + rotations: int | float | np.ndarray | Sequence[int | float], + n_datapoints: int, + isolated_buffer: bool = True, + property_name: str = "point_rotations", + ): + """ + Manages rotations buffer of scatter points. + """ + sizes = self._fix_sizes(rotations, n_datapoints) + super().__init__( + data=sizes, isolated_buffer=isolated_buffer, property_name=property_name + ) + + def _fix_sizes( + self, + sizes: int | float | np.ndarray | Sequence[int | float], + n_datapoints: int, + ): + if np.issubdtype(type(sizes), np.number): + # single value given + sizes = np.full( + n_datapoints, sizes, dtype=np.float32 + ) # force it into a float to avoid weird gpu errors + + elif isinstance( + sizes, (np.ndarray, tuple, list) + ): # if it's not a ndarray already, make it one + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): + raise ValueError( + f"sequence of `rotations` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError( + "`rotations` must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints" + ) + + return sizes + + @block_reentrance + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | list[int | bool], + value: int | float | np.ndarray | Sequence[int | float], + ): + # this is a very simple 1D buffer, no parsing required, directly set buffer + self.buffer.data[key] = value + self._update_range(key) + + self._emit_event(self._property_name, key, value) + + def __len__(self): + return len(self.buffer.data) + + +class VertexPointSizes(BufferManager): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which point sizes were indexed/sliced", + }, + { + "dict key": "value", + "type": "int | float | array-like", + "description": "new size values for points that were changed", + }, + ] + + def __init__( + self, + sizes: int | float | np.ndarray | Sequence[int | float], + n_datapoints: int, + isolated_buffer: bool = True, + property_name: str = "sizes", + ): + """ + Manages sizes buffer of scatter points. + """ + sizes = self._fix_sizes(sizes, n_datapoints) + super().__init__( + data=sizes, isolated_buffer=isolated_buffer, property_name=property_name + ) + + def _fix_sizes( + self, + sizes: int | float | np.ndarray | Sequence[int | float], + n_datapoints: int, + ): + if np.issubdtype(type(sizes), np.number): + # single value given + sizes = np.full( + n_datapoints, sizes, dtype=np.float32 + ) # force it into a float to avoid weird gpu errors + + elif isinstance( + sizes, (np.ndarray, tuple, list) + ): # if it's not a ndarray already, make it one + sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32 + if (sizes.ndim != 1) or (sizes.size != n_datapoints): + raise ValueError( + f"sequence of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints" + ) + + else: + raise TypeError( + "sizes must be a single , , or a sequence (array, list, tuple) of int" + "or float with the length equal to the number of datapoints" + ) + + if np.count_nonzero(sizes < 0) > 1: + raise ValueError( + "All sizes must be positive numbers greater than or equal to 0.0." + ) + + return sizes + + @block_reentrance + def __setitem__( + self, + key: int | slice | np.ndarray[int | bool] | list[int | bool], + value: int | float | np.ndarray | Sequence[int | float], + ): + # this is a very simple 1D buffer, no parsing required, directly set buffer + self.buffer.data[key] = value + self._update_range(key) + + self._emit_event(self._property_name, key, value) + + def __len__(self): + return len(self.buffer.data) + + +class UniformSize(GraphicFeature): + event_info_spec = [ + {"dict key": "value", "type": "float", "description": "new size value"}, + ] + + def __init__(self, value: int | float, property_name: str = "sizes"): + """Manages uniform size for scatter material""" + + self._value = float(value) + super().__init__(property_name=property_name) + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float | int): + value = float(value) + graphic.world_object.material.size = value + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index ed18c8287..654b3d4c6 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -40,7 +40,7 @@ def __init__(self, axis: str, value: float, limits: tuple[float, float]): """ - super().__init__() + super().__init__(property_name="selection") self._axis = axis self._limits = limits @@ -70,7 +70,7 @@ def set_value(self, selector, value: float): self._value = value - event = GraphicFeatureEvent("selection", {"value": value}) + event = GraphicFeatureEvent(self._property_name, {"value": value}) event.get_selected_index = selector.get_selected_index self._call_event_handlers(event) @@ -99,7 +99,7 @@ class LinearRegionSelectionFeature(GraphicFeature): ] def __init__(self, value: tuple[int, int], axis: str, limits: tuple[float, float]): - super().__init__() + super().__init__(property_name="selection") self._axis = axis self._limits = limits @@ -182,7 +182,7 @@ def set_value(self, selector, value: Sequence[float]): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent("selection", {"value": self.value}) + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data @@ -220,7 +220,7 @@ def __init__( value: tuple[float, float, float, float], limits: tuple[float, float, float, float], ): - super().__init__() + super().__init__(property_name="selection") self._limits = limits self._value = tuple(int(v) for v in value) @@ -335,7 +335,7 @@ def set_value(self, selector, value: Sequence[float]): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent("selection", {"value": self.value}) + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data @@ -371,7 +371,7 @@ def __init__( value: Sequence[tuple[float]], limits: tuple[float, float, float, float], ): - super().__init__() + super().__init__(property_name="selection") self._limits = limits self._value = np.asarray(value).reshape(-1, 3).astype(float) @@ -438,7 +438,7 @@ def set_value(self, selector, value: Sequence[tuple[float]]): if len(self._event_handlers) < 1: return - event = GraphicFeatureEvent("selection", {"value": self.value}) + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) event.get_selected_indices = selector.get_selected_indices event.get_selected_data = selector.get_selected_data diff --git a/fastplotlib/graphics/features/_text.py b/fastplotlib/graphics/features/_text.py index d8e5e95e8..ed0485d3a 100644 --- a/fastplotlib/graphics/features/_text.py +++ b/fastplotlib/graphics/features/_text.py @@ -16,7 +16,7 @@ class TextData(GraphicFeature): def __init__(self, value: str): self._value = value - super().__init__() + super().__init__(property_name="text") @property def value(self) -> str: @@ -27,7 +27,7 @@ def set_value(self, graphic, value: str): graphic.world_object.set_text(value) self._value = value - event = GraphicFeatureEvent(type="text", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -42,7 +42,7 @@ class FontSize(GraphicFeature): def __init__(self, value: float | int): self._value = value - super().__init__() + super().__init__(property_name="font_size") @property def value(self) -> float | int: @@ -53,7 +53,7 @@ def set_value(self, graphic, value: float | int): graphic.world_object.font_size = value self._value = graphic.world_object.font_size - event = GraphicFeatureEvent(type="font_size", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -68,7 +68,7 @@ class TextFaceColor(GraphicFeature): def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): self._value = pygfx.Color(value) - super().__init__() + super().__init__(property_name="face_color") @property def value(self) -> pygfx.Color: @@ -80,7 +80,7 @@ def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float graphic.world_object.material.color = value self._value = graphic.world_object.material.color - event = GraphicFeatureEvent(type="face_color", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -95,7 +95,7 @@ class TextOutlineColor(GraphicFeature): def __init__(self, value: str | np.ndarray | list[float] | tuple[float]): self._value = pygfx.Color(value) - super().__init__() + super().__init__(property_name="outline_color") @property def value(self) -> pygfx.Color: @@ -107,7 +107,7 @@ def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float graphic.world_object.material.outline_color = value self._value = graphic.world_object.material.outline_color - event = GraphicFeatureEvent(type="outline_color", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -122,7 +122,7 @@ class TextOutlineThickness(GraphicFeature): def __init__(self, value: float): self._value = value - super().__init__() + super().__init__(property_name="outline_thickness") @property def value(self) -> float: @@ -133,5 +133,5 @@ def set_value(self, graphic, value: float): graphic.world_object.material.outline_thickness = value self._value = graphic.world_object.material.outline_thickness - event = GraphicFeatureEvent(type="outline_thickness", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py index fd3c8e745..ec4c4052a 100644 --- a/fastplotlib/graphics/features/_volume.py +++ b/fastplotlib/graphics/features/_volume.py @@ -35,7 +35,7 @@ class TextureArrayVolume(GraphicFeature): ] def __init__(self, data, isolated_buffer: bool = True): - super().__init__() + super().__init__(property_name="data") data = self._fix_data(data) @@ -192,7 +192,9 @@ def __setitem__(self, key, value): for texture in self.buffer.ravel(): texture.update_range((0, 0, 0), texture.size) - event = GraphicFeatureEvent("data", info={"key": key, "value": value}) + event = GraphicFeatureEvent( + self._property_name, info={"key": key, "value": value} + ) self._call_event_handlers(event) def __len__(self): @@ -242,7 +244,7 @@ class VolumeRenderMode(GraphicFeature): def __init__(self, value: str): self._validate(value) self._value = value - super().__init__() + super().__init__(property_name="mode") @property def value(self) -> str: @@ -271,7 +273,7 @@ def set_value(self, graphic, value: str): graphic._material = new_material self._value = value - event = GraphicFeatureEvent(type="mode", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -288,7 +290,7 @@ class VolumeIsoThreshold(GraphicFeature): def __init__(self, value: float): self._value = value - super().__init__() + super().__init__(property_name="threshold") @property def value(self) -> float: @@ -299,7 +301,7 @@ def set_value(self, graphic, value: float): graphic._material.threshold = value self._value = graphic._material.threshold - event = GraphicFeatureEvent(type="threshold", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -316,7 +318,7 @@ class VolumeIsoStepSize(GraphicFeature): def __init__(self, value: float): self._value = value - super().__init__() + super().__init__(property_name="step_size") @property def value(self) -> float: @@ -327,7 +329,7 @@ def set_value(self, graphic, value: float): graphic._material.step_size = value self._value = graphic._material.step_size - event = GraphicFeatureEvent(type="step_size", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -344,7 +346,7 @@ class VolumeIsoSubStepSize(GraphicFeature): def __init__(self, value: float): self._value = value - super().__init__() + super().__init__(property_name="substep_size") @property def value(self) -> float: @@ -355,7 +357,7 @@ def set_value(self, graphic, value: float): graphic._material.substep_size = value self._value = graphic._material.substep_size - event = GraphicFeatureEvent(type="step_size", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -372,7 +374,7 @@ class VolumeIsoEmissive(GraphicFeature): def __init__(self, value: pygfx.Color | str | tuple | np.ndarray): self._value = pygfx.Color(value) - super().__init__() + super().__init__(property_name="emissive") @property def value(self) -> pygfx.Color: @@ -383,7 +385,7 @@ def set_value(self, graphic, value: pygfx.Color | str | tuple | np.ndarray): graphic._material.emissive = value self._value = graphic._material.emissive - event = GraphicFeatureEvent(type="emissive", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -400,7 +402,7 @@ class VolumeIsoShininess(GraphicFeature): def __init__(self, value: int): self._value = value - super().__init__() + super().__init__(property_name="shininess") @property def value(self) -> int: @@ -411,7 +413,7 @@ def set_value(self, graphic, value: float): graphic._material.shininess = value self._value = graphic._material.shininess - event = GraphicFeatureEvent(type="shininess", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) @@ -428,7 +430,7 @@ class VolumeSlicePlane(GraphicFeature): def __init__(self, value: tuple[float, float, float, float]): self._value = value - super().__init__() + super().__init__(property_name="plane") @property def value(self) -> tuple[float, float, float, float]: @@ -439,5 +441,5 @@ def set_value(self, graphic, value: tuple[float, float, float, float]): graphic._material.plane = value self._value = graphic._material.plane - event = GraphicFeatureEvent(type="plane", info={"value": value}) + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event) diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 6bff05fa5..4df4f7557 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -5,36 +5,58 @@ from ._positions_base import PositionsGraphic from .features import ( - PointsSizesFeature, + VertexPointSizes, UniformSize, SizeSpace, VertexPositions, VertexColors, UniformColor, VertexCmap, + VertexMarkers, + UniformMarker, + UniformEdgeColor, + EdgeWidth, + UniformRotations, + VertexRotations, + TextureArray, ) class ScatterGraphic(PositionsGraphic): _features = { "data": VertexPositions, - "sizes": (PointsSizesFeature, UniformSize), + "sizes": (VertexPointSizes, UniformSize), "colors": (VertexColors, UniformColor), "cmap": (VertexCmap, None), + "markers": (VertexMarkers, UniformMarker, None), + "edge_colors": (UniformEdgeColor, VertexColors, None), + "edge_width": (EdgeWidth, None), + "image": (TextureArray, None), "size_space": SizeSpace, + "point_rotations": (UniformRotations, VertexRotations, None), } def __init__( self, data: Any, - colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w", + colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w", uniform_color: bool = False, cmap: str = None, cmap_transform: np.ndarray = None, - isolated_buffer: bool = True, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: str | np.ndarray | Sequence[str] = "o", + uniform_marker: bool = False, + custom_sdf: str = None, + edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black", + uniform_edge_color: bool = True, + edge_width: float = 1.0, + image: np.ndarray = None, + point_rotations: float | np.ndarray = 0, + point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: float | np.ndarray | Sequence[float] = 1, uniform_size: bool = False, size_space: str = "screen", + isolated_buffer: bool = True, **kwargs, ): """ @@ -62,9 +84,66 @@ def __init__( cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap - isolated_buffer: bool, default True - whether the buffers should be isolated from the user input array. - Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: None | str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers" + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>*". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, the WGSL + code defined by ``custom_sdf`` will be used. + + uniform_marker: bool, default False + Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use + the same marker for all points and want to save GPU RAM. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" + edge color of the markers, used when `mode` is "markers" + + uniform_edge_color: bool, default True + Set the same edge color for all markers. Useful for saving GPU RAM. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: ArrayLike, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float | ArrayLike = 0, + The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all + points, or an array of rotation values can be used to set per-point rotations + + point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" + * uniform: set the same rotation for every point, useful to save GPU RAM + * vertex: set per-vertex rotations + * curve: The rotation follows the curve of the line defined by the points (in screen space) sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points @@ -74,7 +153,11 @@ def __init__( save GPU VRAM when all points have the same size. size_space: str, default "screen" - coordinate space in which the size is expressed ("screen", "world", "model") + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + isolated_buffer: bool, default True + whether the buffers should be isolated from the user input array. + Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. kwargs passed to :class:`.Graphic` @@ -94,40 +177,260 @@ def __init__( n_datapoints = self.data.value.shape[0] + geo_kwargs = {"positions": self._data.buffer} + aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend") - geo_kwargs = {"positions": self._data.buffer} - material_kwargs = dict(pick_write=True, aa=aa, depth_compare="<=") + material_kwargs = dict( + pick_write=True, + aa=aa, + depth_compare="<=", + ) + + self._markers: VertexMarkers | UniformMarker | None = None + self._edge_colors: UniformEdgeColor | VertexColors | None = None + self._edge_width: EdgeWidth | None = None + self._point_rotations: VertexRotations | UniformRotations | None = None + self._image: TextureArray | None = None + + # material cannot be changed after the ScatterGraphic is created + self._mode = mode + match self.mode: + case "markers": + # default + material = pygfx.PointsMarkerMaterial + + if uniform_marker: + if not isinstance(markers, str): + raise TypeError( + "must pass a single marker if uniform_marker is True" + ) + + self._markers = UniformMarker(markers) + + material_kwargs["marker_mode"] = pygfx.MarkerMode.uniform + material_kwargs["marker"] = self._markers.value + else: + material_kwargs["marker_mode"] = pygfx.MarkerMode.vertex + + self._markers = VertexMarkers(markers, n_datapoints) + + geo_kwargs["markers"] = self._markers.buffer + + if edge_colors is None: + # interpret as no edge color + edge_colors = (0, 0, 0, 0) + + if uniform_edge_color: + if not isinstance(edge_colors, (str, pygfx.Color)): + if len(edge_colors) not in [3, 4]: + raise TypeError( + f"if `uniform_edge_color` is True, then `edge_color` must be a str, pygfx.Color, " + f"or an RGB(A) tuple, list, array representation of a single color. You have passed: " + f"{edge_colors}" + ) + + self._edge_colors = UniformEdgeColor(edge_colors) + material_kwargs["edge_color"] = self._edge_colors.value + material_kwargs["edge_color_mode"] = pygfx.ColorMode.uniform + else: + self._edge_colors = VertexColors( + edge_colors, n_datapoints, property_name="edge_colors" + ) + material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex + geo_kwargs["edge_colors"] = self._edge_colors.buffer + + self._edge_width = EdgeWidth(edge_width) + material_kwargs["edge_width"] = self._edge_width.value + material_kwargs["custom_sdf"] = custom_sdf + + case "simple": + # basic points material + material = pygfx.PointsMaterial + + case "gaussian": + material = pygfx.PointsGaussianBlobMaterial + + case "image": + material = pygfx.PointsSpriteMaterial + # sprites should actually only be one texture, but we don't + # want to create a new buffer manager just for sprites. + # If someone is creating scatter plots with images of size + # larger than the typical limit of 16384, I'm very curious + # to know what they're trying to visualize + shared = pygfx.renderers.wgpu.get_shared() + limit = shared.device.limits["max-texture-dimension-2d"] + if any([dim > limit for dim in image.shape]): + raise BufferError( + f"Scatter point image dimension is greater than the device texture limit." + f"Your device limit is: {limit} but your image shape is: {image.shape}" + ) + + # create texture array with normalized image + self._image = TextureArray( + image / np.nanmax(image), property_name="image" + ) + + material_kwargs["sprite"] = self._image.buffer[0, 0] + self._size_space = SizeSpace(size_space) if uniform_color: - material_kwargs["color_mode"] = "uniform" + material_kwargs["color_mode"] = pygfx.ColorMode.uniform material_kwargs["color"] = self.colors else: - material_kwargs["color_mode"] = "vertex" + material_kwargs["color_mode"] = pygfx.ColorMode.vertex geo_kwargs["colors"] = self.colors.buffer if uniform_size: - material_kwargs["size_mode"] = "uniform" + material_kwargs["size_mode"] = pygfx.SizeMode.uniform self._sizes = UniformSize(sizes) material_kwargs["size"] = self.sizes else: - material_kwargs["size_mode"] = "vertex" - self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints) + material_kwargs["size_mode"] = pygfx.SizeMode.vertex + self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints) geo_kwargs["sizes"] = self.sizes.buffer + match point_rotation_mode: + case pygfx.enums.RotationMode.vertex: + self._point_rotations = VertexRotations( + point_rotations, n_datapoints=n_datapoints + ) + geo_kwargs["rotations"] = self._point_rotations.buffer + + case pygfx.enums.RotationMode.uniform: + self._point_rotations = UniformRotations(point_rotations) + + case pygfx.enums.RotationMode.curve: + pass # nothing special for curve rotation mode + + case _: + raise ValueError( + f"`point_rotation_mode` must be one of: {pygfx.enums.RotationMode}, " + f"you have passed: {point_rotation_mode}" + ) + + material_kwargs["rotation_mode"] = point_rotation_mode material_kwargs["size_space"] = self.size_space + world_object = pygfx.Points( pygfx.Geometry(**geo_kwargs), - material=pygfx.PointsMaterial(**material_kwargs), + material=material(**material_kwargs), ) self._set_world_object(world_object) @property - def sizes(self) -> PointsSizesFeature | float: + def mode(self) -> str: + """scatter point display mode""" + return self._mode + + @property + def markers(self) -> str | VertexMarkers | None: + """markers if mode is 'marker'""" + if isinstance(self._markers, VertexMarkers): + return self._markers + elif isinstance(self._markers, UniformMarker): + return self._markers.value + + @markers.setter + def markers(self, value: str | np.ndarray[str] | Sequence[str]): + if self.mode != "markers": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers" + ) + if isinstance(self._markers, VertexMarkers): + self._markers[:] = value + elif isinstance(self._markers, UniformMarker): + self._markers.set_value(self, value) + + @property + def edge_colors(self) -> str | pygfx.Color | VertexColors | None: + """edge_colors if mode is 'marker'""" + + if isinstance(self._edge_colors, VertexColors): + return self._edge_colors + + elif isinstance(self._edge_colors, UniformEdgeColor): + return self._edge_colors.value + + @edge_colors.setter + def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]): + if self.mode != "markers": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors" + ) + + if isinstance(self._edge_colors, VertexColors): + self._edge_colors[:] = value + + elif isinstance(self._edge_colors, UniformEdgeColor): + self._edge_colors.set_value(self, value) + + @property + def edge_width(self) -> float | None: + """Get or set the edge_width if mode is 'markers'""" + if self._edge_width is None: + return None + + return self._edge_width.value + + @edge_width.setter + def edge_width(self, value: float): + if self.mode != "markers": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_width" + ) + + self._edge_width.set_value(self, value) + + @property + def point_rotation_mode(self) -> str: + """point rotation mode, read-only, one of 'uniform', 'vertex', or 'curve'""" + return self.world_object.material.rotation_mode + + @property + def point_rotations(self) -> VertexRotations | float | None: + """rotation of each point, in radians, if `point_rotation_mode` is 'uniform' or 'vertex'""" + + if isinstance(self._point_rotations, VertexRotations): + return self._point_rotations + + elif isinstance(self._point_rotations, UniformRotations): + return self._point_rotations.value + + @point_rotations.setter + def point_rotations(self, value: float | np.ndarray[float]): + if self.point_rotation_mode not in ["uniform", "vertex"]: + raise AttributeError( + f"point_rotation_mode is: {self.point_rotation_mode}. " + f"it be 'uniform' or 'vertex' to set the `point_rotations`" + ) + + if isinstance(self._point_rotations, VertexRotations): + self._point_rotations[:] = value + + elif isinstance(self._point_rotations, UniformRotations): + self._point_rotations.set_value(self, value) + + @property + def image(self) -> TextureArray | None: + """Get or set the image data, returns None if scatter plot mode is not 'image'""" + return self._image + + @image.setter + def image(self, data): + if self.mode != "image": + raise AttributeError( + f"scatter plot is: {self.mode}. The mode must be 'image' to set the image" + ) + + self._image[:] = data + + @property + def sizes(self) -> VertexPointSizes | float: """Get or set the scatter point size(s)""" - if isinstance(self._sizes, PointsSizesFeature): + if isinstance(self._sizes, VertexPointSizes): return self._sizes elif isinstance(self._sizes, UniformSize): @@ -135,7 +438,7 @@ def sizes(self) -> PointsSizesFeature | float: @sizes.setter def sizes(self, value): - if isinstance(self._sizes, PointsSizesFeature): + if isinstance(self._sizes, VertexPointSizes): self._sizes[:] = value elif isinstance(self._sizes, UniformSize): diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 96c76f9a8..62f824227 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -4,6 +4,8 @@ import numpy +import pygfx + from ..graphics import * from ..graphics._base import Graphic @@ -103,6 +105,7 @@ def add_image_volume( ) -> ImageVolumeGraphic: """ + Create an ImageVolumeGraphic. Parameters ---------- @@ -110,7 +113,7 @@ def add_image_volume( array-like, usually numpy.ndarray, must support ``memoryview()``. Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) - mode: str, default "ray" + mode: str, default "mip" render mode, one of "mip", "minip", "iso" or "slice" vmin: float @@ -432,14 +435,26 @@ def add_line_stack( def add_scatter( self, data: Any, - colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w", + colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w", uniform_color: bool = False, cmap: str = None, cmap_transform: numpy.ndarray = None, - isolated_buffer: bool = True, + mode: Literal["markers", "simple", "gaussian", "image"] = "markers", + markers: Union[str, numpy.ndarray, Sequence[str]] = "o", + uniform_marker: bool = False, + custom_sdf: str = None, + edge_colors: Union[ + str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float] + ] = "black", + uniform_edge_color: bool = True, + edge_width: float = 1.0, + image: numpy.ndarray = None, + point_rotations: float | numpy.ndarray = 0, + point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform", sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, uniform_size: bool = False, size_space: str = "screen", + isolated_buffer: bool = True, **kwargs, ) -> ScatterGraphic: """ @@ -468,9 +483,66 @@ def add_scatter( cmap_transform: 1D array-like or list of numerical values, optional if provided, these values are used to map the colors from the cmap - isolated_buffer: bool, default True - whether the buffers should be isolated from the user input array. - Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. + mode: one of: "markers", "simple", "gaussian", "image", default "markers" + The scatter points mode, cannot be changed after the graphic has been created. + + * markers: represent points with various or custom markers, default + * simple: all scatters points are simple circles + * gaussian: each point is a gaussian blob + * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites + + markers: None | str | np.ndarray | Sequence[str], default "o" + The shape of the markers when `mode` is "markers" + + Supported values: + + * A string from pygfx.MarkerShape enum + * Matplotlib compatible characters: "osD+x^v<>". + * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶". + * Emojis: "❤️♠️♣️♦️💎💍✳️📍". + * A string containing the value "custom". In this case, the WGSL + code defined by ``custom_sdf`` will be used. + + uniform_marker: bool, default False + Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use + the same marker for all points and want to save GPU RAM. + + custom_sdf: str = None, + The SDF code for the marker shape when the marker is set to custom. + Can be used when `mode` is "markers". + + Negative values are inside the shape, positive values are outside the + shape. + + The SDF's takes in two parameters `coords: vec2` and `size: f32`. + The first is a WGSL coordinate and `size` is the overall size of + the texture. The returned value should be the signed distance from + any edge of the shape. Distances (positive and negative) that are + less than half the `edge_width` in absolute terms will be colored + with the `edge_color`. Other negative distances will be colored by + `colors`. + + edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black" + edge color of the markers, used when `mode` is "markers" + + uniform_edge_color: bool, default True + Set the same edge color for all markers. Useful for saving GPU RAM. + + edge_width: float = 1.0, + Width of the marker edges. used when `mode` is "markers". + + image: ArrayLike, optional + renders an image at the scatter points, also known as sprites. + The image color is multiplied with the point's "normal" color. + + point_rotations: float | ArrayLike = 0, + The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all + points, or an array of rotation values can be used to set per-point rotations + + point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform" + * uniform: set the same rotation for every point, useful to save GPU RAM + * vertex: set per-vertex rotations + * curve: The rotation follows the curve of the line defined by the points (in screen space) sizes: float or iterable of float, optional, default 1.0 sizes of the scatter points @@ -480,7 +552,11 @@ def add_scatter( save GPU VRAM when all points have the same size. size_space: str, default "screen" - coordinate space in which the size is expressed ("screen", "world", "model") + coordinate space in which the size is expressed, one of ("screen", "world", "model") + + isolated_buffer: bool, default True + whether the buffers should be isolated from the user input array. + Generally always ``True``, ``False`` is for rare advanced use if you have large arrays. kwargs passed to :class:`.Graphic` @@ -494,10 +570,20 @@ def add_scatter( uniform_color, cmap, cmap_transform, - isolated_buffer, + mode, + markers, + uniform_marker, + custom_sdf, + edge_colors, + uniform_edge_color, + edge_width, + image, + point_rotations, + point_rotation_mode, sizes, uniform_size, size_space, + isolated_buffer, **kwargs, ) diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 968c68d2a..46433180f 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -33,6 +33,7 @@ def generate_add_graphics_methods(): f.write("from typing import *\n\n") f.write("import numpy\n\n") + f.write("import pygfx\n\n") f.write("from ..graphics import *\n") f.write("from ..graphics._base import Graphic\n\n") diff --git a/tests/conftest.py b/tests/conftest.py index 29ac02fcb..761b0762e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,5 +6,9 @@ def pytest_sessionstart(session): - pygfx.renderers.wgpu.set_wgpu_limits(**{"max-texture-dimension-2d": MAX_TEXTURE_SIZE}) - pygfx.renderers.wgpu.set_wgpu_limits(**{"max-texture-dimension-3d": MAX_TEXTURE_SIZE_3D}) + pygfx.renderers.wgpu.set_wgpu_limits( + **{"max-texture-dimension-2d": MAX_TEXTURE_SIZE} + ) + pygfx.renderers.wgpu.set_wgpu_limits( + **{"max-texture-dimension-3d": MAX_TEXTURE_SIZE_3D} + ) diff --git a/tests/test_common_features.py b/tests/test_common_features.py index 5671478a7..aea016aae 100644 --- a/tests/test_common_features.py +++ b/tests/test_common_features.py @@ -4,7 +4,13 @@ import pytest import fastplotlib as fpl -from fastplotlib.graphics.features import GraphicFeatureEvent, Name, Offset, Rotation, Visible +from fastplotlib.graphics.features import ( + GraphicFeatureEvent, + Name, + Offset, + Rotation, + Visible, +) def make_graphic(kind: str, **kwargs): diff --git a/tests/test_figure.py b/tests/test_figure.py index 520091009..d5d4087e3 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -175,28 +175,16 @@ def test_set_controllers_from_existing_controllers(): def test_subplot_names(): # names must be unique with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4", "4", "5"] - ) + fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "4", "5"]) with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", "2", None, "4", "4", "5"] - ) + fpl.Figure(shape=(2, 3), names=["1", "2", None, "4", "4", "5"]) with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=[None, "2", None, "4", "4", "5"] - ) + fpl.Figure(shape=(2, 3), names=[None, "2", None, "4", "4", "5"]) # len(names) <= n_subplots - fig = fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4", "5", "6"] - ) + fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "5", "6"]) assert fig[0, 0].name == "1" assert fig[0, 1].name == "2" @@ -205,10 +193,7 @@ def test_subplot_names(): assert fig[1, 1].name == "5" assert fig[1, 2].name == "6" - fig = fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", None, "5", "6"] - ) + fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", None, "5", "6"]) assert fig[0, 0].name == "1" assert fig[0, 1].name == "2" @@ -217,10 +202,7 @@ def test_subplot_names(): assert fig[1, 1].name == "5" assert fig[1, 2].name == "6" - fig = fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", None, "5", None] - ) + fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", None, "5", None]) assert fig[0, 0].name == "1" assert fig[0, 1].name == "2" @@ -230,10 +212,7 @@ def test_subplot_names(): assert fig[1, 2].name is None # if fewer subplot names are given than n_sublots, pad with Nones - fig = fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4"] - ) + fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4"]) assert fig[0, 0].name == "1" assert fig[0, 1].name == "2" @@ -244,19 +223,10 @@ def test_subplot_names(): # raise if len(names) > n_subplots with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4", "5", "6", "7"] - ) + fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "5", "6", "7"]) with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", "2", "3", "4", None, "6", "7"] - ) + fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", None, "6", "7"]) with pytest.raises(ValueError): - fpl.Figure( - shape=(2, 3), - names=["1", None, "3", "4", None, "6", "7"] - ) + fpl.Figure(shape=(2, 3), names=["1", None, "3", "4", None, "6", "7"]) diff --git a/tests/test_image_volume_graphic.py b/tests/test_image_volume_graphic.py index f6c6c0641..3cb574e78 100644 --- a/tests/test_image_volume_graphic.py +++ b/tests/test_image_volume_graphic.py @@ -51,8 +51,12 @@ def check_set_slice( data_values[:, : col_slice.start], data[:, : col_slice.start] ) npt.assert_almost_equal(data_values[:, col_slice.stop :], data[:, col_slice.stop :]) - npt.assert_almost_equal(data_values[:, :, : zpl_slice.start], data[:, :, : zpl_slice.start]) - npt.assert_almost_equal(data_values[:, :, zpl_slice.stop :], data[:, :, zpl_slice.stop :]) + npt.assert_almost_equal( + data_values[:, :, : zpl_slice.start], data[:, :, : zpl_slice.start] + ) + npt.assert_almost_equal( + data_values[:, :, zpl_slice.stop :], data[:, :, zpl_slice.stop :] + ) global EVENT_RETURN_VALUE assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent) diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py new file mode 100644 index 000000000..65ead392e --- /dev/null +++ b/tests/test_markers_buffer_manager.py @@ -0,0 +1,143 @@ +import numpy as np +from numpy import testing as npt +import pytest + +import fastplotlib as fpl +import pygfx +from fastplotlib.graphics.features import GraphicFeatureEvent, VertexMarkers +from fastplotlib.graphics.features._scatter import marker_names, vectorized_user_markers_to_std_markers + +from .utils import ( + generate_slice_indices, + generate_positions_spiral_data, +) + + +EVENT_RETURN_VALUE: GraphicFeatureEvent = None + + +def event_handler(ev): + global EVENT_RETURN_VALUE + EVENT_RETURN_VALUE = ev + + +MARKERS1 = list("osD+x^v<>*") +MARKERS2 = list(">+vx*")) +def test_uniform_markers(marker): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter(data, markers=marker, uniform_marker=True) + + marker_full_name = marker_names.get(marker) + + assert isinstance(scatter.world_object.material, pygfx.PointsMarkerMaterial) + assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.uniform + assert isinstance(scatter._markers, UniformMarker) + + assert scatter.markers == marker_full_name + assert scatter.world_object.material.marker == marker_full_name + + # test changes and event + scatter.add_event_handler(event_handler, "markers") + scatter.markers = "o" + assert scatter.markers == pygfx.MarkerShape.circle + assert scatter.world_object.material.marker == pygfx.MarkerShape.circle + + check_event(scatter, "markers", pygfx.MarkerShape.circle) + + +@pytest.mark.parametrize("to_type", [list, tuple, np.array]) +@pytest.mark.parametrize("uniform_marker", [True, False]) +def test_incompatible_marker_args(to_type, uniform_marker): + markers = ["o"] * 3 + ["s"] * 3 + ["+"] * 3 + ["x"] + + markers = to_type(markers) + + data = generate_positions_spiral_data("xyz") + + fig = fpl.Figure() + + if uniform_marker: + with pytest.raises(TypeError): + scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=True) + + else: + scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=False) + assert isinstance(scatter._markers, VertexMarkers) + assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.vertex + + +def test_uniform_custom_sdf(): + lower_right_triangle_sdf = """ + // hardcode square root of 2 + let m_sqrt_2 = 1.4142135; + + // given a distance from an origin point, this defines the hypotenuse of a lower right triangle + let distance = (-coord.x + coord.y) / m_sqrt_2; + + // return distance for this position + return distance * size; + """ + + data = generate_positions_spiral_data("xyz") + + fig = fpl.Figure() + + scatter = fig[0, 0].add_scatter( + data, markers="custom", uniform_marker=True, custom_sdf=lower_right_triangle_sdf + ) + + assert scatter.markers == "custom" + assert scatter.world_object.material.marker == "custom" + assert scatter.world_object.material.custom_sdf == lower_right_triangle_sdf + +# test with both list[str] and 2D numpy array inputs as colors +@pytest.mark.parametrize("edge_colors",[generate_color_inputs("multi")[0], generate_color_inputs("multi")[1]]) +def test_edge_colors(edge_colors): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, + edge_colors=edge_colors, + uniform_edge_color=False, + ) + + assert isinstance(scatter._edge_colors, VertexColors) + + npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH) + + assert ( + scatter.edge_colors.buffer is scatter.world_object.geometry.edge_colors + ) + + # test changes, don't need to test extensively here since it's tested in the main VertexColors test + new_colors, array = generate_color_inputs("multi2") + scatter.edge_colors = new_colors + npt.assert_almost_equal(scatter.edge_colors.value, array) + + +@pytest.mark.parametrize("edge_color", ["r", (1, 0, 0), [1, 0, 0], np.array([1, 0, 0])]) +def test_uniform_edge_colors(edge_color): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, edge_colors=edge_color, uniform_edge_color=True + ) + + assert isinstance(scatter._edge_colors, UniformEdgeColor) + assert scatter.edge_colors == pygfx.Color(edge_color) + assert scatter.world_object.material.edge_color == pygfx.Color(edge_color) + + # test changes and event + scatter.add_event_handler(event_handler, "edge_colors") + scatter.edge_colors = "g" + + assert scatter.edge_colors == pygfx.Color("g") + assert scatter.world_object.material.edge_color == pygfx.Color("g") + + check_event(scatter, "edge_colors", pygfx.Color("g")) + + +@pytest.mark.parametrize("edge_colors", [generate_color_inputs("multi")[0],generate_color_inputs("multi")[1]]) +@pytest.mark.parametrize("uniform_edge_color", [False, True]) +def test_incompatible_edge_colors_args(edge_colors, uniform_edge_color): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + if uniform_edge_color: + with pytest.raises(TypeError): + scatter = fig[0, 0].add_scatter( + data=data, + edge_colors=edge_colors, + uniform_edge_color=uniform_edge_color, + ) + + +@pytest.mark.parametrize("edge_width", [0.0, 0.5, 1.0, 5.0]) +def test_edge_width(edge_width): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, + edge_width=edge_width, + ) + + assert isinstance(scatter._edge_width, EdgeWidth) + assert scatter.world_object.material.edge_width == edge_width + assert scatter.edge_width == edge_width + + # test changes and events + scatter.add_event_handler(event_handler, "edge_width") + scatter.edge_width = 2.0 + + npt.assert_almost_equal(scatter.edge_width, 2.0) + npt.assert_almost_equal(scatter.world_object.material.edge_width, 2.0) + + check_event(scatter, "edge_width", 2.0) + + +def test_uniform_rotation(): + fig = fpl.Figure() + + data = generate_positions_spiral_data("xyz") + + scatter = fig[0, 0].add_scatter( + data=data, + point_rotations=np.pi / 2, + ) + + assert scatter.point_rotation_mode == "uniform" + npt.assert_almost_equal(scatter.point_rotations, np.pi / 2) + + # test changes and events + scatter.add_event_handler(event_handler, "point_rotations") + scatter.point_rotations = np.pi / 3 + + npt.assert_almost_equal(scatter.point_rotations, np.pi / 3) + + check_event(scatter, "point_rotations", np.pi / 3) + + +def test_sprite(): + image = np.array( + [ + [1, 0, 1], + [0, 1, 0], + [1, 1, 1], + ] + ) + + data = generate_positions_spiral_data("xyz") + + fig = fpl.Figure() + + scatter = fig[0, 0].add_scatter( + data=data, + mode="image", + image=image, + ) + + # make sure the image is a fpl TextureArray + assert isinstance(scatter.image, TextureArray) + # make sure the sprite is the TextureArray buffer, i.e. a pygfx.Texture + assert scatter.world_object.material.sprite is scatter.image.buffer[0, 0] + assert scatter.image.buffer.size == 1 + + npt.assert_almost_equal(scatter.image.value, image) + npt.assert_almost_equal(scatter.image.buffer[0, 0].data, image) + + # test changes and event + + image2 = np.array( + [ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0] + ] + ) + + scatter.add_event_handler(event_handler, "image") + + scatter.image = image2 + npt.assert_almost_equal(scatter.image.buffer[0, 0].data, image2) + + assert EVENT_RETURN_VALUE.graphic is scatter + assert EVENT_RETURN_VALUE.target is scatter.world_object + assert EVENT_RETURN_VALUE.type == "image" + npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], image2) diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py index 2f55eab27..d1260e27c 100644 --- a/tests/test_sizes_buffer_manager.py +++ b/tests/test_sizes_buffer_manager.py @@ -2,7 +2,7 @@ from numpy import testing as npt import pytest -from fastplotlib.graphics.features import PointsSizesFeature +from fastplotlib.graphics.features import VertexPointSizes from .utils import generate_slice_indices @@ -28,7 +28,7 @@ def generate_data(input_type: str) -> np.ndarray | float: @pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]]) def test_create_buffer(data): - sizes = PointsSizesFeature(data, n_datapoints=10) + sizes = VertexPointSizes(data, n_datapoints=10) if isinstance(data, float): npt.assert_almost_equal(sizes[:], generate_data("float")) @@ -50,7 +50,7 @@ def test_slice(slice_method: dict, user_input: str): size = slice_method["size"] others = slice_method["others"] - sizes = PointsSizesFeature(data, n_datapoints=10) + sizes = VertexPointSizes(data, n_datapoints=10) match user_input: case "float": diff --git a/tests/test_texture_array_volume.py b/tests/test_texture_array_volume.py index 0cc425230..f2d28501b 100644 --- a/tests/test_texture_array_volume.py +++ b/tests/test_texture_array_volume.py @@ -31,6 +31,7 @@ def make_data(z: int, n_rows: int, n_cols: int) -> np.ndarray: return data.T + def check_texture_array( data: np.ndarray, ta: TextureArrayVolume, @@ -68,9 +69,7 @@ def check_texture_array( data_row_start_index = chunk_row * MAX_TEXTURE_SIZE_3D data_col_start_index = chunk_col * MAX_TEXTURE_SIZE_3D - data_z_stop_index = min( - data.shape[0], data_z_start_index + MAX_TEXTURE_SIZE_3D - ) + data_z_stop_index = min(data.shape[0], data_z_start_index + MAX_TEXTURE_SIZE_3D) data_row_stop_index = min( data.shape[1], data_row_start_index + MAX_TEXTURE_SIZE_3D @@ -91,14 +90,14 @@ def check_set_slice(data, ta, zdim_slice, row_slice, col_slice): npt.assert_almost_equal(ta[zdim_slice, row_slice, col_slice], 1) # make sure other vals unchanged - npt.assert_almost_equal(ta[:zdim_slice.start], data[:zdim_slice.start]) - npt.assert_almost_equal(ta[zdim_slice.stop:], data[zdim_slice.stop:]) + npt.assert_almost_equal(ta[: zdim_slice.start], data[: zdim_slice.start]) + npt.assert_almost_equal(ta[zdim_slice.stop :], data[zdim_slice.stop :]) - npt.assert_almost_equal(ta[:, :row_slice.start], data[:, :row_slice.start]) - npt.assert_almost_equal(ta[:, row_slice.stop:], data[:, row_slice.stop:]) + npt.assert_almost_equal(ta[:, : 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:]) + npt.assert_almost_equal(ta[:, :, : col_slice.start], data[:, :, : col_slice.start]) + npt.assert_almost_equal(ta[:, :, col_slice.stop :], data[:, :, col_slice.stop :]) def make_image_volume_graphic(data) -> fpl.ImageVolumeGraphic: diff --git a/tests/utils.py b/tests/utils.py index bc9a092c8..6da080433 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -148,6 +148,23 @@ def generate_color_inputs( array = np.vstack([pygfx.Color(c) for c in s]) return [s, array] + if name == "multi2": + # a second multi option + s = [ + "g", + "r", + "cyan", + "magenta", + "b", + "black", + "white", + "purple", + "yellow", + "pink", + ] + array = np.vstack([pygfx.Color(c) for c in s]) + return [s, array] + color = pygfx.Color(name) s = name @@ -172,3 +189,37 @@ def generate_color_inputs( [1.0, 0.6470588445663452, 0.0, 1.0], ] ) + + +TRUTH_CMAPS = { + "jet": np.array( + [ + [0.0, 0.0, 0.5, 1.0], + [0.0, 0.0, 0.99910873, 1.0], + [0.0, 0.37843138, 1.0, 1.0], + [0.0, 0.8333333, 1.0, 1.0], + [0.30044276, 1.0, 0.66729915, 1.0], + [0.65464896, 1.0, 0.31309298, 1.0], + [1.0, 0.90123457, 0.0, 1.0], + [1.0, 0.4945534, 0.0, 1.0], + [1.0, 0.08787218, 0.0, 1.0], + [0.5, 0.0, 0.0, 1.0], + ], + dtype=np.float32, + ), + "viridis": np.array( + [ + [0.267004, 0.004874, 0.329415, 1.0], + [0.281412, 0.155834, 0.469201, 1.0], + [0.244972, 0.287675, 0.53726, 1.0], + [0.190631, 0.407061, 0.556089, 1.0], + [0.147607, 0.511733, 0.557049, 1.0], + [0.119483, 0.614817, 0.537692, 1.0], + [0.20803, 0.718701, 0.472873, 1.0], + [0.421908, 0.805774, 0.35191, 1.0], + [0.699415, 0.867117, 0.175971, 1.0], + [0.993248, 0.906157, 0.143936, 1.0], + ], + dtype=np.float32, + ), +}