From 88f33c3459cb98778ccccb58fe0738071de78d30 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 3 Oct 2023 14:12:55 -0400 Subject: [PATCH 01/22] WIP on plot frame --- fastplotlib/layouts/_frame/__init__.py | 0 fastplotlib/layouts/_frame/_frame_base.py | 89 ++++++++++ fastplotlib/layouts/_frame/_frame_desktop.py | 0 fastplotlib/layouts/_frame/_frame_notebook.py | 103 ++++++++++++ fastplotlib/layouts/_frame/_toolbar.py | 156 ++++++++++++++++++ .../layouts/{_base.py => _plot_area.py} | 0 fastplotlib/layouts/_subplot.py | 2 +- 7 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 fastplotlib/layouts/_frame/__init__.py create mode 100644 fastplotlib/layouts/_frame/_frame_base.py create mode 100644 fastplotlib/layouts/_frame/_frame_desktop.py create mode 100644 fastplotlib/layouts/_frame/_frame_notebook.py create mode 100644 fastplotlib/layouts/_frame/_toolbar.py rename fastplotlib/layouts/{_base.py => _plot_area.py} (100%) diff --git a/fastplotlib/layouts/_frame/__init__.py b/fastplotlib/layouts/_frame/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/layouts/_frame/_frame_base.py b/fastplotlib/layouts/_frame/_frame_base.py new file mode 100644 index 000000000..969ab4a7b --- /dev/null +++ b/fastplotlib/layouts/_frame/_frame_base.py @@ -0,0 +1,89 @@ +import numpy as np + +from .._subplot import Subplot + +class BaseFrame: + """Mixin class for Plot and GridPlot that gives them the toolbar""" + def __init__(self, canvas, toolbar): + """ + + Parameters + ---------- + plot: + `Plot` or `GridPlot` + toolbar + """ + self._canvas = canvas + self._toolbar = toolbar + + # default points upwards + self._y_axis: int = 1 + + self._plot_type = self.__class__.__name__ + + @property + def selected_subplot(self) -> Subplot: + if self._plot_type == "GridPlot": + return self.toolbar.selected_subplot + else: + return self + + @property + def toolbar(self): + return self._toolbar + + @property + def panzoom(self) -> bool: + return self.selected_subplot.controller.enabled + + @property + def maintain_aspect(self) -> bool: + return self.selected_subplot.camera.maintain_aspect + + @property + def y_axis(self) -> int: + return int(np.sign(self.selected_subplot.camera.local.scale_y)) + + @y_axis.setter + def y_axis(self, value: int): + """ + + Parameters + ---------- + value: 1 or -1 + 1: points upwards, -1: points downwards + + """ + value = int(value) # in case we had a float 1.0 + + if value not in [1, -1]: + raise ValueError("y_axis value must be 1 or -1") + + sign = np.sign(self.selected_subplot.camera.local.scale_y) + + if sign == value: + # desired y-axis is already set + return + + # otherwise flip it + self.selected_subplot.camera.local.scale_y *= -1 + + def render(self): + raise NotImplemented + + def _autoscale_init(self, maintain_aspect: bool): + """autoscale function that is called only during show()""" + if self._plot_type == "GridPlot": + 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 show(self): + raise NotImplemented("Must be implemented in subclass") diff --git a/fastplotlib/layouts/_frame/_frame_desktop.py b/fastplotlib/layouts/_frame/_frame_desktop.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/layouts/_frame/_frame_notebook.py b/fastplotlib/layouts/_frame/_frame_notebook.py new file mode 100644 index 000000000..99422e72d --- /dev/null +++ b/fastplotlib/layouts/_frame/_frame_notebook.py @@ -0,0 +1,103 @@ +import os + + +from ._frame_base import BaseFrame +from ._toolbar import ToolBar + + +class FrameNotebook(BaseFrame): + 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: + self._autoscale_init(maintain_aspect) + + 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 = 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])) + diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/_frame/_toolbar.py new file mode 100644 index 000000000..654ac57b0 --- /dev/null +++ b/fastplotlib/layouts/_frame/_toolbar.py @@ -0,0 +1,156 @@ +from datetime import datetime +from itertools import product +import traceback + +from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Dropdown, Widget + +from fastplotlib.layouts._subplot import Subplot + + +class ToolBar: + def __init__(self, plot): + """ + 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.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.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.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() diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_plot_area.py similarity index 100% rename from fastplotlib/layouts/_base.py rename to fastplotlib/layouts/_plot_area.py 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 From 34ff9a27fb99bed66caa3631a06f8d303adabde3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 19:01:49 -0400 Subject: [PATCH 02/22] create Output context classes, basic layout of Frame, not tested yet --- fastplotlib/layouts/_frame/__init__.py | 1 + fastplotlib/layouts/_frame/_frame.py | 123 +++++++ fastplotlib/layouts/_frame/_frame_base.py | 89 ------ fastplotlib/layouts/_frame/_frame_desktop.py | 0 fastplotlib/layouts/_frame/_frame_notebook.py | 103 ------ .../layouts/_frame/_ipywidget_toolbar.py | 174 ++++++++++ fastplotlib/layouts/_frame/_jupyter_output.py | 46 +++ fastplotlib/layouts/_frame/_qt_output.py | 4 + fastplotlib/layouts/_frame/_qt_toolbar.py | 44 +++ fastplotlib/layouts/_frame/_toolbar.py | 164 ++-------- fastplotlib/layouts/_gridplot.py | 299 +----------------- fastplotlib/layouts/_plot.py | 251 +-------------- fastplotlib/layouts/_plot_area.py | 24 +- 13 files changed, 443 insertions(+), 879 deletions(-) create mode 100644 fastplotlib/layouts/_frame/_frame.py delete mode 100644 fastplotlib/layouts/_frame/_frame_base.py delete mode 100644 fastplotlib/layouts/_frame/_frame_desktop.py delete mode 100644 fastplotlib/layouts/_frame/_frame_notebook.py create mode 100644 fastplotlib/layouts/_frame/_ipywidget_toolbar.py create mode 100644 fastplotlib/layouts/_frame/_jupyter_output.py create mode 100644 fastplotlib/layouts/_frame/_qt_output.py create mode 100644 fastplotlib/layouts/_frame/_qt_toolbar.py diff --git a/fastplotlib/layouts/_frame/__init__.py b/fastplotlib/layouts/_frame/__init__.py index e69de29bb..708efdff0 100644 --- a/fastplotlib/layouts/_frame/__init__.py +++ b/fastplotlib/layouts/_frame/__init__.py @@ -0,0 +1 @@ +from ._frame import Frame \ No newline at end of file diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py new file mode 100644 index 000000000..82bf2d5b0 --- /dev/null +++ b/fastplotlib/layouts/_frame/_frame.py @@ -0,0 +1,123 @@ +import os + +from ._toolbar import ToolBar + +from .._utils import CANVAS_OPTIONS_AVAILABLE + +class UnavailableOutputContext: + def __init__(self, *arg, **kwargs): + raise ModuleNotFoundError("Unavailable output context") + + +if CANVAS_OPTIONS_AVAILABLE["jupyter"]: + from ._jupyter_output import JupyterOutput +else: + JupyterOutput = UnavailableOutputContext + +if CANVAS_OPTIONS_AVAILABLE["qt"]: + from ._qt_output import QtOutput +else: + JupyterOutput = UnavailableOutputContext + + +# Single class for PlotFrame to avoid determining inheritance at runtime +class Frame: + """Mixin class for Plot and GridPlot that gives them the toolbar""" + def __init__(self): + """ + + Parameters + ---------- + plot: + `Plot` or `GridPlot` + toolbar + """ + self._plot_type = self.__class__.__name__ + self._output = None + + @property + def toolbar(self) -> ToolBar: + return self._output.toolbar + + def render(self): + 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 show( + self, + autoscale: bool = True, + maintain_aspect: bool = None, + toolbar: bool = True, + sidecar: bool = False, + sidecar_kwargs: dict = 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 + + Returns + ------- + WgpuCanvas + the canvas + + """ + if self._output is not None: + return self._output + + self.canvas.request_draw(self.render) + self.canvas.set_logical_size(*self._starting_size) + + if autoscale: + self._autoscale_init(maintain_aspect) + + if "NB_SNAPSHOT" in os.environ.keys(): + # used for docs + if os.environ["NB_SNAPSHOT"] == "1": + return self.canvas.snapshot() + + if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": + return JupyterOutput( + frame=self, + make_toolbar=toolbar, + use_sidecar=sidecar, + sidecar_kwargs=sidecar_kwargs + ) + + elif self.canvas.__class__.__name__ == "QWgpuCanvas": + QtOutput( + frame=self, + make_toolbar=toolbar, + ) + + def close(self): + self._output.close() diff --git a/fastplotlib/layouts/_frame/_frame_base.py b/fastplotlib/layouts/_frame/_frame_base.py deleted file mode 100644 index 969ab4a7b..000000000 --- a/fastplotlib/layouts/_frame/_frame_base.py +++ /dev/null @@ -1,89 +0,0 @@ -import numpy as np - -from .._subplot import Subplot - -class BaseFrame: - """Mixin class for Plot and GridPlot that gives them the toolbar""" - def __init__(self, canvas, toolbar): - """ - - Parameters - ---------- - plot: - `Plot` or `GridPlot` - toolbar - """ - self._canvas = canvas - self._toolbar = toolbar - - # default points upwards - self._y_axis: int = 1 - - self._plot_type = self.__class__.__name__ - - @property - def selected_subplot(self) -> Subplot: - if self._plot_type == "GridPlot": - return self.toolbar.selected_subplot - else: - return self - - @property - def toolbar(self): - return self._toolbar - - @property - def panzoom(self) -> bool: - return self.selected_subplot.controller.enabled - - @property - def maintain_aspect(self) -> bool: - return self.selected_subplot.camera.maintain_aspect - - @property - def y_axis(self) -> int: - return int(np.sign(self.selected_subplot.camera.local.scale_y)) - - @y_axis.setter - def y_axis(self, value: int): - """ - - Parameters - ---------- - value: 1 or -1 - 1: points upwards, -1: points downwards - - """ - value = int(value) # in case we had a float 1.0 - - if value not in [1, -1]: - raise ValueError("y_axis value must be 1 or -1") - - sign = np.sign(self.selected_subplot.camera.local.scale_y) - - if sign == value: - # desired y-axis is already set - return - - # otherwise flip it - self.selected_subplot.camera.local.scale_y *= -1 - - def render(self): - raise NotImplemented - - def _autoscale_init(self, maintain_aspect: bool): - """autoscale function that is called only during show()""" - if self._plot_type == "GridPlot": - 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 show(self): - raise NotImplemented("Must be implemented in subclass") diff --git a/fastplotlib/layouts/_frame/_frame_desktop.py b/fastplotlib/layouts/_frame/_frame_desktop.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/fastplotlib/layouts/_frame/_frame_notebook.py b/fastplotlib/layouts/_frame/_frame_notebook.py deleted file mode 100644 index 99422e72d..000000000 --- a/fastplotlib/layouts/_frame/_frame_notebook.py +++ /dev/null @@ -1,103 +0,0 @@ -import os - - -from ._frame_base import BaseFrame -from ._toolbar import ToolBar - - -class FrameNotebook(BaseFrame): - 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: - self._autoscale_init(maintain_aspect) - - 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 = 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])) - diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py new file mode 100644 index 000000000..c8d56240a --- /dev/null +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -0,0 +1,174 @@ +import traceback +from datetime import datetime +from itertools import product + +from ipywidgets import Button, Layout, ToggleButton, Dropdown, HBox + +from ...graphics.selectors import PolygonSelector +from ._toolbar import ToolBar + + +class IpywidgetToolBar(ToolBar): + def __init__(self, plot): + """ + Basic toolbar for a GridPlot instance. + + Parameters + ---------- + plot: + """ + super().__init__(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.widget = HBox(widgets) + + 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, "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") + + self._maintain_aspect_button.value = self.current_subplot.camera.maintain_aspect + + 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(self, obj): + for camera in self.plot.controller.cameras: + camera.maintain_aspect = self._maintain_aspect_button.value + + def y_direction_handler(self, obj): + # TODO: What if the user has set different y_scales for cameras under the same controller? + self.current_subplot.camera.local.scale_y *= -1 + if 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 + + 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) + + def close(self): + self.widget.close() + + def __repr__(self): + return self.widget \ No newline at end of file diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py new file mode 100644 index 000000000..81e23a71e --- /dev/null +++ b/fastplotlib/layouts/_frame/_jupyter_output.py @@ -0,0 +1,46 @@ +from ipywidgets import VBox +from sidecar import Sidecar +from IPython.display import display + +from ._ipywidget_toolbar import IpywidgetToolBar + + +class JupyterOutput: + def __init__( + self, + frame, + make_toolbar, + use_sidecar, + sidecar_kwargs + ): + self.frame = frame + self.toolbar = None + self.sidecar = None + + self.use_sidecar = use_sidecar + + if not make_toolbar and not use_sidecar: + self.output = frame.canvas + + if make_toolbar: + self.toolbar = IpywidgetToolBar(frame) + self.output = VBox([frame.canvas, frame.toolbar]) + + if use_sidecar: + self.sidecar = Sidecar(**sidecar_kwargs) + + def __repr__(self): + if self.use_sidecar: + with self.sidecar: + return display(self.output) + else: + return self.output + + def close(self): + self.frame.canvas.close() + + if self.toolbar is not None: + self.toolbar.close() + + if self.sidecar is not None: + self.sidecar.close() diff --git a/fastplotlib/layouts/_frame/_qt_output.py b/fastplotlib/layouts/_frame/_qt_output.py new file mode 100644 index 000000000..a1236a94e --- /dev/null +++ b/fastplotlib/layouts/_frame/_qt_output.py @@ -0,0 +1,4 @@ +class QtOutput: + def __init__(self): + pass + diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py new file mode 100644 index 000000000..01412f6bb --- /dev/null +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -0,0 +1,44 @@ +from fastplotlib.layouts._subplot import Subplot +from fastplotlib.layouts._frame._toolbar import ToolBar + + +class QtToolbar(ToolBar): + def __init__(self, plot): + self.plot = plot + + super().__init__(plot) + + def _get_subplot_dropdown_value(self) -> str: + raise NotImplemented + + @property + def current_subplot(self) -> Subplot: + if hasattr(self.plot, "_subplots"): + # parses dropdown 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 = (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 diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/_frame/_toolbar.py index 654ac57b0..6c14fe2fe 100644 --- a/fastplotlib/layouts/_frame/_toolbar.py +++ b/fastplotlib/layouts/_frame/_toolbar.py @@ -1,156 +1,44 @@ -from datetime import datetime -from itertools import product -import traceback - -from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Dropdown, Widget - from fastplotlib.layouts._subplot import Subplot class ToolBar: def __init__(self, plot): - """ - 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.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.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.record_button.observe(self.record_plot, "value") - - self.plot.renderer.add_event_handler(self.update_current_subplot, "click") + def _get_subplot_dropdown_value(self) -> str: + raise NotImplemented @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)] + if hasattr(self.plot, "_subplots"): + # parses dropdown 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 = (int(i) for i in current.strip("()").split(",")) + return self.plot[current] + else: + return self.plot[current] else: - return self.plot[current] + return self.plot - def auto_scale(self, obj): - current = self.current_subplot - current.auto_scale(maintain_aspect=current.camera.maintain_aspect) + def panzoom_handler(self, ev): + raise NotImplemented - def center_scene(self, obj): - current = self.current_subplot - current.center_scene() + def maintain_aspect_handler(self, ev): + raise NotImplemented - def panzoom_control(self, obj): - current = self.current_subplot - current.controller.enabled = self.panzoom_controller_button.value + def y_direction_handler(self, ev): + raise NotImplemented - def maintain_aspect(self, obj): - current = self.current_subplot - current.camera.maintain_aspect = self.maintain_aspect_button.value + def auto_scale_handler(self, ev): + raise NotImplemented - 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 center_scene_handler(self, ev): + raise NotImplemented - 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_handler(self, ev): + raise NotImplemented - 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, ev): + raise NotImplemented 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..7670a0962 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -1,22 +1,15 @@ 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 +55,12 @@ 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): 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/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 3cfdbbd41..2060850c2 100644 --- a/fastplotlib/layouts/_plot_area.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): """ From 80a3fb2eb67caaf6352edeb10d4d328f158eb7e4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 19:43:14 -0400 Subject: [PATCH 03/22] plot frame system working for jupyter --- fastplotlib/layouts/_frame/_frame.py | 28 +++++++++++++++---- .../layouts/_frame/_ipywidget_toolbar.py | 18 ++++++------ fastplotlib/layouts/_frame/_jupyter_output.py | 8 +++--- fastplotlib/layouts/_frame/_toolbar.py | 2 +- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 82bf2d5b0..4dd3624d8 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -4,20 +4,33 @@ from .._utils import CANVAS_OPTIONS_AVAILABLE + class UnavailableOutputContext: - def __init__(self, *arg, **kwargs): - raise ModuleNotFoundError("Unavailable output context") + 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}" + ) if CANVAS_OPTIONS_AVAILABLE["jupyter"]: from ._jupyter_output import JupyterOutput else: - JupyterOutput = UnavailableOutputContext + JupyterOutput = UnavailableOutputContext( + "Jupyter", + "You must install `jupyter_rfb` to use this output context" + ) if CANVAS_OPTIONS_AVAILABLE["qt"]: from ._qt_output import QtOutput else: - JupyterOutput = UnavailableOutputContext + QtOutput = UnavailableOutputContext( + "Qt", + "You must install `PyQt6` to use this output context" + ) # Single class for PlotFrame to avoid determining inheritance at runtime @@ -94,6 +107,9 @@ def show( if self._output is not None: return self._output + if sidecar_kwargs is None: + sidecar_kwargs = dict() + self.canvas.request_draw(self.render) self.canvas.set_logical_size(*self._starting_size) @@ -106,7 +122,7 @@ def show( return self.canvas.snapshot() if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": - return JupyterOutput( + self._output = JupyterOutput( frame=self, make_toolbar=toolbar, use_sidecar=sidecar, @@ -119,5 +135,7 @@ def show( make_toolbar=toolbar, ) + return self._output + def close(self): self._output.close() diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index c8d56240a..4fd8257e5 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -8,7 +8,7 @@ from ._toolbar import ToolBar -class IpywidgetToolBar(ToolBar): +class IpywidgetToolBar(HBox, ToolBar): def __init__(self, plot): """ Basic toolbar for a GridPlot instance. @@ -17,7 +17,7 @@ def __init__(self, plot): ---------- plot: """ - super().__init__(plot) + ToolBar.__init__(self, plot) self._auto_scale_button = Button( value=False, @@ -102,7 +102,7 @@ def __init__(self, plot): widgets.append(self._dropdown) - self.widget = HBox(widgets) + # self.widget = HBox(widgets) self._panzoom_controller_button.observe(self.panzoom_handler, "value") self._auto_scale_button.on_click(self.auto_scale_handler) @@ -114,6 +114,8 @@ def __init__(self, plot): self._maintain_aspect_button.value = self.current_subplot.camera.maintain_aspect + HBox.__init__(self, widgets) + def _get_subplot_dropdown_value(self) -> str: return self._dropdown.value @@ -127,7 +129,7 @@ def panzoom_handler(self, obj): self.current_subplot.controller.enabled = self._panzoom_controller_button.value def maintain_aspect(self, obj): - for camera in self.plot.controller.cameras: + for camera in self.current_subplot.controller.cameras: camera.maintain_aspect = self._maintain_aspect_button.value def y_direction_handler(self, obj): @@ -164,11 +166,7 @@ def record_plot(self, obj): def add_polygon(self, obj): ps = PolygonSelector(edge_width=3, edge_color="magenta") - self.current_subplot.add_graphic(ps, center=False) - def close(self): - self.widget.close() - - def __repr__(self): - return self.widget \ No newline at end of file + def _repr_mimebundle(self, *args, **kwargs): + super()._repr_mimebundle(*args, **kwargs) diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py index 81e23a71e..1d1cb1de3 100644 --- a/fastplotlib/layouts/_frame/_jupyter_output.py +++ b/fastplotlib/layouts/_frame/_jupyter_output.py @@ -19,22 +19,22 @@ def __init__( self.use_sidecar = use_sidecar - if not make_toolbar and not use_sidecar: + if not make_toolbar: self.output = frame.canvas if make_toolbar: self.toolbar = IpywidgetToolBar(frame) - self.output = VBox([frame.canvas, frame.toolbar]) + self.output = VBox([frame.canvas, self.toolbar]) if use_sidecar: self.sidecar = Sidecar(**sidecar_kwargs) - def __repr__(self): + def _repr_mimebundle_(self, *args, **kwargs): if self.use_sidecar: with self.sidecar: return display(self.output) else: - return self.output + return self.output._repr_mimebundle_(*args, **kwargs) def close(self): self.frame.canvas.close() diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/_frame/_toolbar.py index 6c14fe2fe..58845664b 100644 --- a/fastplotlib/layouts/_frame/_toolbar.py +++ b/fastplotlib/layouts/_frame/_toolbar.py @@ -15,7 +15,7 @@ def current_subplot(self) -> Subplot: current = self._get_subplot_dropdown_value() if current[0] == "(": # str representation of int tuple to tuple of int - current = (int(i) for i in current.strip("()").split(",")) + current = tuple(int(i) for i in current.strip("()").split(",")) return self.plot[current] else: return self.plot[current] From 7943697f573d3b1cbef129e9b223455c3c1bb1d5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 21:06:46 -0400 Subject: [PATCH 04/22] make it work with imagewidget --- fastplotlib/layouts/_frame/_frame.py | 23 +++++- .../layouts/_frame/_ipywidget_toolbar.py | 12 +-- fastplotlib/layouts/_frame/_jupyter_output.py | 26 +++++-- fastplotlib/widgets/image.py | 74 ++++--------------- 4 files changed, 60 insertions(+), 75 deletions(-) diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 4dd3624d8..3f18c71a2 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -2,6 +2,8 @@ from ._toolbar import ToolBar +from ...graphics import ImageGraphic + from .._utils import CANVAS_OPTIONS_AVAILABLE @@ -52,7 +54,7 @@ def __init__(self): def toolbar(self) -> ToolBar: return self._output.toolbar - def render(self): + def _render_step(self): raise NotImplemented def _autoscale_init(self, maintain_aspect: bool): @@ -69,6 +71,10 @@ def _autoscale_init(self, maintain_aspect: bool): maintain_aspect = self.camera.maintain_aspect self.auto_scale(maintain_aspect=maintain_aspect, zoom=0.95) + def start_render(self): + self.canvas.request_draw(self.render) + self.canvas.set_logical_size(*self._starting_size) + def show( self, autoscale: bool = True, @@ -76,6 +82,7 @@ def show( toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None, + add_widgets: list = None, ): """ Begins the rendering event loop and returns the canvas @@ -104,14 +111,21 @@ def show( the canvas """ + + # 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() - self.canvas.request_draw(self.render) - self.canvas.set_logical_size(*self._starting_size) + # flip y axis if ImageGraphics are present + for g in self.graphics: + if isinstance(g, ImageGraphic): + self.camera.local.scale_y = -1 + break if autoscale: self._autoscale_init(maintain_aspect) @@ -126,7 +140,8 @@ def show( frame=self, make_toolbar=toolbar, use_sidecar=sidecar, - sidecar_kwargs=sidecar_kwargs + sidecar_kwargs=sidecar_kwargs, + add_widgets=add_widgets, ) elif self.canvas.__class__.__name__ == "QWgpuCanvas": diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index 4fd8257e5..d09f7a7dd 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -48,6 +48,7 @@ def __init__(self, plot): tooltip="maintain aspect", ) self._maintain_aspect_button.style.font_weight = "bold" + self._y_direction_button = Button( value=False, disabled=False, @@ -102,8 +103,6 @@ def __init__(self, plot): widgets.append(self._dropdown) - # self.widget = HBox(widgets) - 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) @@ -112,8 +111,14 @@ def __init__(self, plot): 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 self.current_subplot.camera.local.scale_y == -1: + self._y_direction_button.icon = "arrow-down" + else: + self._y_direction_button.icon = "arrow-up" + HBox.__init__(self, widgets) def _get_subplot_dropdown_value(self) -> str: @@ -167,6 +172,3 @@ def record_plot(self, obj): def add_polygon(self, obj): ps = PolygonSelector(edge_width=3, edge_color="magenta") self.current_subplot.add_graphic(ps, center=False) - - def _repr_mimebundle(self, *args, **kwargs): - super()._repr_mimebundle(*args, **kwargs) diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py index 1d1cb1de3..066511c80 100644 --- a/fastplotlib/layouts/_frame/_jupyter_output.py +++ b/fastplotlib/layouts/_frame/_jupyter_output.py @@ -1,40 +1,52 @@ -from ipywidgets import VBox +from ipywidgets import VBox, Widget from sidecar import Sidecar from IPython.display import display from ._ipywidget_toolbar import IpywidgetToolBar -class JupyterOutput: +class JupyterOutput(VBox): def __init__( self, frame, make_toolbar, use_sidecar, - sidecar_kwargs + sidecar_kwargs, + add_widgets, ): self.frame = frame self.toolbar = None self.sidecar = None + if add_widgets is None: + add_widgets = list() + else: + 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: - self.output = frame.canvas + self.output = (frame.canvas,) if make_toolbar: self.toolbar = IpywidgetToolBar(frame) - self.output = VBox([frame.canvas, self.toolbar]) + self.output = (frame.canvas, self.toolbar, *add_widgets) if use_sidecar: self.sidecar = Sidecar(**sidecar_kwargs) + super().__init__(self.output) + def _repr_mimebundle_(self, *args, **kwargs): if self.use_sidecar: with self.sidecar: - return display(self.output) + # TODO: prints all the children called, will figure out later + return display(VBox(self.output)) else: - return self.output._repr_mimebundle_(*args, **kwargs) + return super()._repr_mimebundle_(*args, **kwargs) def close(self): self.frame.canvas.close() diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 2da413ac0..3191bae78 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -14,12 +14,10 @@ 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 @@ -879,70 +877,28 @@ 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 """ - # 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 - - 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=[ImageWidgetToolbar(self), *list(self.sliders.values())] + ) 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 + self.gridplot.close() -class ImageWidgetToolbar: +class ImageWidgetToolbar(HBox): def __init__(self, iw: ImageWidget): """ Basic toolbar for a ImageWidget instance. @@ -964,7 +920,7 @@ def __init__(self, iw: ImageWidget): # only for xy data, no time point slider needed if self.iw.ndim == 2: - self.widget = HBox([self.reset_vminvmax_button]) + widgets = [self.reset_vminvmax_button] # for txy, tzxy, etc. data else: self.step_size_setter = BoundedIntText( @@ -995,9 +951,7 @@ def __init__(self, iw: ImageWidget): description="play/pause", disabled=False, ) - self.widget = HBox( - [self.reset_vminvmax_button, self.play_button, self.step_size_setter, self.speed_text] - ) + widgets = [self.reset_vminvmax_button, self.play_button, self.step_size_setter, self.speed_text] self.play_button.interval = 10 @@ -1008,6 +962,8 @@ def __init__(self, iw: ImageWidget): self.reset_vminvmax_button.on_click(self._reset_vminvmax) + HBox.__init__(self, widgets) + def _reset_vminvmax(self, obj): self.iw.reset_vmin_vmax() From 57e0fe9ae7beeb7235db30bd301fc2b18e093f19 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 21:07:31 -0400 Subject: [PATCH 05/22] update demo nbs --- examples/notebooks/image_widget.ipynb | 44 ++++------ examples/notebooks/linear_selector.ipynb | 2 +- examples/notebooks/simple.ipynb | 102 ++++------------------- 3 files changed, 35 insertions(+), 113 deletions(-) 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()" ] }, { From 4c0002b6df8efa889f293fe918f0d278d1846671 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 22:18:03 -0400 Subject: [PATCH 06/22] fix finding if imagegraphic is present --- fastplotlib/layouts/_frame/_frame.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 3f18c71a2..fab15bd46 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -122,10 +122,17 @@ def show( sidecar_kwargs = dict() # flip y axis if ImageGraphics are present - for g in self.graphics: - if isinstance(g, ImageGraphic): - self.camera.local.scale_y = -1 - break + if hasattr(self, "_subplots"): + for subplot in self: + for g in subplot: + 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) From dbcedf87cb3cf963fab354c21acacb92746b5b68 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 22:28:44 -0400 Subject: [PATCH 07/22] fix finding images again --- fastplotlib/layouts/_frame/_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index fab15bd46..049969466 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -124,7 +124,7 @@ def show( # flip y axis if ImageGraphics are present if hasattr(self, "_subplots"): for subplot in self: - for g in subplot: + for g in subplot.graphics: if isinstance(g, ImageGraphic): subplot.camera.local.scale_y = -1 break From 9c222dd6bd60850211903ba81d2b85380880f5da Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 22:29:00 -0400 Subject: [PATCH 08/22] update desk image screenshots --- examples/desktop/screenshots/image_cmap.png | 4 ++-- examples/desktop/screenshots/image_rgb.png | 4 ++-- examples/desktop/screenshots/image_rgbvminvmax.png | 4 ++-- examples/desktop/screenshots/image_simple.png | 4 ++-- examples/desktop/screenshots/image_vminvmax.png | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) 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 From 6dd2f1ad00d64a62bafee775041019472cc632ab Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 29 Oct 2023 22:33:05 -0400 Subject: [PATCH 09/22] update screenshot --- examples/desktop/screenshots/gridplot.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 5e11f87793f528aa7ebfe4d8888c6059e80d1a59 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 01:00:15 -0400 Subject: [PATCH 10/22] qt works with toolbar --- fastplotlib/layouts/_frame/_frame.py | 24 ++-- .../layouts/_frame/_ipywidget_toolbar.py | 12 +- fastplotlib/layouts/_frame/_jupyter_output.py | 2 +- fastplotlib/layouts/_frame/_qt_output.py | 39 ++++++- fastplotlib/layouts/_frame/_qt_toolbar.py | 105 +++++++++++++----- .../layouts/_frame/_qtoolbar_template.py | 62 +++++++++++ fastplotlib/layouts/_frame/qtoolbar.ui | 89 +++++++++++++++ fastplotlib/layouts/_utils.py | 29 ++++- 8 files changed, 315 insertions(+), 47 deletions(-) create mode 100644 fastplotlib/layouts/_frame/_qtoolbar_template.py create mode 100644 fastplotlib/layouts/_frame/qtoolbar.ui diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 049969466..629bebb42 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -19,15 +19,16 @@ def __call__(self, *args, **kwargs): if CANVAS_OPTIONS_AVAILABLE["jupyter"]: - from ._jupyter_output import JupyterOutput + from ._jupyter_output import JupyterOutputContext else: - JupyterOutput = UnavailableOutputContext( + JupyterOutputContext = UnavailableOutputContext( "Jupyter", - "You must install `jupyter_rfb` to use this output context" + "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 QtOutput + from ._qt_output import QOutputContext else: QtOutput = UnavailableOutputContext( "Qt", @@ -54,7 +55,12 @@ def __init__(self): def toolbar(self) -> ToolBar: return self._output.toolbar - def _render_step(self): + @property + def widget(self): + """ipywidget or QWidget that contains this plot""" + return self._output + + def render(self): raise NotImplemented def _autoscale_init(self, maintain_aspect: bool): @@ -105,6 +111,9 @@ def show( 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 ------- WgpuCanvas @@ -143,7 +152,7 @@ def show( return self.canvas.snapshot() if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": - self._output = JupyterOutput( + self._output = JupyterOutputContext( frame=self, make_toolbar=toolbar, use_sidecar=sidecar, @@ -152,9 +161,10 @@ def show( ) elif self.canvas.__class__.__name__ == "QWgpuCanvas": - QtOutput( + self._output = QOutputContext( frame=self, make_toolbar=toolbar, + add_widgets=add_widgets ) return self._output diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index d09f7a7dd..5ff2551aa 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -1,6 +1,7 @@ import traceback from datetime import datetime from itertools import product +from math import copysign from ipywidgets import Button, Layout, ToggleButton, Dropdown, HBox @@ -106,7 +107,7 @@ def __init__(self, plot): 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, "value") + 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") @@ -114,7 +115,7 @@ def __init__(self, plot): # set initial values for some buttons self._maintain_aspect_button.value = self.current_subplot.camera.maintain_aspect - if self.current_subplot.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" @@ -133,14 +134,15 @@ def center_scene_handler(self, obj): def panzoom_handler(self, obj): self.current_subplot.controller.enabled = self._panzoom_controller_button.value - def maintain_aspect(self, obj): + def maintain_aspect_handler(self, obj): for camera in self.current_subplot.controller.cameras: - camera.maintain_aspect = self._maintain_aspect_button.value + camera.maintain_aspect_handler = self._maintain_aspect_button.value def y_direction_handler(self, obj): # TODO: What if the user has set different y_scales for cameras under the same controller? self.current_subplot.camera.local.scale_y *= -1 - if self.current_subplot.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" diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py index 066511c80..a94b782c6 100644 --- a/fastplotlib/layouts/_frame/_jupyter_output.py +++ b/fastplotlib/layouts/_frame/_jupyter_output.py @@ -5,7 +5,7 @@ from ._ipywidget_toolbar import IpywidgetToolBar -class JupyterOutput(VBox): +class JupyterOutputContext(VBox): def __init__( self, frame, diff --git a/fastplotlib/layouts/_frame/_qt_output.py b/fastplotlib/layouts/_frame/_qt_output.py index a1236a94e..8332d3c47 100644 --- a/fastplotlib/layouts/_frame/_qt_output.py +++ b/fastplotlib/layouts/_frame/_qt_output.py @@ -1,4 +1,37 @@ -class QtOutput: - def __init__(self): - pass +from PyQt6 import QtWidgets +from ._qt_toolbar import QToolbar + + +class QOutputContext(QtWidgets.QWidget): + def __init__( + self, + frame, + make_toolbar, + add_widgets, + ): + QtWidgets.QWidget.__init__(self, parent=None) + self.frame = frame + self.toolbar = None + + self.vlayout = QtWidgets.QVBoxLayout(self) + self.vlayout.addWidget(self.frame.canvas) + + if make_toolbar: + self.toolbar = QToolbar(output_context=self, plot=frame) + self.vlayout.addWidget(self.toolbar) + + if add_widgets is not None: + for w in add_widgets: + self.vlayout.addWidget(w) + + self.setLayout(self.vlayout) + + self.resize(*self.frame._starting_size) + + self.show() + + def close(self): + self.frame.canvas.close() + self.toolbar.close() + super().close() diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index 01412f6bb..70da5628b 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -1,44 +1,89 @@ -from fastplotlib.layouts._subplot import Subplot -from fastplotlib.layouts._frame._toolbar import ToolBar +from datetime import datetime +from itertools import product +from math import copysign +import traceback +from PyQt6 import QtWidgets -class QtToolbar(ToolBar): - def __init__(self, plot): - self.plot = plot +from ...graphics.selectors import PolygonSelector +from ._toolbar import ToolBar +from ._qtoolbar_template import Ui_QToolbar - super().__init__(plot) - def _get_subplot_dropdown_value(self) -> str: - raise NotImplemented +class QToolbar(ToolBar, QtWidgets.QWidget): + def __init__(self, output_context, plot): + QtWidgets.QWidget.__init__(self, parent=output_context) + ToolBar.__init__(self, plot) + + self.ui = Ui_QToolbar() + self.ui.setupUi(self) + + 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) - @property - def current_subplot(self) -> Subplot: if hasattr(self.plot, "_subplots"): - # parses dropdown 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 = (int(i) for i in current.strip("()").split(",")) - return self.plot[current] - else: - return self.plot[current] + 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.ui.dropdown = QtWidgets.QComboBox(parent=self) + self.ui.dropdown.addItems(values) + self.ui.horizontalLayout.addWidget(self.ui.dropdown) + + self.setMaximumHeight(40) + + # set the initial values + 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: - return self.plot + self.ui.y_direction_button.setText("^") + + def _get_subplot_dropdown_value(self) -> str: + return self.ui.dropdown.currentText() - def panzoom_handler(self, ev): - raise NotImplemented + def auto_scale_handler(self, *args): + self.current_subplot.auto_scale(maintain_aspect=self.current_subplot.camera.maintain_aspect) - def maintain_aspect_handler(self, ev): - raise NotImplemented + def center_scene_handler(self, *args): + self.current_subplot.center_scene() - def y_direction_handler(self, ev): - raise NotImplemented + def panzoom_handler(self, value: bool): + self.current_subplot.controller.enabled = value - def auto_scale_handler(self, ev): - raise NotImplemented + def maintain_aspect_handler(self, value: bool): + for camera in self.current_subplot.controller.cameras: + camera.maintain_aspect = value - def center_scene_handler(self, ev): - raise NotImplemented + def y_direction_handler(self, *args): + # TODO: What if the user has set different y_scales for cameras under the same controller? + self.current_subplot.camera.local.scale_y *= -1 + if 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): - raise NotImplemented + 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) 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/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/_utils.py b/fastplotlib/layouts/_utils.py index ebfe9e306..5c6f543c5 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,31 @@ } +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 + + raise ModuleNotFoundError( + "Could not find any framework to create a canvas. You must install either `glfw`, " + "`PyQt6` or the jupyter requirements: 'fastplotlib[notebook]'." + ) + + + def make_canvas_and_renderer( canvas: Union[str, WgpuCanvas, Texture, None], renderer: [WgpuRenderer, None] ): @@ -41,7 +67,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: From 6da351777872b9b7116ecd918ac58c0f134ddb01 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 02:55:39 -0400 Subject: [PATCH 11/22] qt context works with image widget --- .../layouts/_frame/_ipywidget_toolbar.py | 120 +++++++++++++- fastplotlib/layouts/_frame/_qt_output.py | 1 + fastplotlib/layouts/_frame/_qt_toolbar.py | 126 +++++++++++++-- .../layouts/_frame/_qtoolbar_imagewidget.ui | 22 +++ .../_frame/_qtoolbar_imagewidget.ui.autosave | 45 ++++++ fastplotlib/layouts/_utils.py | 8 +- fastplotlib/widgets/image.py | 153 +++--------------- 7 files changed, 329 insertions(+), 146 deletions(-) create mode 100644 fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui create mode 100644 fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index 5ff2551aa..b2346cdbc 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -2,8 +2,22 @@ from datetime import datetime from itertools import product from math import copysign +from functools import partial +from typing import * -from ipywidgets import Button, Layout, ToggleButton, Dropdown, HBox + +from ipywidgets.widgets import ( + IntSlider, + VBox, + HBox, + ToggleButton, + Dropdown, + Layout, + Button, + BoundedIntText, + Play, + jslink, +) from ...graphics.selectors import PolygonSelector from ._toolbar import ToolBar @@ -136,7 +150,7 @@ def panzoom_handler(self, obj): def maintain_aspect_handler(self, obj): for camera in self.current_subplot.controller.cameras: - camera.maintain_aspect_handler = self._maintain_aspect_button.value + camera.maintain_aspect = self._maintain_aspect_button.value def y_direction_handler(self, obj): # TODO: What if the user has set different y_scales for cameras under the same controller? @@ -174,3 +188,105 @@ def record_plot(self, obj): 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.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.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.iw.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize") + + # the buttons + self.hbox = HBox(widgets) + + VBox.__init__(self, (self.hbox, *list(self.sliders.values()))) + + def _reset_vminvmax(self, obj): + self.iw.reset_vmin_vmax() + + 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/_qt_output.py b/fastplotlib/layouts/_frame/_qt_output.py index 8332d3c47..58dbfb80b 100644 --- a/fastplotlib/layouts/_frame/_qt_output.py +++ b/fastplotlib/layouts/_frame/_qt_output.py @@ -23,6 +23,7 @@ def __init__( if add_widgets is not None: for w in add_widgets: + w.setParent(self) self.vlayout.addWidget(w) self.setLayout(self.vlayout) diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index 70da5628b..ae5149830 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -1,9 +1,11 @@ from datetime import datetime +from functools import partial from itertools import product from math import copysign import traceback +from typing import * -from PyQt6 import QtWidgets +from PyQt6 import QtWidgets, QtCore from ...graphics.selectors import PolygonSelector from ._toolbar import ToolBar @@ -25,17 +27,18 @@ def __init__(self, output_context, plot): self.ui.y_direction_button.clicked.connect(self.y_direction_handler) 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)) + 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) + + self.ui.current_subplot = QtWidgets.QLabel(parent=self) + self.ui.current_subplot.setText(name) + self.ui.horizontalLayout.addWidget(self.ui.current_subplot) - self.ui.dropdown = QtWidgets.QComboBox(parent=self) - self.ui.dropdown.addItems(values) - self.ui.horizontalLayout.addWidget(self.ui.dropdown) + self.plot.renderer.add_event_handler(self.update_current_subplot, "click") self.setMaximumHeight(40) @@ -48,8 +51,20 @@ def __init__(self, output_context, plot): else: self.ui.y_direction_button.setText("^") + 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 _get_subplot_dropdown_value(self) -> str: - return self.ui.dropdown.currentText() + 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) @@ -87,3 +102,90 @@ def record_handler(self, ev): 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: + 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) + + self.vlayout = QtWidgets.QVBoxLayout(self) + + self.image_widget = image_widget + + 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) + self.vlayout.addWidget(self.reset_vmin_vmax_button) + + self.sliders: Dict[str, SliderInterface] = dict() + + # has time and/or z-volume + if self.image_widget.ndim > 2: + for dim in self.image_widget.slider_dims: + hlayout = QtWidgets.QHBoxLayout() + max_val = self.image_widget._dims_max_bounds[dim] - 1 + + 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) + + spinbox = QtWidgets.QSpinBox(self) + spinbox.setMinimum(0) + spinbox.setMaximum(max_val) + spinbox.setValue(0) + spinbox.setSingleStep(1) + + slider.valueChanged.connect(spinbox.setValue) + spinbox.valueChanged.connect(slider.setValue) + + slider.valueChanged.connect(partial(self.image_widget._slider_value_changed, dim)) + + slider_label = QtWidgets.QLabel(self) + slider_label.setText(dim) + + hlayout.addWidget(slider_label) + hlayout.addWidget(slider) + hlayout.addWidget(spinbox) + + self.vlayout.addLayout(hlayout) + self.sliders[dim] = SliderInterface(slider) + + max_height = 40 + (40 * len(self.sliders.keys())) + + self.setMaximumHeight(max_height) diff --git a/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui b/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui new file mode 100644 index 000000000..9580b0b39 --- /dev/null +++ b/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui @@ -0,0 +1,22 @@ + + + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + diff --git a/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave b/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave new file mode 100644 index 000000000..f14709d04 --- /dev/null +++ b/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave @@ -0,0 +1,45 @@ + + + Form + + + + 0 + 0 + 809 + 154 + + + + Form + + + + + 20 + 30 + 88 + 34 + + + + auto-contrast + + + + + + 320 + 70 + 160 + 20 + + + + Qt::Horizontal + + + + + + diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 5c6f543c5..dd6fbeb50 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -51,11 +51,9 @@ def auto_determine_canvas(): elif CANVAS_OPTIONS_AVAILABLE["glfw"]: return GlfwWgpuCanvas - raise ModuleNotFoundError( - "Could not find any framework to create a canvas. You must install either `glfw`, " - "`PyQt6` or the jupyter requirements: 'fastplotlib[notebook]'." - ) - + # We go with the wgpu auto guess + # for example, offscreen canvas etc. + return WgpuCanvas def make_canvas_and_renderer( diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 3191bae78..ccb5583e4 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,24 +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 ..layouts import GridPlot from ..graphics import ImageGraphic 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 = { @@ -153,9 +150,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]: @@ -290,9 +287,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 @@ -519,7 +513,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} @@ -561,30 +555,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]: @@ -772,15 +744,14 @@ 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): """ @@ -885,91 +856,19 @@ def show(self, toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict ------- OutputContext """ + if self.gridplot.canvas.__class__.__name__ == "JupyterWgpuCanvas": + self._image_widget_toolbar = IpywidgetImageWidgetToolbar(self) + + elif self.gridplot.canvas.__class__.__name__ == "QWgpuCanvas": + self._image_widget_toolbar = QToolbarImageWidget(self) return self.gridplot.show( toolbar=toolbar, sidecar=sidecar, sidecar_kwargs=sidecar_kwargs, - add_widgets=[ImageWidgetToolbar(self), *list(self.sliders.values())] + add_widgets=[self._image_widget_toolbar] ) def close(self): """Close Widget""" self.gridplot.close() - - -class ImageWidgetToolbar(HBox): - 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: - widgets = [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, - ) - widgets = [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) - - HBox.__init__(self, widgets) - - 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 From b1e7bda285ab68f23f32c549abb368a64f724206 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 03:45:51 -0400 Subject: [PATCH 12/22] comments --- fastplotlib/layouts/_frame/_frame.py | 43 +++++++++----- fastplotlib/layouts/_frame/_jupyter_output.py | 58 ++++++++++++++----- fastplotlib/layouts/_frame/_qt_output.py | 31 ++++++++-- fastplotlib/layouts/_frame/_qt_toolbar.py | 48 +++++++++++---- fastplotlib/layouts/_frame/_toolbar.py | 3 +- fastplotlib/layouts/_plot.py | 2 +- 6 files changed, 135 insertions(+), 50 deletions(-) diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 629bebb42..79e28f173 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -8,6 +8,8 @@ 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 @@ -18,6 +20,7 @@ def __call__(self, *args, **kwargs): ) +# 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: @@ -36,31 +39,28 @@ def __call__(self, *args, **kwargs): ) -# Single class for PlotFrame to avoid determining inheritance at runtime class Frame: - """Mixin class for Plot and GridPlot that gives them the toolbar""" - def __init__(self): - """ + """ + Mixin class for Plot and GridPlot that "frames" the plot. - Parameters - ---------- - plot: - `Plot` or `GridPlot` - toolbar - """ - self._plot_type = self.__class__.__name__ + 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): @@ -78,6 +78,7 @@ def _autoscale_init(self, maintain_aspect: bool): 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) @@ -91,7 +92,7 @@ def show( add_widgets: list = None, ): """ - Begins the rendering event loop and returns the canvas + Begins the rendering event loop and shows the plot in the desired output context (jupyter, qt or glfw). Parameters ---------- @@ -105,7 +106,7 @@ def show( show toolbar sidecar: bool, default ``True`` - display plot in a ``jupyterlab-sidecar`` + display plot in a ``jupyterlab-sidecar``, only for jupyter output context sidecar_kwargs: dict, default ``None`` kwargs for sidecar instance to display plot @@ -116,9 +117,10 @@ def show( Returns ------- - WgpuCanvas - the canvas + 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 @@ -130,6 +132,9 @@ def show( 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: @@ -146,11 +151,12 @@ def show( if autoscale: self._autoscale_init(maintain_aspect) + # used for generating images in docs using nbsphinx if "NB_SNAPSHOT" in os.environ.keys(): - # used for docs 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, @@ -167,7 +173,12 @@ def show( 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/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py index a94b782c6..25f5e2a2e 100644 --- a/fastplotlib/layouts/_frame/_jupyter_output.py +++ b/fastplotlib/layouts/_frame/_jupyter_output.py @@ -1,3 +1,5 @@ +from typing import * + from ipywidgets import VBox, Widget from sidecar import Sidecar from IPython.display import display @@ -6,49 +8,71 @@ 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, - use_sidecar, - sidecar_kwargs, - add_widgets, + 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 - if add_widgets is None: - add_widgets = list() - else: - 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}" - ) + # 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: - self.output = (frame.canvas,) + if not make_toolbar: # just stack canvas and the additional widgets, if any + self.output = (frame.canvas, *add_widgets) - if make_toolbar: + 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: + 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 children called, will figure out later + # 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: @@ -56,3 +80,5 @@ def close(self): 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 index 58dbfb80b..b4c7cffd9 100644 --- a/fastplotlib/layouts/_frame/_qt_output.py +++ b/fastplotlib/layouts/_frame/_qt_output.py @@ -4,27 +4,45 @@ 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: + if make_toolbar: # make toolbar and add to layout self.toolbar = QToolbar(output_context=self, plot=frame) self.vlayout.addWidget(self.toolbar) - if add_widgets is not None: - for w in add_widgets: - w.setParent(self) - self.vlayout.addWidget(w) + for w in add_widgets: # add any additional widgets to layout + w.setParent(self) + self.vlayout.addWidget(w) self.setLayout(self.vlayout) @@ -33,6 +51,7 @@ def __init__( self.show() def close(self): + """Cleanup and close the output context""" self.frame.canvas.close() self.toolbar.close() - super().close() + super().close() # QWidget cleanup diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index ae5149830..3fd339cb8 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -1,6 +1,5 @@ from datetime import datetime from functools import partial -from itertools import product from math import copysign import traceback from typing import * @@ -12,20 +11,24 @@ from ._qtoolbar_template import Ui_QToolbar -class QToolbar(ToolBar, QtWidgets.QWidget): +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 @@ -34,15 +37,18 @@ def __init__(self, output_context, plot): 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(40) - # set the initial values + # 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) @@ -52,16 +58,19 @@ def __init__(self, output_context, plot): 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: - # update self.dropdown - if subplot.name is None: - self._dropdown.value = str(subplot.position) + if subplot.name is not None: + name = subplot.name else: - self._dropdown.value = subplot.name - self._panzoom_controller_button.value = subplot.controller.enabled - self._maintain_aspect_button.value = subplot.camera.maintain_aspect + 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) def _get_subplot_dropdown_value(self) -> str: return self.ui.current_subplot.text() @@ -107,6 +116,11 @@ def add_polygon(self, *args): # 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 @@ -137,9 +151,11 @@ def min(self, value: int): 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 @@ -153,10 +169,14 @@ def __init__(self, image_widget): # 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() + 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) @@ -165,25 +185,33 @@ def __init__(self, image_widget): 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 = 40 + (40 * len(self.sliders.keys())) diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/_frame/_toolbar.py index 58845664b..94410b8ea 100644 --- a/fastplotlib/layouts/_frame/_toolbar.py +++ b/fastplotlib/layouts/_frame/_toolbar.py @@ -10,8 +10,9 @@ def _get_subplot_dropdown_value(self) -> str: @property def current_subplot(self) -> Subplot: + """Returns current subplot""" if hasattr(self.plot, "_subplots"): - # parses dropdown value as plot name or position + # 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 diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 7670a0962..5aa04bb76 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -1,5 +1,4 @@ from typing import * -import os import pygfx from wgpu.gui.auto import WgpuCanvas @@ -60,6 +59,7 @@ def __init__( self._starting_size = size def render(self): + """performs a single render of the plot, not for the user""" super(Plot, self).render() self.renderer.flush() From 81df69f0c31a354bf72d4eb5e6b3fb8c4f299f4c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 04:08:40 -0400 Subject: [PATCH 13/22] bugfix with flipping --- fastplotlib/layouts/_frame/_ipywidget_toolbar.py | 5 +++++ fastplotlib/layouts/_frame/_qt_toolbar.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index b2346cdbc..796c97a74 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -173,6 +173,11 @@ def update_current_subplot(self, ev): 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: diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index 3fd339cb8..9d1b32180 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -72,6 +72,11 @@ def update_current_subplot(self, ev): 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() @@ -91,7 +96,7 @@ def maintain_aspect_handler(self, value: bool): def y_direction_handler(self, *args): # TODO: What if the user has set different y_scales for cameras under the same controller? self.current_subplot.camera.local.scale_y *= -1 - if self.current_subplot.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("^") From 6f85cedb76afd2a5366fd8f281a45ccf0c9a5b81 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 19:56:39 -0400 Subject: [PATCH 14/22] remove junk files --- .../layouts/_frame/_qtoolbar_imagewidget.ui | 22 --------- .../_frame/_qtoolbar_imagewidget.ui.autosave | 45 ------------------- 2 files changed, 67 deletions(-) delete mode 100644 fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui delete mode 100644 fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave diff --git a/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui b/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui deleted file mode 100644 index 9580b0b39..000000000 --- a/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui +++ /dev/null @@ -1,22 +0,0 @@ - - - - - Form - - - - 0 - 0 - 400 - 300 - - - - Form - - - - - - diff --git a/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave b/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave deleted file mode 100644 index f14709d04..000000000 --- a/fastplotlib/layouts/_frame/_qtoolbar_imagewidget.ui.autosave +++ /dev/null @@ -1,45 +0,0 @@ - - - Form - - - - 0 - 0 - 809 - 154 - - - - Form - - - - - 20 - 30 - 88 - 34 - - - - auto-contrast - - - - - - 320 - 70 - 160 - 20 - - - - Qt::Horizontal - - - - - - From 5bed8a1e329d10e0258047c9efa276112850d43f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 20:33:29 -0400 Subject: [PATCH 15/22] fix flip, image widget can reset vmin vmax based on currently displayed frame --- fastplotlib/layouts/_frame/__init__.py | 2 +- fastplotlib/layouts/_frame/_frame.py | 4 +-- .../layouts/_frame/_ipywidget_toolbar.py | 29 ++++++++++++------- fastplotlib/layouts/_frame/_qt_toolbar.py | 6 ++-- fastplotlib/widgets/image.py | 14 ++++++++- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/fastplotlib/layouts/_frame/__init__.py b/fastplotlib/layouts/_frame/__init__.py index 708efdff0..c34884022 100644 --- a/fastplotlib/layouts/_frame/__init__.py +++ b/fastplotlib/layouts/_frame/__init__.py @@ -1 +1 @@ -from ._frame import Frame \ No newline at end of file +from ._frame import Frame diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 79e28f173..abd79759e 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -140,12 +140,12 @@ def show( for subplot in self: for g in subplot.graphics: if isinstance(g, ImageGraphic): - subplot.camera.local.scale_y = -1 + subplot.camera.local.scale_y *= -1 break else: for g in self.graphics: if isinstance(g, ImageGraphic): - self.camera.local.scale_y = -1 + self.camera.local.scale_y *= -1 break if autoscale: diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index 796c97a74..50aaa4aac 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -24,14 +24,8 @@ class IpywidgetToolBar(HBox, ToolBar): + """Basic toolbar using ipywidgets""" def __init__(self, plot): - """ - Basic toolbar for a GridPlot instance. - - Parameters - ---------- - plot: - """ ToolBar.__init__(self, plot) self._auto_scale_button = Button( @@ -134,7 +128,7 @@ def __init__(self, plot): else: self._y_direction_button.icon = "arrow-up" - HBox.__init__(self, widgets) + super().__init__(widgets) def _get_subplot_dropdown_value(self) -> str: return self._dropdown.value @@ -153,8 +147,9 @@ def maintain_aspect_handler(self, obj): camera.maintain_aspect = self._maintain_aspect_button.value def y_direction_handler(self, obj): - # TODO: What if the user has set different y_scales for cameras under the same controller? - self.current_subplot.camera.local.scale_y *= -1 + # 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" @@ -214,6 +209,14 @@ def __init__(self, iw): tooltip="reset vmin/vmax", ) + self.reset_vminvmax_frame_button = Button( + value=False, + icon="adjust", + description="frame", + layout=Layout(width="auto"), + tooltip="reset vmin/vmax w.r.t. current frame only" + ) + self.sliders: Dict[str, IntSlider] = dict() # only for xy data, no time point slider needed @@ -273,17 +276,21 @@ def __init__(self, iw): jslink((self.play_button, "max"), (self.sliders["t"], "max")) self.reset_vminvmax_button.on_click(self._reset_vminvmax) + self.reset_vminvmax_frame_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) - VBox.__init__(self, (self.hbox, *list(self.sliders.values()))) + 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 diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index 9d1b32180..bb11627ff 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -94,8 +94,10 @@ def maintain_aspect_handler(self, value: bool): camera.maintain_aspect = value def y_direction_handler(self, *args): - # TODO: What if the user has set different y_scales for cameras under the same controller? - self.current_subplot.camera.local.scale_y *= -1 + # 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: diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index ccb5583e4..d14282c41 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -755,11 +755,23 @@ def _slider_value_changed(self, dimension: str, change: Union[dict, int]): 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]], From 45a752e0c49e96b287144ab046866697bcea4cdf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 20:45:09 -0400 Subject: [PATCH 16/22] hlut autoscales plot area when set_data() called --- fastplotlib/widgets/histogram_lut.py | 5 +++++ 1 file changed, 5 insertions(+) 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 From 2b276a5c0ba0a0bac8dd76d2203e273220ddb2a4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 20:45:32 -0400 Subject: [PATCH 17/22] return buffer --- fastplotlib/widgets/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index d14282c41..0834c9a74 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -770,7 +770,7 @@ def reset_vmin_vmax_frame(self): 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) + hlut.set_data(subplot["image_widget_managed"].data()) def set_data( self, From 232e902477161a8f398670b180e95185eca18bef Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 21:05:25 -0400 Subject: [PATCH 18/22] reset hlut button --- fastplotlib/layouts/_frame/_ipywidget_toolbar.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index 50aaa4aac..7d59e4e2e 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -212,9 +212,9 @@ def __init__(self, iw): self.reset_vminvmax_frame_button = Button( value=False, icon="adjust", - description="frame", + description="reset", layout=Layout(width="auto"), - tooltip="reset vmin/vmax w.r.t. current frame only" + tooltip="reset vmin/vmax and reset histogram using current frame" ) self.sliders: Dict[str, IntSlider] = dict() @@ -266,7 +266,13 @@ def __init__(self, iw): description="play/pause", disabled=False, ) - widgets = [self.reset_vminvmax_button, self.play_button, self.step_size_setter, self.speed_text] + widgets = [ + self.reset_vminvmax_button, + self.reset_vminvmax_frame_button, + self.play_button, + self.step_size_setter, + self.speed_text + ] self.play_button.interval = 10 From ae9b47eea7d97adce3a85082f530f96a47e0bafc Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 21:22:16 -0400 Subject: [PATCH 19/22] add reset hlut button to QToolbar --- fastplotlib/layouts/_frame/_ipywidget_toolbar.py | 6 +++--- fastplotlib/layouts/_frame/_qt_toolbar.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py index 7d59e4e2e..f27856e61 100644 --- a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py +++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py @@ -209,7 +209,7 @@ def __init__(self, iw): tooltip="reset vmin/vmax", ) - self.reset_vminvmax_frame_button = Button( + self.reset_vminvmax_hlut_button = Button( value=False, icon="adjust", description="reset", @@ -268,7 +268,7 @@ def __init__(self, iw): ) widgets = [ self.reset_vminvmax_button, - self.reset_vminvmax_frame_button, + self.reset_vminvmax_hlut_button, self.play_button, self.step_size_setter, self.speed_text @@ -282,7 +282,7 @@ def __init__(self, iw): jslink((self.play_button, "max"), (self.sliders["t"], "max")) self.reset_vminvmax_button.on_click(self._reset_vminvmax) - self.reset_vminvmax_frame_button.on_click(self._reset_vminvmax_frame) + self.reset_vminvmax_hlut_button.on_click(self._reset_vminvmax_frame) self.iw.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize") diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index bb11627ff..b62c0e7a5 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -167,10 +167,19 @@ def __init__(self, image_widget): 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) - self.vlayout.addWidget(self.reset_vmin_vmax_button) + 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() @@ -221,6 +230,6 @@ def __init__(self, image_widget): # add to sliders dict for easier access to users self.sliders[dim] = SliderInterface(slider) - max_height = 40 + (40 * len(self.sliders.keys())) + max_height = 30 + (30 * len(self.sliders.keys())) self.setMaximumHeight(max_height) From 3cfe184c178559f1abf0084468498ec119e77627 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 21:35:50 -0400 Subject: [PATCH 20/22] add wiget property to imagewidget, fix gridplot kwargs handling --- fastplotlib/widgets/image.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 0834c9a74..a9ebfafb4 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -96,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.""" @@ -526,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() + + # 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) + 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: From 059cb9fec1e27b1e6836fcf4b8531fadaaa12af9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 21:36:10 -0400 Subject: [PATCH 21/22] qt toolbar layouting --- fastplotlib/layouts/_frame/_qt_toolbar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index b62c0e7a5..9d4e0b48f 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -46,7 +46,7 @@ def __init__(self, output_context, plot): # update the subplot label when a subplot is clicked into self.plot.renderer.add_event_handler(self.update_current_subplot, "click") - self.setMaximumHeight(40) + self.setMaximumHeight(35) # set the initial values for buttons self.ui.maintain_aspect_button.setChecked(self.current_subplot.camera.maintain_aspect) @@ -230,6 +230,6 @@ def __init__(self, image_widget): # add to sliders dict for easier access to users self.sliders[dim] = SliderInterface(slider) - max_height = 30 + (30 * len(self.sliders.keys())) + max_height = 35 + (35 * len(self.sliders.keys())) self.setMaximumHeight(max_height) From 3c9f4a91c8bda9b22bf28cc7d2ee3a892f8b78c4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 30 Oct 2023 21:37:33 -0400 Subject: [PATCH 22/22] add standalone qt image widget example --- examples/qt/imagewidget.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 examples/qt/imagewidget.py 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()