diff --git a/examples/desktop/screenshots/gridplot.png b/examples/desktop/screenshots/gridplot.png index dbc8cd5b2..f2cbb1e7a 100644 --- a/examples/desktop/screenshots/gridplot.png +++ b/examples/desktop/screenshots/gridplot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:220c8e26502fec371bab3d860f405d53c32f56ed848a2e27a45074f1bb943acd -size 351714 +oid sha256:2705c69adab84f7740322b4a66ce33df00001dc7d51624becb8e88204113b028 +size 350236 diff --git a/examples/desktop/screenshots/image_cmap.png b/examples/desktop/screenshots/image_cmap.png index e2b9e7016..cf3ae8ac0 100644 --- a/examples/desktop/screenshots/image_cmap.png +++ b/examples/desktop/screenshots/image_cmap.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2b1c0c3c2df2e897c43d0263d66d05663b7007c43bcb8bdaf1f3857daa65f79 -size 274669 +oid sha256:d9dcf05ca2953103b9960d9159ccb89dc257bf5e5c6d3906eeaaac9f71686439 +size 274882 diff --git a/examples/desktop/screenshots/image_rgb.png b/examples/desktop/screenshots/image_rgb.png index 2ca90a7fb..5681017c8 100644 --- a/examples/desktop/screenshots/image_rgb.png +++ b/examples/desktop/screenshots/image_rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:558f81f9e62244b89add9b5a84e58e70219e6a6495c3c9a9ea90ef22e5922c33 -size 319491 +oid sha256:408e31db97278c584f4aaa0039099366fc8feb5693d15ab335205927d067c42a +size 319585 diff --git a/examples/desktop/screenshots/image_rgbvminvmax.png b/examples/desktop/screenshots/image_rgbvminvmax.png index e7d32475c..aea5fdf85 100644 --- a/examples/desktop/screenshots/image_rgbvminvmax.png +++ b/examples/desktop/screenshots/image_rgbvminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d919e3a3b9fb87fd64072f818aad07637fb82c82f95ef188c8eb0362ded2baf -size 44805 +oid sha256:d5dbe9a837b3503ca45eb83edbec7b1d7b6463093699af6b01b5303978af4b85 +size 44781 diff --git a/examples/desktop/screenshots/image_simple.png b/examples/desktop/screenshots/image_simple.png index e89c0a6de..5ab073433 100644 --- a/examples/desktop/screenshots/image_simple.png +++ b/examples/desktop/screenshots/image_simple.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e46eba536bc4b6904f9df590b8c2e9b73226f22e37e920cf65e4c6720cd6634 -size 272624 +oid sha256:4aa397a120ed1b232c4d56ffd3547ea42c2874aa54bfbdbffebfd34129059ccd +size 272355 diff --git a/examples/desktop/screenshots/image_vminvmax.png b/examples/desktop/screenshots/image_vminvmax.png index e7d32475c..aea5fdf85 100644 --- a/examples/desktop/screenshots/image_vminvmax.png +++ b/examples/desktop/screenshots/image_vminvmax.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d919e3a3b9fb87fd64072f818aad07637fb82c82f95ef188c8eb0362ded2baf -size 44805 +oid sha256:d5dbe9a837b3503ca45eb83edbec7b1d7b6463093699af6b01b5303978af4b85 +size 44781 diff --git a/examples/notebooks/image_widget.ipynb b/examples/notebooks/image_widget.ipynb index 449f13229..d8f91c1be 100644 --- a/examples/notebooks/image_widget.ipynb +++ b/examples/notebooks/image_widget.ipynb @@ -51,10 +51,8 @@ { "cell_type": "code", "execution_count": null, - "id": "8264fd19-661f-4c50-bdb4-d3998ffd5ff5", - "metadata": { - "tags": [] - }, + "id": "cc90aff2-4e56-4020-93d0-94e81f030f45", + "metadata": {}, "outputs": [], "source": [ "iw.show()" @@ -109,7 +107,7 @@ }, "outputs": [], "source": [ - "iw = ImageWidget(\n", + "iw2 = ImageWidget(\n", " data=a, \n", " slider_dims=[\"t\"],\n", " cmap=\"gnuplot2\"\n", @@ -125,7 +123,7 @@ }, "outputs": [], "source": [ - "iw.show()" + "iw2.show()" ] }, { @@ -148,7 +146,7 @@ "outputs": [], "source": [ "# must be in the form of {dim: (func, window_size)}\n", - "iw.window_funcs = {\"t\": (np.mean, 13)}" + "iw2.window_funcs = {\"t\": (np.mean, 13)}" ] }, { @@ -161,7 +159,7 @@ "outputs": [], "source": [ "# change the winow size\n", - "iw.window_funcs[\"t\"].window_size = 23" + "iw2.window_funcs[\"t\"].window_size = 23" ] }, { @@ -174,7 +172,7 @@ "outputs": [], "source": [ "# change the function\n", - "iw.window_funcs[\"t\"].func = np.max" + "iw2.window_funcs[\"t\"].func = np.max" ] }, { @@ -187,7 +185,7 @@ "outputs": [], "source": [ "# or set it again\n", - "iw.window_funcs = {\"t\": (np.min, 11)}" + "iw2.window_funcs = {\"t\": (np.min, 11)}" ] }, { @@ -208,7 +206,7 @@ "outputs": [], "source": [ "new_data = np.random.rand(500, 512, 512)\n", - "iw.set_data(new_data=new_data)" + "iw2.set_data(new_data=new_data)" ] }, { @@ -241,7 +239,7 @@ }, "outputs": [], "source": [ - "iw = ImageWidget(\n", + "iw3 = ImageWidget(\n", " data=data, \n", " slider_dims=[\"t\"], \n", " # dims_order=\"txy\", # you can set this manually if dim order is not the usual\n", @@ -268,7 +266,7 @@ }, "outputs": [], "source": [ - "iw.show()" + "iw3.show()" ] }, { @@ -288,7 +286,7 @@ }, "outputs": [], "source": [ - "iw.gridplot[\"two\"]" + "iw3.gridplot[\"two\"]" ] }, { @@ -308,7 +306,7 @@ }, "outputs": [], "source": [ - "iw.window_funcs[\"t\"].func = np.max" + "iw3.window_funcs[\"t\"].func = np.max" ] }, { @@ -331,7 +329,7 @@ "dims = (256, 256, 5, 100)\n", "data = [np.random.rand(*dims) for i in range(4)]\n", "\n", - "iw = ImageWidget(\n", + "iw4 = ImageWidget(\n", " data=data, \n", " slider_dims=[\"t\", \"z\"], \n", " dims_order=\"xyzt\", # example of how you can set this for non-standard orders\n", @@ -350,7 +348,7 @@ }, "outputs": [], "source": [ - "iw.show()" + "iw4.show()" ] }, { @@ -370,16 +368,8 @@ }, "outputs": [], "source": [ - "iw.window_funcs = {\"t\": (np.mean, 11)}" + "iw4.window_funcs = {\"t\": (np.mean, 11)}" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3090a7e2-558e-4975-82f4-6a67ae141900", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -398,7 +388,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.2" + "version": "3.11.3" } }, "nbformat": 4, diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb index 9382ffa63..0f81bc36b 100644 --- a/examples/notebooks/linear_selector.ipynb +++ b/examples/notebooks/linear_selector.ipynb @@ -57,7 +57,7 @@ "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n", "\n", "plot.auto_scale()\n", - "plot.show(vbox=[ipywidget_slider])" + "plot.show(add_widgets=[ipywidget_slider])" ] }, { diff --git a/examples/notebooks/simple.ipynb b/examples/notebooks/simple.ipynb index 753de5a98..681980d39 100644 --- a/examples/notebooks/simple.ipynb +++ b/examples/notebooks/simple.ipynb @@ -110,19 +110,7 @@ "source": [ "**Use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**\n", "\n", - "By default the origin is on the bottom left, you can click the flip button to flip the y-axis, or use `plot.camera.local.scale_y *= -1`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "58c1dc0b-9bf0-4ad5-8579-7c10396fc6bc", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot.camera.local.scale_y *= -1" + "If an image is in the plot the origin is in the top left. You can click the flip button to flip the y-axis direction, or use `plot.camera.local.scale_y *= -1`" ] }, { @@ -450,8 +438,8 @@ "metadata": {}, "outputs": [], "source": [ - "# close the sidecar\n", - "plot.sidecar.close()" + "# close the plot\n", + "plot.close()" ] }, { @@ -481,18 +469,6 @@ "plot_rgb.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "71eae361-3bbf-4d1f-a903-3615d35b557b", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_rgb.camera.local.scale_y *= -1" - ] - }, { "cell_type": "markdown", "id": "7fc66377-00e8-4f32-9671-9cf63f74529f", @@ -533,8 +509,8 @@ "metadata": {}, "outputs": [], "source": [ - "# close sidecar\n", - "plot_rgb.sidecar.close()" + "# close plot\n", + "plot_rgb.close()" ] }, { @@ -632,16 +608,6 @@ "### You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", - "metadata": {}, - "outputs": [], - "source": [ - "VBox([plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])" - ] - }, { "cell_type": "code", "execution_count": null, @@ -649,7 +615,7 @@ "metadata": {}, "outputs": [], "source": [ - "HBox([plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])" + "HBox([plot_v.show(), plot_sync.show()])" ] }, { @@ -659,8 +625,9 @@ "metadata": {}, "outputs": [], "source": [ - "# close sidecar\n", - "plot_v.sidecar.close()" + "# close plot\n", + "plot_v.close()\n", + "plot_sync.close()" ] }, { @@ -762,19 +729,7 @@ "\n", "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n", "\n", - "You can also click the **`1:1`** button to toggle this." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "plot_l.camera.maintain_aspect = False" + "You can also click the **`1:1`** button to toggle this, or use `plot.camera.maintain_aspect`" ] }, { @@ -877,7 +832,7 @@ "id": "c29f81f9-601b-49f4-b20c-575c56e58026", "metadata": {}, "source": [ - "## Graphic _data_ is itself also indexable" + "## Graphic _data_ is also indexable" ] }, { @@ -1027,8 +982,8 @@ "metadata": {}, "outputs": [], "source": [ - "# close sidecar\n", - "plot_l.sidecar.close()" + "# close plot\n", + "plot_l.close()" ] }, { @@ -1099,8 +1054,8 @@ }, "outputs": [], "source": [ - "# close sidecar\n", - "plot_l3d.sidecar.close()" + "# close plot\n", + "plot_l3d.close()" ] }, { @@ -1239,31 +1194,8 @@ "metadata": {}, "outputs": [], "source": [ - "# close sidecar\n", - "plot_s.sidecar.close()" - ] - }, - { - "cell_type": "markdown", - "id": "d9e554de-c436-4684-a46a-ce8a33d409ac", - "metadata": {}, - "source": [ - "### You can combine VBox and HBox to create more complex layouts\n", - "\n", - "This just plots everything above in a single nb output" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f404a5ea-633b-43f5-87d1-237017bbca2a", - "metadata": {}, - "outputs": [], - "source": [ - "row1 = HBox([plot.show(sidecar=False), plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])\n", - "row2 = HBox([plot_l.show(sidecar=False), plot_l3d.show(sidecar=False), plot_s.show(sidecar=False)])\n", - "\n", - "VBox([row1, row2])" + "# close plot\n", + "plot_s.close()" ] }, { diff --git a/examples/qt/imagewidget.py b/examples/qt/imagewidget.py new file mode 100644 index 000000000..ab1a055f1 --- /dev/null +++ b/examples/qt/imagewidget.py @@ -0,0 +1,29 @@ +""" +Use ImageWidget to display one or multiple image sequences +""" +import numpy as np +from PyQt6 import QtWidgets +import fastplotlib as fpl + +# Qt app MUST be instantiated before creating any fpl objects, or any other Qt objects +app = QtWidgets.QApplication([]) + +images = np.random.rand(100, 512, 512) + +# create image widget, force Qt canvas so it doesn't pick glfw +iw = fpl.ImageWidget(images, grid_plot_kwargs={"canvas": "qt"}) +iw.show() +iw.widget.resize(800, 800) + +# another image widget with multiple images +images_list = [np.random.rand(100, 512, 512) for i in range(9)] + +iw_mult = fpl.ImageWidget( + images_list, + grid_plot_kwargs={"canvas": "qt"}, + cmap="viridis" +) +iw_mult.show() +iw_mult.widget.resize(800, 800) + +app.exec() diff --git a/fastplotlib/layouts/_frame/__init__.py b/fastplotlib/layouts/_frame/__init__.py new file mode 100644 index 000000000..c34884022 --- /dev/null +++ b/fastplotlib/layouts/_frame/__init__.py @@ -0,0 +1 @@ +from ._frame import Frame diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py new file mode 100644 index 000000000..abd79759e --- /dev/null +++ b/fastplotlib/layouts/_frame/_frame.py @@ -0,0 +1,184 @@ +import os + +from ._toolbar import ToolBar + +from ...graphics import ImageGraphic + +from .._utils import CANVAS_OPTIONS_AVAILABLE + + +class UnavailableOutputContext: + # called when a requested output context is not available + # ex: if trying to force jupyter_rfb canvas but jupyter_rfb is not installed + def __init__(self, context_name, msg): + self.context_name = context_name + self.msg = msg + + def __call__(self, *args, **kwargs): + raise ModuleNotFoundError( + f"The following output context is not available: {self.context_name}\n{self.msg}" + ) + + +# TODO: potentially put all output context and toolbars in their own module and have this determination done at import +if CANVAS_OPTIONS_AVAILABLE["jupyter"]: + from ._jupyter_output import JupyterOutputContext +else: + JupyterOutputContext = UnavailableOutputContext( + "Jupyter", + "You must install fastplotlib using the `'notebook'` option to use this context:\n" + 'pip install "fastplotlib[notebook]"' + ) + +if CANVAS_OPTIONS_AVAILABLE["qt"]: + from ._qt_output import QOutputContext +else: + QtOutput = UnavailableOutputContext( + "Qt", + "You must install `PyQt6` to use this output context" + ) + + +class Frame: + """ + Mixin class for Plot and GridPlot that "frames" the plot. + + Gives them their `show()` call that returns the appropriate output context. + """ + def __init__(self): + self._output = None + + @property + def toolbar(self) -> ToolBar: + """ipywidget or QToolbar instance""" + return self._output.toolbar + + @property + def widget(self): + """ipywidget or QWidget that contains this plot""" + # @caitlin: this is the same as the output context, but I figure widget is a simpler public name + return self._output + + def render(self): + """render call implemented in subclass""" + raise NotImplemented + + def _autoscale_init(self, maintain_aspect: bool): + """autoscale function that is called only during show()""" + if hasattr(self, "_subplots"): + for subplot in self: + if maintain_aspect is None: + _maintain_aspect = subplot.camera.maintain_aspect + else: + _maintain_aspect = maintain_aspect + subplot.auto_scale(maintain_aspect=_maintain_aspect, zoom=0.95) + else: + if maintain_aspect is None: + maintain_aspect = self.camera.maintain_aspect + self.auto_scale(maintain_aspect=maintain_aspect, zoom=0.95) + + def start_render(self): + """start render cycle""" + self.canvas.request_draw(self.render) + self.canvas.set_logical_size(*self._starting_size) + + def show( + self, + autoscale: bool = True, + maintain_aspect: bool = None, + toolbar: bool = True, + sidecar: bool = False, + sidecar_kwargs: dict = None, + add_widgets: list = None, + ): + """ + Begins the rendering event loop and shows the plot in the desired output context (jupyter, qt or glfw). + + Parameters + ---------- + autoscale: bool, default ``True`` + autoscale the Scene + + maintain_aspect: bool, default ``True`` + maintain aspect ratio + + toolbar: bool, default ``True`` + show toolbar + + sidecar: bool, default ``True`` + display plot in a ``jupyterlab-sidecar``, only for jupyter output context + + sidecar_kwargs: dict, default ``None`` + kwargs for sidecar instance to display plot + i.e. title, layout + + add_widgets: list of widgets + a list of ipywidgets or QWidget that are vertically stacked below the plot + + Returns + ------- + OutputContext + In jupyter, it will display the plot in the output cell or sidecar + + In Qt, it will display the Plot, toolbar, etc. as stacked widget, use `Plot.widget` to access it. + """ + + # show was already called, return existing output context + if self._output is not None: + return self._output + + self.start_render() + + if sidecar_kwargs is None: + sidecar_kwargs = dict() + + if add_widgets is None: + add_widgets = list() + + # flip y axis if ImageGraphics are present + if hasattr(self, "_subplots"): + for subplot in self: + for g in subplot.graphics: + if isinstance(g, ImageGraphic): + subplot.camera.local.scale_y *= -1 + break + else: + for g in self.graphics: + if isinstance(g, ImageGraphic): + self.camera.local.scale_y *= -1 + break + + if autoscale: + self._autoscale_init(maintain_aspect) + + # used for generating images in docs using nbsphinx + if "NB_SNAPSHOT" in os.environ.keys(): + if os.environ["NB_SNAPSHOT"] == "1": + return self.canvas.snapshot() + + # return the appropriate OutputContext based on the current canvas + if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": + self._output = JupyterOutputContext( + frame=self, + make_toolbar=toolbar, + use_sidecar=sidecar, + sidecar_kwargs=sidecar_kwargs, + add_widgets=add_widgets, + ) + + elif self.canvas.__class__.__name__ == "QWgpuCanvas": + self._output = QOutputContext( + frame=self, + make_toolbar=toolbar, + add_widgets=add_widgets + ) + + else: # assume GLFW, the output context is just the canvas + self._output = self.canvas + + # return the output context, this call is required for jupyter but not for Qt + return self._output + + def close(self): + """Close the output context""" + self._output.close() diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py new file mode 100644 index 000000000..f27856e61 --- /dev/null +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -0,0 +1,310 @@ +import traceback +from datetime import datetime +from itertools import product +from math import copysign +from functools import partial +from typing import * + + +from ipywidgets.widgets import ( + IntSlider, + VBox, + HBox, + ToggleButton, + Dropdown, + Layout, + Button, + BoundedIntText, + Play, + jslink, +) + +from ...graphics.selectors import PolygonSelector +from ._toolbar import ToolBar + + +class IpywidgetToolBar(HBox, ToolBar): + """Basic toolbar using ipywidgets""" + def __init__(self, plot): + ToolBar.__init__(self, plot) + + self._auto_scale_button = Button( + value=False, + disabled=False, + icon="expand-arrows-alt", + layout=Layout(width="auto"), + tooltip="auto-scale scene", + ) + self._center_scene_button = Button( + value=False, + disabled=False, + icon="align-center", + layout=Layout(width="auto"), + tooltip="auto-center scene", + ) + self._panzoom_controller_button = ToggleButton( + value=True, + disabled=False, + icon="hand-pointer", + layout=Layout(width="auto"), + tooltip="panzoom controller", + ) + self._maintain_aspect_button = ToggleButton( + value=True, + disabled=False, + description="1:1", + layout=Layout(width="auto"), + tooltip="maintain aspect", + ) + self._maintain_aspect_button.style.font_weight = "bold" + + self._y_direction_button = Button( + value=False, + disabled=False, + icon="arrow-up", + layout=Layout(width="auto"), + tooltip="y-axis direction", + ) + + self._record_button = ToggleButton( + value=False, + disabled=False, + icon="video", + layout=Layout(width="auto"), + tooltip="record", + ) + + self._add_polygon_button = Button( + value=False, + disabled=False, + icon="draw-polygon", + layout=Layout(width="auto"), + tooltip="add PolygonSelector" + ) + + widgets = [ + self._auto_scale_button, + self._center_scene_button, + self._panzoom_controller_button, + self._maintain_aspect_button, + self._y_direction_button, + self._add_polygon_button, + self._record_button, + ] + + if hasattr(self.plot, "_subplots"): + positions = list(product(range(self.plot.shape[0]), range(self.plot.shape[1]))) + values = list() + for pos in positions: + if self.plot[pos].name is not None: + values.append(self.plot[pos].name) + else: + values.append(str(pos)) + + self._dropdown = Dropdown( + options=values, + disabled=False, + description="Subplots:", + layout=Layout(width="200px"), + ) + + self.plot.renderer.add_event_handler(self.update_current_subplot, "click") + + widgets.append(self._dropdown) + + self._panzoom_controller_button.observe(self.panzoom_handler, "value") + self._auto_scale_button.on_click(self.auto_scale_handler) + self._center_scene_button.on_click(self.center_scene_handler) + self._maintain_aspect_button.observe(self.maintain_aspect_handler, "value") + self._y_direction_button.on_click(self.y_direction_handler) + self._add_polygon_button.on_click(self.add_polygon) + self._record_button.observe(self.record_plot, "value") + + # set initial values for some buttons + self._maintain_aspect_button.value = self.current_subplot.camera.maintain_aspect + + if copysign(1, self.current_subplot.camera.local.scale_y) == -1: + self._y_direction_button.icon = "arrow-down" + else: + self._y_direction_button.icon = "arrow-up" + + super().__init__(widgets) + + def _get_subplot_dropdown_value(self) -> str: + return self._dropdown.value + + def auto_scale_handler(self, obj): + self.current_subplot.auto_scale(maintain_aspect=self.current_subplot.camera.maintain_aspect) + + def center_scene_handler(self, obj): + self.current_subplot.center_scene() + + def panzoom_handler(self, obj): + self.current_subplot.controller.enabled = self._panzoom_controller_button.value + + def maintain_aspect_handler(self, obj): + for camera in self.current_subplot.controller.cameras: + camera.maintain_aspect = self._maintain_aspect_button.value + + def y_direction_handler(self, obj): + # flip every camera under the same controller + for camera in self.current_subplot.controller.cameras: + camera.local.scale_y *= -1 + + if copysign(1, self.current_subplot.camera.local.scale_y) == -1: + self._y_direction_button.icon = "arrow-down" + else: + self._y_direction_button.icon = "arrow-up" + + def update_current_subplot(self, ev): + for subplot in self.plot: + pos = subplot.map_screen_to_world((ev.x, ev.y)) + if pos is not None: + # update self.dropdown + if subplot.name is None: + self._dropdown.value = str(subplot.position) + else: + self._dropdown.value = subplot.name + self._panzoom_controller_button.value = subplot.controller.enabled + self._maintain_aspect_button.value = subplot.camera.maintain_aspect + + if copysign(1, subplot.camera.local.scale_y) == -1: + self._y_direction_button.icon = "arrow-down" + else: + self._y_direction_button.icon = "arrow-up" + + def record_plot(self, obj): + if self._record_button.value: + try: + self.plot.record_start( + f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" + ) + except Exception: + traceback.print_exc() + self._record_button.value = False + else: + self.plot.record_stop() + + def add_polygon(self, obj): + ps = PolygonSelector(edge_width=3, edge_color="magenta") + self.current_subplot.add_graphic(ps, center=False) + + +class IpywidgetImageWidgetToolbar(VBox): + def __init__(self, iw): + """ + Basic toolbar for a ImageWidget instance. + + Parameters + ---------- + plot: + """ + self.iw = iw + + self.reset_vminvmax_button = Button( + value=False, + disabled=False, + icon="adjust", + layout=Layout(width="auto"), + tooltip="reset vmin/vmax", + ) + + self.reset_vminvmax_hlut_button = Button( + value=False, + icon="adjust", + description="reset", + layout=Layout(width="auto"), + tooltip="reset vmin/vmax and reset histogram using current frame" + ) + + self.sliders: Dict[str, IntSlider] = dict() + + # only for xy data, no time point slider needed + if self.iw.ndim == 2: + widgets = [self.reset_vminvmax_button] + # for txy, tzxy, etc. data + else: + for dim in self.iw.slider_dims: + slider = IntSlider( + min=0, + max=self.iw._dims_max_bounds[dim] - 1, + step=1, + value=0, + description=f"dimension: {dim}", + orientation="horizontal", + ) + + slider.observe(partial(self.iw._slider_value_changed, dim), names="value") + + self.sliders[dim] = slider + + self.step_size_setter = BoundedIntText( + value=1, + min=1, + max=self.sliders["t"].max, + step=1, + description="Step Size:", + disabled=False, + description_tooltip="set slider step", + layout=Layout(width="150px"), + ) + self.speed_text = BoundedIntText( + value=100, + min=1, + max=1_000, + step=50, + description="Speed", + disabled=False, + description_tooltip="Playback speed, this is NOT framerate.\nArbitrary units between 1 - 1,000", + layout=Layout(width="150px"), + ) + self.play_button = Play( + value=0, + min=self.sliders["t"].min, + max=self.sliders["t"].max, + step=self.sliders["t"].step, + description="play/pause", + disabled=False, + ) + widgets = [ + self.reset_vminvmax_button, + self.reset_vminvmax_hlut_button, + self.play_button, + self.step_size_setter, + self.speed_text + ] + + self.play_button.interval = 10 + + self.step_size_setter.observe(self._change_stepsize, "value") + self.speed_text.observe(self._change_framerate, "value") + jslink((self.play_button, "value"), (self.sliders["t"], "value")) + jslink((self.play_button, "max"), (self.sliders["t"], "max")) + + self.reset_vminvmax_button.on_click(self._reset_vminvmax) + self.reset_vminvmax_hlut_button.on_click(self._reset_vminvmax_frame) + + self.iw.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize") + + # the buttons + self.hbox = HBox(widgets) + + super().__init__((self.hbox, *list(self.sliders.values()))) + + def _reset_vminvmax(self, obj): + self.iw.reset_vmin_vmax() + + def _reset_vminvmax_frame(self, obj): + self.iw.reset_vmin_vmax_frame() + + def _change_stepsize(self, obj): + self.sliders["t"].step = self.step_size_setter.value + + def _change_framerate(self, change): + interval = int(1000 / change["new"]) + self.play_button.interval = interval + + def _set_slider_layout(self, *args): + w, h = self.iw.gridplot.renderer.logical_size + for k, v in self.sliders.items(): + v.layout = Layout(width=f"{w}px") diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py new file mode 100644 index 000000000..25f5e2a2e --- /dev/null +++ b/fastplotlib/layouts/_frame/_jupyter_output.py @@ -0,0 +1,84 @@ +from typing import * + +from ipywidgets import VBox, Widget +from sidecar import Sidecar +from IPython.display import display + +from ._ipywidget_toolbar import IpywidgetToolBar + + +class JupyterOutputContext(VBox): + """ + Output context to display plots in jupyter. Inherits from ipywidgets.VBox + + Basically vstacks plot canvas, toolbar, and other widgets. Uses sidecar if desired. + """ + def __init__( + self, + frame, + make_toolbar: bool, + use_sidecar: bool, + sidecar_kwargs: dict, + add_widgets: List[Widget], + ): + """ + + Parameters + ---------- + frame: + Plot frame for which to generate the output context + + sidecar_kwargs: dict + optional kwargs passed to Sidecar + + add_widgets: List[Widget] + list of ipywidgets to stack below the plot and toolbar + """ + self.frame = frame + self.toolbar = None + self.sidecar = None + + # verify they are all valid ipywidgets + if False in [isinstance(w, Widget) for w in add_widgets]: + raise TypeError( + f"add_widgets must be list of ipywidgets, you have passed:\n{add_widgets}" + ) + + self.use_sidecar = use_sidecar + + if not make_toolbar: # just stack canvas and the additional widgets, if any + self.output = (frame.canvas, *add_widgets) + + if make_toolbar: # make toolbar and stack canvas, toolbar, add_widgets + self.toolbar = IpywidgetToolBar(frame) + self.output = (frame.canvas, self.toolbar, *add_widgets) + + if use_sidecar: # instantiate sidecar if desired + self.sidecar = Sidecar(**sidecar_kwargs) + + # stack all of these in the VBox + super().__init__(self.output) + + def _repr_mimebundle_(self, *args, **kwargs): + """ + This is what jupyter hook into when this output context instance is returned at the end of a cell. + """ + if self.use_sidecar: + with self.sidecar: + # TODO: prints all the child widgets in the cell output, will figure out later, sidecar output works + return display(VBox(self.output)) + else: + # just display VBox contents in cell output + return super()._repr_mimebundle_(*args, **kwargs) + + def close(self): + """Closes the output context, cleanup all the stuff""" + self.frame.canvas.close() + + if self.toolbar is not None: + self.toolbar.close() + + if self.sidecar is not None: + self.sidecar.close() + + super().close() # ipywidget VBox cleanup diff --git a/fastplotlib/layouts/_frame/_qt_output.py b/fastplotlib/layouts/_frame/_qt_output.py new file mode 100644 index 000000000..b4c7cffd9 --- /dev/null +++ b/fastplotlib/layouts/_frame/_qt_output.py @@ -0,0 +1,57 @@ +from PyQt6 import QtWidgets + +from ._qt_toolbar import QToolbar + + +class QOutputContext(QtWidgets.QWidget): + """ + Output context to display plots in Qt apps. Inherits from QtWidgets.QWidget + + Basically vstacks plot canvas, toolbar, and other widgets. + """ + def __init__( + self, + frame, + make_toolbar, + add_widgets, + ): + """ + + Parameters + ---------- + frame: + Plot frame for which to generate the output context + + add_widgets: List[Widget] + list of QWidget to stack below the plot and toolbar + """ + # no parent, user can use Plot.widget.setParent(parent) if necessary to embed into other widgets + QtWidgets.QWidget.__init__(self, parent=None) + self.frame = frame + self.toolbar = None + + # vertical layout used to stack plot canvas, toolbar, and add_widgets + self.vlayout = QtWidgets.QVBoxLayout(self) + + # add canvas to layout + self.vlayout.addWidget(self.frame.canvas) + + if make_toolbar: # make toolbar and add to layout + self.toolbar = QToolbar(output_context=self, plot=frame) + self.vlayout.addWidget(self.toolbar) + + for w in add_widgets: # add any additional widgets to layout + w.setParent(self) + self.vlayout.addWidget(w) + + self.setLayout(self.vlayout) + + self.resize(*self.frame._starting_size) + + self.show() + + def close(self): + """Cleanup and close the output context""" + self.frame.canvas.close() + self.toolbar.close() + super().close() # QWidget cleanup diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py new file mode 100644 index 000000000..9d4e0b48f --- /dev/null +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -0,0 +1,235 @@ +from datetime import datetime +from functools import partial +from math import copysign +import traceback +from typing import * + +from PyQt6 import QtWidgets, QtCore + +from ...graphics.selectors import PolygonSelector +from ._toolbar import ToolBar +from ._qtoolbar_template import Ui_QToolbar + + +class QToolbar(ToolBar, QtWidgets.QWidget): # inheritance order MUST be Toolbar first, QWidget second! Else breaks + """Toolbar for Qt context""" + def __init__(self, output_context, plot): + QtWidgets.QWidget.__init__(self, parent=output_context) + ToolBar.__init__(self, plot) + + # initialize UI + self.ui = Ui_QToolbar() + self.ui.setupUi(self) + + # connect button events + self.ui.auto_scale_button.clicked.connect(self.auto_scale_handler) + self.ui.center_button.clicked.connect(self.center_scene_handler) + self.ui.panzoom_button.toggled.connect(self.panzoom_handler) + self.ui.maintain_aspect_button.toggled.connect(self.maintain_aspect_handler) + self.ui.y_direction_button.clicked.connect(self.y_direction_handler) + + # the subplot labels that update when a user click on subplots + if hasattr(self.plot, "_subplots"): + subplot = self.plot[0, 0] + # set label from first subplot name + if subplot.name is not None: + name = subplot.name + else: + name = str(subplot.position) + + # here we will just use a simple label, not a dropdown like ipywidgets + # the dropdown implementation is tedious with Qt + self.ui.current_subplot = QtWidgets.QLabel(parent=self) + self.ui.current_subplot.setText(name) + self.ui.horizontalLayout.addWidget(self.ui.current_subplot) + + # update the subplot label when a subplot is clicked into + self.plot.renderer.add_event_handler(self.update_current_subplot, "click") + + self.setMaximumHeight(35) + + # set the initial values for buttons + self.ui.maintain_aspect_button.setChecked(self.current_subplot.camera.maintain_aspect) + self.ui.panzoom_button.setChecked(self.current_subplot.controller.enabled) + + if copysign(1, self.current_subplot.camera.local.scale_y) == -1: + self.ui.y_direction_button.setText("v") + else: + self.ui.y_direction_button.setText("^") + + def update_current_subplot(self, ev): + """update the text label for the current subplot""" + for subplot in self.plot: + pos = subplot.map_screen_to_world((ev.x, ev.y)) + if pos is not None: + if subplot.name is not None: + name = subplot.name + else: + name = str(subplot.position) + self.ui.current_subplot.setText(name) + + # set buttons w.r.t. current subplot + self.ui.panzoom_button.setChecked(subplot.controller.enabled) + self.ui.maintain_aspect_button.setChecked(subplot.camera.maintain_aspect) + + if copysign(1, subplot.camera.local.scale_y) == -1: + self.ui.y_direction_button.setText("v") + else: + self.ui.y_direction_button.setText("^") + + def _get_subplot_dropdown_value(self) -> str: + return self.ui.current_subplot.text() + + def auto_scale_handler(self, *args): + self.current_subplot.auto_scale(maintain_aspect=self.current_subplot.camera.maintain_aspect) + + def center_scene_handler(self, *args): + self.current_subplot.center_scene() + + def panzoom_handler(self, value: bool): + self.current_subplot.controller.enabled = value + + def maintain_aspect_handler(self, value: bool): + for camera in self.current_subplot.controller.cameras: + camera.maintain_aspect = value + + def y_direction_handler(self, *args): + # flip every camera under the same controller + for camera in self.current_subplot.controller.cameras: + camera.local.scale_y *= -1 + + if copysign(1, self.current_subplot.camera.local.scale_y) == -1: + self.ui.y_direction_button.setText("v") + else: + self.ui.y_direction_button.setText("^") + + def record_handler(self, ev): + if self.ui.record_button.isChecked(): + try: + self.plot.record_start( + f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" + ) + except Exception: + traceback.print_exc() + self.ui.record_button.setChecked(False) + else: + self.plot.record_stop() + + def add_polygon(self, *args): + ps = PolygonSelector(edge_width=3, edge_color="mageneta") + self.current_subplot.add_graphic(ps, center=False) + + +# TODO: There must be a better way to do this +# TODO: Check if an interface exists between ipywidgets and Qt +class SliderInterface: + """ + This exists so that ImageWidget has a common interface for Sliders. + + This interface makes a QSlider behave somewhat like a ipywidget IntSlider, enough for ImageWidget to function. + """ + def __init__(self, qslider): + self.qslider = qslider + + @property + def value(self) -> int: + return self.qslider.value() + + @value.setter + def value(self, value: int): + self.qslider.setValue(value) + + @property + def max(self) -> int: + return self.qslider.maximum() + + @max.setter + def max(self, value: int): + self.qslider.setMaximum(value) + + @property + def min(self): + return self.qslider.minimum() + + @min.setter + def min(self, value: int): + self.qslider.setMinimum(value) + + +class QToolbarImageWidget(QtWidgets.QWidget): + """Toolbar for ImageWidget""" + + def __init__(self, image_widget): + QtWidgets.QWidget.__init__(self) + + # vertical layout + self.vlayout = QtWidgets.QVBoxLayout(self) + + self.image_widget = image_widget + + hlayout_buttons = QtWidgets.QHBoxLayout() + + self.reset_vmin_vmax_button = QtWidgets.QPushButton(self) + self.reset_vmin_vmax_button.setText("auto-contrast") + self.reset_vmin_vmax_button.clicked.connect(self.image_widget.reset_vmin_vmax) + hlayout_buttons.addWidget(self.reset_vmin_vmax_button) + + self.reset_vmin_vmax_hlut_button = QtWidgets.QPushButton(self) + self.reset_vmin_vmax_hlut_button.setText("reset histogram-lut") + self.reset_vmin_vmax_hlut_button.clicked.connect(self.image_widget.reset_vmin_vmax_frame) + hlayout_buttons.addWidget(self.reset_vmin_vmax_hlut_button) + + self.vlayout.addLayout(hlayout_buttons) + + self.sliders: Dict[str, SliderInterface] = dict() + + # has time and/or z-volume + if self.image_widget.ndim > 2: + # create a slider, spinbox and dimension label for each dimension in the ImageWidget + for dim in self.image_widget.slider_dims: + hlayout = QtWidgets.QHBoxLayout() # horizontal stack for label, slider, spinbox + + # max value for current dimension + max_val = self.image_widget._dims_max_bounds[dim] - 1 + + # make slider + slider = QtWidgets.QSlider(self) + slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + slider.setMinimum(0) + slider.setMaximum(max_val) + slider.setValue(0) + slider.setSingleStep(1) + slider.setPageStep(10) + + # make spinbox + spinbox = QtWidgets.QSpinBox(self) + spinbox.setMinimum(0) + spinbox.setMaximum(max_val) + spinbox.setValue(0) + spinbox.setSingleStep(1) + + # link slider and spinbox + slider.valueChanged.connect(spinbox.setValue) + spinbox.valueChanged.connect(slider.setValue) + + # connect slider to change the index within the dimension + slider.valueChanged.connect(partial(self.image_widget._slider_value_changed, dim)) + + # slider dimension label + slider_label = QtWidgets.QLabel(self) + slider_label.setText(dim) + + # add the widgets to the horizontal layout + hlayout.addWidget(slider_label) + hlayout.addWidget(slider) + hlayout.addWidget(spinbox) + + # add horizontal layout to the vertical layout + self.vlayout.addLayout(hlayout) + + # add to sliders dict for easier access to users + self.sliders[dim] = SliderInterface(slider) + + max_height = 35 + (35 * len(self.sliders.keys())) + + self.setMaximumHeight(max_height) diff --git a/fastplotlib/layouts/_frame/_qtoolbar_template.py b/fastplotlib/layouts/_frame/_qtoolbar_template.py new file mode 100644 index 000000000..a8a1c6f86 --- /dev/null +++ b/fastplotlib/layouts/_frame/_qtoolbar_template.py @@ -0,0 +1,62 @@ +# Form implementation generated from reading ui file 'qtoolbar.ui' +# +# Created by: PyQt6 UI code generator 6.5.3 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_QToolbar(object): + def setupUi(self, QToolbar): + QToolbar.setObjectName("QToolbar") + QToolbar.resize(638, 48) + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(QToolbar) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.auto_scale_button = QtWidgets.QPushButton(parent=QToolbar) + self.auto_scale_button.setObjectName("auto_scale_button") + self.horizontalLayout.addWidget(self.auto_scale_button) + self.center_button = QtWidgets.QPushButton(parent=QToolbar) + self.center_button.setObjectName("center_button") + self.horizontalLayout.addWidget(self.center_button) + self.panzoom_button = QtWidgets.QPushButton(parent=QToolbar) + self.panzoom_button.setCheckable(True) + self.panzoom_button.setObjectName("panzoom_button") + self.horizontalLayout.addWidget(self.panzoom_button) + self.maintain_aspect_button = QtWidgets.QPushButton(parent=QToolbar) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.maintain_aspect_button.setFont(font) + self.maintain_aspect_button.setCheckable(True) + self.maintain_aspect_button.setObjectName("maintain_aspect_button") + self.horizontalLayout.addWidget(self.maintain_aspect_button) + self.y_direction_button = QtWidgets.QPushButton(parent=QToolbar) + self.y_direction_button.setObjectName("y_direction_button") + self.horizontalLayout.addWidget(self.y_direction_button) + self.add_polygon_button = QtWidgets.QPushButton(parent=QToolbar) + self.add_polygon_button.setObjectName("add_polygon_button") + self.horizontalLayout.addWidget(self.add_polygon_button) + self.record_button = QtWidgets.QPushButton(parent=QToolbar) + self.record_button.setCheckable(True) + self.record_button.setObjectName("record_button") + self.horizontalLayout.addWidget(self.record_button) + self.horizontalLayout_2.addLayout(self.horizontalLayout) + + self.retranslateUi(QToolbar) + QtCore.QMetaObject.connectSlotsByName(QToolbar) + + def retranslateUi(self, QToolbar): + _translate = QtCore.QCoreApplication.translate + QToolbar.setWindowTitle(_translate("QToolbar", "Form")) + self.auto_scale_button.setText(_translate("QToolbar", "autoscale")) + self.center_button.setText(_translate("QToolbar", "center")) + self.panzoom_button.setText(_translate("QToolbar", "panzoom")) + self.maintain_aspect_button.setText(_translate("QToolbar", "1:1")) + self.y_direction_button.setText(_translate("QToolbar", "^")) + self.add_polygon_button.setText(_translate("QToolbar", "polygon")) + self.record_button.setText(_translate("QToolbar", "record")) diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/_frame/_toolbar.py new file mode 100644 index 000000000..94410b8ea --- /dev/null +++ b/fastplotlib/layouts/_frame/_toolbar.py @@ -0,0 +1,45 @@ +from fastplotlib.layouts._subplot import Subplot + + +class ToolBar: + def __init__(self, plot): + self.plot = plot + + def _get_subplot_dropdown_value(self) -> str: + raise NotImplemented + + @property + def current_subplot(self) -> Subplot: + """Returns current subplot""" + if hasattr(self.plot, "_subplots"): + # parses dropdown or label value as plot name or position + current = self._get_subplot_dropdown_value() + if current[0] == "(": + # str representation of int tuple to tuple of int + current = tuple(int(i) for i in current.strip("()").split(",")) + return self.plot[current] + else: + return self.plot[current] + else: + return self.plot + + def panzoom_handler(self, ev): + raise NotImplemented + + def maintain_aspect_handler(self, ev): + raise NotImplemented + + def y_direction_handler(self, ev): + raise NotImplemented + + def auto_scale_handler(self, ev): + raise NotImplemented + + def center_scene_handler(self, ev): + raise NotImplemented + + def record_handler(self, ev): + raise NotImplemented + + def add_polygon(self, ev): + raise NotImplemented diff --git a/fastplotlib/layouts/_frame/qtoolbar.ui b/fastplotlib/layouts/_frame/qtoolbar.ui new file mode 100644 index 000000000..6c9aadae8 --- /dev/null +++ b/fastplotlib/layouts/_frame/qtoolbar.ui @@ -0,0 +1,89 @@ + + + QToolbar + + + + 0 + 0 + 638 + 48 + + + + Form + + + + + + + + autoscale + + + + + + + center + + + + + + + panzoom + + + true + + + + + + + + 75 + true + + + + 1:1 + + + true + + + + + + + ^ + + + + + + + polygon + + + + + + + record + + + true + + + + + + + + + + diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index b3f30d3f9..473196f78 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -1,26 +1,19 @@ -import traceback -from datetime import datetime from itertools import product import numpy as np from typing import * from inspect import getfullargspec from warnings import warn -import os import pygfx -from wgpu.gui.auto import WgpuCanvas, is_jupyter - -if is_jupyter(): - from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Dropdown, Widget - from sidecar import Sidecar - from IPython.display import display +from wgpu.gui.auto import WgpuCanvas +from ._frame import Frame from ._utils import make_canvas_and_renderer from ._defaults import create_controller from ._subplot import Subplot from ._record_mixin import RecordMixin -from ..graphics.selectors import PolygonSelector + def to_array(a) -> np.ndarray: if isinstance(a, np.ndarray): @@ -35,7 +28,7 @@ def to_array(a) -> np.ndarray: valid_cameras = ["2d", "2d-big", "3d", "3d-big"] -class GridPlot(RecordMixin): +class GridPlot(Frame, RecordMixin): def __init__( self, shape: Tuple[int, int], @@ -82,10 +75,6 @@ def __init__( """ self.shape = shape - self.toolbar = None - self.sidecar = None - self.vbox = None - self.plot_open = False canvas, renderer = make_canvas_and_renderer(canvas, renderer) @@ -196,6 +185,7 @@ def __init__( self._starting_size = size RecordMixin.__init__(self) + Frame.__init__(self) @property def canvas(self) -> WgpuCanvas: @@ -298,121 +288,6 @@ def remove_animation(self, func): if func in self._animate_funcs_post: self._animate_funcs_post.remove(func) - def show( - self, - autoscale: bool = True, - maintain_aspect: bool = None, - toolbar: bool = True, - sidecar: bool = True, - sidecar_kwargs: dict = None, - vbox: list = None - ): - """ - Begins the rendering event loop and returns the canvas - - Parameters - ---------- - autoscale: bool, default ``True`` - autoscale the Scene - - maintain_aspect: bool, default ``True`` - maintain aspect ratio - - toolbar: bool, default ``True`` - show toolbar - - sidecar: bool, default ``True`` - display plot in a ``jupyterlab-sidecar`` - - sidecar_kwargs: dict, default ``None`` - kwargs for sidecar instance to display plot - i.e. title, layout - - vbox: list, default ``None`` - list of ipywidgets to be displayed with plot - - Returns - ------- - WgpuCanvas - the canvas - - """ - - self.canvas.request_draw(self.render) - - self.canvas.set_logical_size(*self._starting_size) - - if autoscale: - for subplot in self: - if maintain_aspect is None: - _maintain_aspect = subplot.camera.maintain_aspect - else: - _maintain_aspect = maintain_aspect - subplot.auto_scale(maintain_aspect=_maintain_aspect, zoom=0.95) - - if "NB_SNAPSHOT" in os.environ.keys(): - # used for docs - if os.environ["NB_SNAPSHOT"] == "1": - return self.canvas.snapshot() - - # check if in jupyter notebook, or if toolbar is False - if (self.canvas.__class__.__name__ != "JupyterWgpuCanvas") or (not toolbar): - return self.canvas - - if self.toolbar is None: - self.toolbar = GridPlotToolBar(self) - self.toolbar.maintain_aspect_button.value = self[ - 0, 0 - ].camera.maintain_aspect - - # validate vbox if not None - if vbox is not None: - for widget in vbox: - if not isinstance(widget, Widget): - raise ValueError(f"Items in vbox must be ipywidgets. Item: {widget} is of type: {type(widget)}") - self.vbox = VBox(vbox) - - if not sidecar: - if self.vbox is not None: - return VBox([self.canvas, self.toolbar.widget, self.vbox]) - else: - return VBox([self.canvas, self.toolbar.widget]) - - # used when plot.show() is being called again but sidecar has been closed via "x" button - # need to force new sidecar instance - # couldn't figure out how to get access to "close" button in order to add observe method on click - if self.plot_open: - self.sidecar = None - - if self.sidecar is None: - if sidecar_kwargs is not None: - self.sidecar = Sidecar(**sidecar_kwargs) - self.plot_open = True - else: - self.sidecar = Sidecar() - self.plot_open = True - - with self.sidecar: - if self.vbox is not None: - return display(VBox([self.canvas, self.toolbar.widget, self.vbox])) - else: - return display(VBox([self.canvas, self.toolbar.widget])) - - def close(self): - """Close the GridPlot""" - self.canvas.close() - - if self.toolbar is not None: - self.toolbar.widget.close() - - if self.sidecar is not None: - self.sidecar.close() - - if self.vbox is not None: - self.vbox.close() - - self.plot_open = False - def clear(self): """Clear all Subplots""" for subplot in self: @@ -431,167 +306,3 @@ def __next__(self) -> Subplot: def __repr__(self): return f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}\n" - - -class GridPlotToolBar: - def __init__(self, plot: GridPlot): - """ - Basic toolbar for a GridPlot instance. - - Parameters - ---------- - plot: - """ - self.plot = plot - - self.autoscale_button = Button( - value=False, - disabled=False, - icon="expand-arrows-alt", - layout=Layout(width="auto"), - tooltip="auto-scale scene", - ) - self.center_scene_button = Button( - value=False, - disabled=False, - icon="align-center", - layout=Layout(width="auto"), - tooltip="auto-center scene", - ) - self.panzoom_controller_button = ToggleButton( - value=True, - disabled=False, - icon="hand-pointer", - layout=Layout(width="auto"), - tooltip="panzoom controller", - ) - self.maintain_aspect_button = ToggleButton( - value=True, - disabled=False, - description="1:1", - layout=Layout(width="auto"), - tooltip="maintain aspect", - ) - self.maintain_aspect_button.style.font_weight = "bold" - self.flip_camera_button = Button( - value=False, - disabled=False, - icon="arrow-up", - layout=Layout(width="auto"), - tooltip="y-axis direction", - ) - - self.add_polygon_button = Button( - value=False, - disabled=False, - icon="draw-polygon", - layout=Layout(width="auto"), - tooltip="add PolygonSelector" - ) - - self.record_button = ToggleButton( - value=False, - disabled=False, - icon="video", - layout=Layout(width="auto"), - tooltip="record", - ) - - positions = list(product(range(self.plot.shape[0]), range(self.plot.shape[1]))) - values = list() - for pos in positions: - if self.plot[pos].name is not None: - values.append(self.plot[pos].name) - else: - values.append(str(pos)) - self.dropdown = Dropdown( - options=values, - disabled=False, - description="Subplots:", - layout=Layout(width="200px"), - ) - - self.widget = HBox( - [ - self.autoscale_button, - self.center_scene_button, - self.panzoom_controller_button, - self.maintain_aspect_button, - self.flip_camera_button, - self.add_polygon_button, - self.record_button, - self.dropdown, - ] - ) - - self.panzoom_controller_button.observe(self.panzoom_control, "value") - self.autoscale_button.on_click(self.auto_scale) - self.center_scene_button.on_click(self.center_scene) - self.maintain_aspect_button.observe(self.maintain_aspect, "value") - self.flip_camera_button.on_click(self.flip_camera) - self.add_polygon_button.on_click(self.add_polygon) - self.record_button.observe(self.record_plot, "value") - - self.plot.renderer.add_event_handler(self.update_current_subplot, "click") - - @property - def current_subplot(self) -> Subplot: - # parses dropdown value as plot name or position - current = self.dropdown.value - if current[0] == "(": - return self.plot[eval(current)] - else: - return self.plot[current] - - def auto_scale(self, obj): - current = self.current_subplot - current.auto_scale(maintain_aspect=current.camera.maintain_aspect) - - def center_scene(self, obj): - current = self.current_subplot - current.center_scene() - - def panzoom_control(self, obj): - current = self.current_subplot - current.controller.enabled = self.panzoom_controller_button.value - - def maintain_aspect(self, obj): - current = self.current_subplot - current.camera.maintain_aspect = self.maintain_aspect_button.value - - def flip_camera(self, obj): - current = self.current_subplot - current.camera.local.scale_y *= -1 - if current.camera.local.scale_y == -1: - self.flip_camera_button.icon = "arrow-down" - else: - self.flip_camera_button.icon = "arrow-up" - - def update_current_subplot(self, ev): - for subplot in self.plot: - pos = subplot.map_screen_to_world((ev.x, ev.y)) - if pos is not None: - # update self.dropdown - if subplot.name is None: - self.dropdown.value = str(subplot.position) - else: - self.dropdown.value = subplot.name - self.panzoom_controller_button.value = subplot.controller.enabled - self.maintain_aspect_button.value = subplot.camera.maintain_aspect - - def record_plot(self, obj): - if self.record_button.value: - try: - self.plot.record_start( - f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" - ) - except Exception: - traceback.print_exc() - self.record_button.value = False - else: - self.plot.record_stop() - - def add_polygon(self, obj): - ps = PolygonSelector(edge_width=3, edge_color="magenta") - - self.current_subplot.add_graphic(ps, center=False) diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 253b6296b..5aa04bb76 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -1,22 +1,14 @@ from typing import * -from datetime import datetime -import traceback -import os import pygfx -from wgpu.gui.auto import WgpuCanvas, is_jupyter - -if is_jupyter(): - from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Widget - from sidecar import Sidecar - from IPython.display import display +from wgpu.gui.auto import WgpuCanvas from ._subplot import Subplot +from ._frame import Frame from ._record_mixin import RecordMixin -from ..graphics.selectors import PolygonSelector -class Plot(Subplot, RecordMixin): +class Plot(Subplot, Frame, RecordMixin): def __init__( self, canvas: WgpuCanvas = None, @@ -62,248 +54,13 @@ def __init__( **kwargs, ) RecordMixin.__init__(self) + Frame.__init__(self) self._starting_size = size - self.toolbar = None - self.sidecar = None - self.vbox = None - self.plot_open = False - def render(self): + """performs a single render of the plot, not for the user""" super(Plot, self).render() self.renderer.flush() self.canvas.request_draw() - - def show( - self, - autoscale: bool = True, - maintain_aspect: bool = None, - toolbar: bool = True, - sidecar: bool = True, - sidecar_kwargs: dict = None, - vbox: list = None - ): - """ - Begins the rendering event loop and returns the canvas - - Parameters - ---------- - autoscale: bool, default ``True`` - autoscale the Scene - - maintain_aspect: bool, default ``None`` - maintain aspect ratio, uses ``camera.maintain_aspect`` if ``None`` - - toolbar: bool, default ``True`` - show toolbar - - sidecar: bool, default ``True`` - display the plot in a ``jupyterlab-sidecar`` - - sidecar_kwargs: dict, default ``None`` - kwargs for sidecar instance to display plot - i.e. title, layout - - vbox: list, default ``None`` - list of ipywidgets to be displayed with plot - - Returns - ------- - WgpuCanvas - the canvas - - """ - - self.canvas.request_draw(self.render) - - self.canvas.set_logical_size(*self._starting_size) - - if maintain_aspect is None: - maintain_aspect = self.camera.maintain_aspect - - if autoscale: - self.auto_scale(maintain_aspect=maintain_aspect, zoom=0.95) - - if "NB_SNAPSHOT" in os.environ.keys(): - # used for docs - if os.environ["NB_SNAPSHOT"] == "1": - return self.canvas.snapshot() - - # check if in jupyter notebook, or if toolbar is False - if (self.canvas.__class__.__name__ != "JupyterWgpuCanvas") or (not toolbar): - return self.canvas - - if self.toolbar is None: - self.toolbar = ToolBar(self) - self.toolbar.maintain_aspect_button.value = maintain_aspect - - # validate vbox if not None - if vbox is not None: - for widget in vbox: - if not isinstance(widget, Widget): - raise ValueError(f"Items in vbox must be ipywidgets. Item: {widget} is of type: {type(widget)}") - self.vbox = VBox(vbox) - - if not sidecar: - if self.vbox is not None: - return VBox([self.canvas, self.toolbar.widget, self.vbox]) - else: - return VBox([self.canvas, self.toolbar.widget]) - - # used when plot.show() is being called again but sidecar has been closed via "x" button - # need to force new sidecar instance - # couldn't figure out how to get access to "close" button in order to add observe method on click - if self.plot_open: - self.sidecar = None - - if self.sidecar is None: - if sidecar_kwargs is not None: - self.sidecar = Sidecar(**sidecar_kwargs) - self.plot_open = True - else: - self.sidecar = Sidecar() - self.plot_open = True - - with self.sidecar: - if self.vbox is not None: - return display(VBox([self.canvas, self.toolbar.widget, self.vbox])) - else: - return display(VBox([self.canvas, self.toolbar.widget])) - - def close(self): - """Close Plot""" - self.canvas.close() - - if self.toolbar is not None: - self.toolbar.widget.close() - - if self.sidecar is not None: - self.sidecar.close() - - if self.vbox is not None: - self.vbox.close() - - self.plot_open = False - - -class ToolBar: - def __init__(self, plot: Plot): - """ - Basic toolbar for a Plot instance. - - Parameters - ---------- - plot: encapsulated plot instance that will be manipulated using the toolbar buttons - """ - self.plot = plot - - self.autoscale_button = Button( - value=False, - disabled=False, - icon="expand-arrows-alt", - layout=Layout(width="auto"), - tooltip="auto-scale scene", - ) - self.center_scene_button = Button( - value=False, - disabled=False, - icon="align-center", - layout=Layout(width="auto"), - tooltip="auto-center scene", - ) - self.panzoom_controller_button = ToggleButton( - value=True, - disabled=False, - icon="hand-pointer", - layout=Layout(width="auto"), - tooltip="panzoom controller", - ) - self.maintain_aspect_button = ToggleButton( - value=True, - disabled=False, - description="1:1", - layout=Layout(width="auto"), - tooltip="maintain aspect", - ) - self.maintain_aspect_button.style.font_weight = "bold" - self.flip_camera_button = Button( - value=False, - disabled=False, - icon="arrow-up", - layout=Layout(width="auto"), - tooltip="flip", - ) - - self.add_polygon_button = Button( - value=False, - disabled=False, - icon="draw-polygon", - layout=Layout(width="auto"), - tooltip="add PolygonSelector" - ) - - self.record_button = ToggleButton( - value=False, - disabled=False, - icon="video", - layout=Layout(width="auto"), - tooltip="record", - ) - - self.widget = HBox( - [ - self.autoscale_button, - self.center_scene_button, - self.panzoom_controller_button, - self.maintain_aspect_button, - self.flip_camera_button, - self.add_polygon_button, - self.record_button, - ] - ) - - self.panzoom_controller_button.observe(self.panzoom_control, "value") - self.autoscale_button.on_click(self.auto_scale) - self.center_scene_button.on_click(self.center_scene) - self.maintain_aspect_button.observe(self.maintain_aspect, "value") - self.flip_camera_button.on_click(self.flip_camera) - self.add_polygon_button.on_click(self.add_polygon) - self.record_button.observe(self.record_plot, "value") - - def auto_scale(self, obj): - self.plot.auto_scale(maintain_aspect=self.plot.camera.maintain_aspect) - - def center_scene(self, obj): - self.plot.center_scene() - - def panzoom_control(self, obj): - self.plot.controller.enabled = self.panzoom_controller_button.value - - def maintain_aspect(self, obj): - self.plot.camera.maintain_aspect = self.maintain_aspect_button.value - - def flip_camera(self, obj): - self.plot.camera.local.scale_y *= -1 - if self.plot.camera.local.scale_y == -1: - self.flip_camera_button.icon = "arrow-down" - else: - self.flip_camera_button.icon = "arrow-up" - - def add_polygon(self, obj): - ps = PolygonSelector(edge_width=3, edge_color="magenta") - - self.plot.add_graphic(ps, center=False) - - def record_plot(self, obj): - if self.record_button.value: - try: - self.plot.record_start( - f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" - ) - except Exception: - traceback.print_exc() - self.record_button.value = False - else: - self.plot.record_stop() diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_plot_area.py similarity index 96% rename from fastplotlib/layouts/_base.py rename to fastplotlib/layouts/_plot_area.py index 3cfdbbd41..2060850c2 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_plot_area.py @@ -472,11 +472,14 @@ def center_scene(self, zoom: float = 1.35): if not len(self.scene.children) > 0: return - self.camera.show_object(self.scene) + # scale all cameras associated with this controller + # else it looks wonky + for camera in self.controller.cameras: + camera.show_object(self.scene) - # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate - # probably because camera.show_object uses bounding sphere - self.camera.zoom = zoom + # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate + # probably because camera.show_object uses bounding sphere + camera.zoom = zoom def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): """ @@ -500,7 +503,10 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): self.center_scene() if not isinstance(maintain_aspect, bool): maintain_aspect = False # assume False - self.camera.maintain_aspect = maintain_aspect + + # scale all cameras associated with this controller else it looks wonky + for camera in self.controller.cameras: + camera.maintain_aspect = maintain_aspect if len(self.scene.children) > 0: width, height, depth = np.ptp(self.scene.get_world_bounding_box(), axis=0) @@ -516,10 +522,12 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): for selector in self.selectors: self.scene.add(selector.world_object) - self.camera.width = width - self.camera.height = height + # scale all cameras associated with this controller else it looks wonky + for camera in self.controller.cameras: + camera.width = width + camera.height = height - self.camera.zoom = zoom + camera.zoom = zoom def remove_graphic(self, graphic: Graphic): """ diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 793109bb5..c178c0fca 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -16,7 +16,7 @@ from ..graphics import TextGraphic from ._utils import make_canvas_and_renderer -from ._base import PlotArea +from ._plot_area import PlotArea from ._defaults import create_camera, create_controller from .graphic_methods_mixin import GraphicMethodsMixin diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index ebfe9e306..dd6fbeb50 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -14,6 +14,7 @@ JupyterWgpuCanvas = False try: + import PyQt6 from wgpu.gui.qt import QWgpuCanvas except ImportError: QWgpuCanvas = False @@ -32,6 +33,29 @@ } +def auto_determine_canvas(): + try: + ip = get_ipython() + if ip.has_trait("kernel"): + if hasattr(ip.kernel, "app"): + if ip.kernel.app.__class__.__name__ == "QApplication": + return QWgpuCanvas + else: + return JupyterWgpuCanvas + except NameError: + pass + + else: + if CANVAS_OPTIONS_AVAILABLE["qt"]: + return QWgpuCanvas + elif CANVAS_OPTIONS_AVAILABLE["glfw"]: + return GlfwWgpuCanvas + + # We go with the wgpu auto guess + # for example, offscreen canvas etc. + return WgpuCanvas + + def make_canvas_and_renderer( canvas: Union[str, WgpuCanvas, Texture, None], renderer: [WgpuRenderer, None] ): @@ -41,7 +65,8 @@ def make_canvas_and_renderer( """ if canvas is None: - canvas = WgpuCanvas() + Canvas = auto_determine_canvas() + canvas = Canvas() elif isinstance(canvas, str): if canvas not in CANVAS_OPTIONS: diff --git a/fastplotlib/widgets/histogram_lut.py b/fastplotlib/widgets/histogram_lut.py index c0c8f5596..0d8ca9f15 100644 --- a/fastplotlib/widgets/histogram_lut.py +++ b/fastplotlib/widgets/histogram_lut.py @@ -129,6 +129,8 @@ def _add_plot_area_hook(self, plot_area): self.linear_region._add_plot_area_hook(plot_area) self.line._add_plot_area_hook(plot_area) + self._plot_area.auto_scale() + def _calculate_histogram(self, data): if data.ndim > 2: # subsample to max of 500 x 100 x 100, @@ -262,6 +264,9 @@ def set_data(self, data, reset_vmin_vmax: bool = True): self._data = weakref.proxy(data) + # reset plotarea dims + self._plot_area.auto_scale() + @property def image_graphic(self) -> ImageGraphic: return self._image_graphic diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 2da413ac0..a9ebfafb4 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,26 +1,21 @@ from typing import * from warnings import warn -from functools import partial import numpy as np -from ipywidgets.widgets import ( - IntSlider, - VBox, - HBox, - Layout, - Button, - BoundedIntText, - Play, - jslink, -) -from sidecar import Sidecar -from IPython.display import display from ..layouts import GridPlot from ..graphics import ImageGraphic -from ..utils import quick_min_max, calculate_gridshape +from ..utils import calculate_gridshape from .histogram_lut import HistogramLUT +from ..layouts._utils import CANVAS_OPTIONS_AVAILABLE + + +if CANVAS_OPTIONS_AVAILABLE["jupyter"]: + from ..layouts._frame._ipywidget_toolbar import IpywidgetImageWidgetToolbar + +if CANVAS_OPTIONS_AVAILABLE["qt"]: + from ..layouts._frame._qt_toolbar import QToolbarImageWidget DEFAULT_DIMS_ORDER = { @@ -101,6 +96,13 @@ def gridplot(self) -> GridPlot: """ return self._gridplot + @property + def widget(self): + """ + Output context, either an ipywidget or QWidget + """ + return self.gridplot.widget + @property def managed_graphics(self) -> List[ImageGraphic]: """List of ``ImageWidget`` managed graphics.""" @@ -155,9 +157,9 @@ def dims_order(self) -> List[str]: return self._dims_order @property - def sliders(self) -> Dict[str, IntSlider]: - """the slider instances used by the widget for indexing the desired dimensions""" - return self._sliders + def sliders(self) -> Dict[str, Any]: + """the ipywidget IntSlider or QSlider instances used by the widget for indexing the desired dimensions""" + return self._image_widget_toolbar.sliders @property def slider_dims(self) -> List[str]: @@ -292,9 +294,6 @@ def __init__( """ self._names = None - self.toolbar = None - self.sidecar = None - self.plot_open = False if isinstance(data, list): # verify that it's a list of np.ndarray @@ -521,7 +520,7 @@ def __init__( self._window_funcs = None self.window_funcs = window_funcs - self._sliders: Dict[str, IntSlider] = dict() + self._sliders: Dict[str, Any] = dict() # current_index stores {dimension_index: slice_index} for every dimension self._current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims} @@ -534,10 +533,15 @@ def __init__( self._dims_max_bounds[_dim], array.shape[order.index(_dim)] ) + grid_plot_kwargs_default = {"controllers": "sync"} if grid_plot_kwargs is None: - grid_plot_kwargs = {"controllers": "sync"} + grid_plot_kwargs = dict() - self._gridplot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs) + # update the default kwargs with any user-specified kwargs + # user specified kwargs will overwrite the defaults + grid_plot_kwargs_default.update(grid_plot_kwargs) + + self._gridplot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs_default) for data_ix, (d, subplot) in enumerate(zip(self.data, self.gridplot)): if self._names is not None: @@ -563,30 +567,8 @@ def __init__( subplot.docks["right"].auto_scale(maintain_aspect=False) subplot.docks["right"].controller.enabled = False - self.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize") - - for sdm in self.slider_dims: - slider = IntSlider( - min=0, - max=self._dims_max_bounds[sdm] - 1, - step=1, - value=0, - description=f"dimension: {sdm}", - orientation="horizontal", - ) - - slider.observe(partial(self._slider_value_changed, sdm), names="value") - - self._sliders[sdm] = slider - - # will change later - # prevent the slider callback if value is self.current_index is changed programmatically - self.block_sliders: bool = False - - # TODO: So just stack everything vertically for now - self._vbox_sliders = VBox( - [*list(self._sliders.values())] - ) + self.block_sliders = False + self._image_widget_toolbar = None @property def window_funcs(self) -> Dict[str, _WindowFunctions]: @@ -774,23 +756,34 @@ def _process_frame_apply(self, array, data_ix) -> np.ndarray: return array - def _slider_value_changed(self, dimension: str, change: dict): + def _slider_value_changed(self, dimension: str, change: Union[dict, int]): if self.block_sliders: return - self.current_index = {dimension: change["new"]} - - def _set_slider_layout(self, *args): - w, h = self.gridplot.renderer.logical_size - for k, v in self.sliders.items(): - v.layout = Layout(width=f"{w}px") + if isinstance(change, dict): + value = change["new"] + else: + value = change + self.current_index = {dimension: value} def reset_vmin_vmax(self): """ - Reset the vmin and vmax w.r.t. the currently displayed image(s) + Reset the vmin and vmax w.r.t. the full data """ for ig in self.managed_graphics: ig.cmap.reset_vmin_vmax() + def reset_vmin_vmax_frame(self): + """ + Resets the vmin vmax and HistogramLUT widgets w.r.t. the current data shown in the + ImageGraphic instead of the data in the full data array. For example, if a post-processing + function is used, the range of values in the ImageGraphic can be very different from the + range of values in the full data array. + """ + for subplot in self.gridplot: + hlut = subplot.docks["right"]["histogram_lut"] + # set the data using the current image graphic data + hlut.set_data(subplot["image_widget_managed"].data()) + def set_data( self, new_data: Union[np.ndarray, List[np.ndarray]], @@ -879,141 +872,27 @@ def set_data( # if reset_vmin_vmax: # self.reset_vmin_vmax() - def show(self, toolbar: bool = True, sidecar: bool = True, sidecar_kwargs: dict = None): + def show(self, toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None): """ Show the widget Returns ------- - VBox - ``ipywidgets.VBox`` stacking the plotter and sliders in a vertical layout + OutputContext """ + if self.gridplot.canvas.__class__.__name__ == "JupyterWgpuCanvas": + self._image_widget_toolbar = IpywidgetImageWidgetToolbar(self) - # don't need to check for jupyter since ImageWidget is only supported within jupyter anyways - if not toolbar: - return VBox([self.gridplot.show(toolbar=False), self._vbox_sliders]) - - if self.toolbar is None: - self.toolbar = ImageWidgetToolbar(self) - - if not sidecar: - return VBox( - [ - self.gridplot.show(toolbar=True, sidecar=False, sidecar_kwargs=None), - self.toolbar.widget, - self._vbox_sliders, - ] - ) - - if self.plot_open: - self.sidecar = None + elif self.gridplot.canvas.__class__.__name__ == "QWgpuCanvas": + self._image_widget_toolbar = QToolbarImageWidget(self) - if self.sidecar is None: - if sidecar_kwargs is not None: - self.sidecar = Sidecar(**sidecar_kwargs) - self.plot_open = True - else: - self.sidecar = Sidecar() - self.plot_open = True - - with self.sidecar: - return display(VBox( - [ - self.gridplot.show(toolbar=True, sidecar=False, sidecar_kwargs=None), - self.toolbar.widget, - self._vbox_sliders - ] - ) - ) + return self.gridplot.show( + toolbar=toolbar, + sidecar=sidecar, + sidecar_kwargs=sidecar_kwargs, + add_widgets=[self._image_widget_toolbar] + ) def close(self): """Close Widget""" - self.gridplot.canvas.close() - - self._vbox_sliders.close() - - if self.toolbar is not None: - self.toolbar.widget.close() - self.gridplot.toolbar.widget.close() - - if self.sidecar is not None: - self.sidecar.close() - - self.plot_open = False - - -class ImageWidgetToolbar: - def __init__(self, iw: ImageWidget): - """ - Basic toolbar for a ImageWidget instance. - - Parameters - ---------- - plot: - """ - self.iw = iw - self.plot = iw.gridplot - - self.reset_vminvmax_button = Button( - value=False, - disabled=False, - icon="adjust", - layout=Layout(width="auto"), - tooltip="reset vmin/vmax", - ) - - # only for xy data, no time point slider needed - if self.iw.ndim == 2: - self.widget = HBox([self.reset_vminvmax_button]) - # for txy, tzxy, etc. data - else: - self.step_size_setter = BoundedIntText( - value=1, - min=1, - max=self.iw.sliders["t"].max, - step=1, - description="Step Size:", - disabled=False, - description_tooltip="set slider step", - layout=Layout(width="150px"), - ) - self.speed_text = BoundedIntText( - value=100, - min=1, - max=1_000, - step=50, - description="Speed", - disabled=False, - description_tooltip="Playback speed, this is NOT framerate.\nArbitrary units between 1 - 1,000", - layout=Layout(width="150px"), - ) - self.play_button = Play( - value=0, - min=iw.sliders["t"].min, - max=iw.sliders["t"].max, - step=iw.sliders["t"].step, - description="play/pause", - disabled=False, - ) - self.widget = HBox( - [self.reset_vminvmax_button, self.play_button, self.step_size_setter, self.speed_text] - ) - - self.play_button.interval = 10 - - self.step_size_setter.observe(self._change_stepsize, "value") - self.speed_text.observe(self._change_framerate, "value") - jslink((self.play_button, "value"), (self.iw.sliders["t"], "value")) - jslink((self.play_button, "max"), (self.iw.sliders["t"], "max")) - - self.reset_vminvmax_button.on_click(self._reset_vminvmax) - - def _reset_vminvmax(self, obj): - self.iw.reset_vmin_vmax() - - def _change_stepsize(self, obj): - self.iw.sliders["t"].step = self.step_size_setter.value - - def _change_framerate(self, change): - interval = int(1000 / change["new"]) - self.play_button.interval = interval + self.gridplot.close()