diff --git a/examples/export_figure.py b/examples/export_figure.py new file mode 100644 index 00000000000..a573e615444 --- /dev/null +++ b/examples/export_figure.py @@ -0,0 +1,110 @@ +""" +Export Figure +============= + +Display one shapes layer ontop of one image layer using the ``add_shapes`` and +``add_image`` APIs. When the window is closed it will print the coordinates of +your shapes. + +.. tags:: visualization-advanced +""" + +import numpy as np +from skimage import data + +import napari + +# create the viewer and window +viewer = napari.Viewer() + +# add the image +img_layer = viewer.add_image(data.camera(), name='photographer') +img_layer.colormap = 'gray' + +# create a list of polygons +polygons = [ + np.array([[11, 13], [111, 113], [22, 246]]), + np.array( + [ + [505, 60], + [402, 71], + [383, 42], + [251, 95], + [212, 59], + [131, 137], + [126, 187], + [191, 204], + [171, 248], + [211, 260], + [273, 243], + [264, 225], + [430, 173], + [512, 160], + ] + ), + np.array( + [ + [310, 382], + [229, 381], + [209, 401], + [221, 411], + [258, 411], + [300, 412], + [306, 435], + [268, 434], + [265, 454], + [298, 461], + [307, 461], + [307, 507], + [349, 510], + [352, 369], + [330, 366], + [330, 366], + ] + ), +] + +# add polygons +layer = viewer.add_shapes( + polygons, + shape_type='polygon', + edge_width=1, + edge_color='coral', + face_color='royalblue', + name='shapes', +) + +# add an ellipse to the layer +ellipse = np.array([[59, 222], [110, 289], [170, 243], [119, 176]]) +layer.add( + ellipse, + shape_type='ellipse', + edge_width=5, + edge_color='coral', + face_color='purple', +) + +labels = layer.to_labels([512, 512]) +labels_layer = viewer.add_labels(labels, name='labels') + +points = np.array([[100, 100], [200, 200], [333, 111]]) +size = np.array([10, 20, 20]) +viewer.add_points(points, size=size) + +# Export figure and change theme before and after exporting to show that the background canvas margins +# are not in the exported figure. +viewer.theme = "light" +# Optionally for saving the exported figure: viewer.export_figure(path="export_figure.png") +export_figure = viewer.export_figure() +scaled_export_figure = viewer.export_figure(scale_factor=5) +viewer.theme = "dark" + +viewer.add_image(export_figure, rgb=True, name='exported_figure') +viewer.add_image(scaled_export_figure, rgb=True, name='scaled_exported_figure') +viewer.reset_view() + +# from skimage.io import imsave +# imsave('screenshot.png', screenshot) + +if __name__ == '__main__': + napari.run() diff --git a/napari/_app_model/constants/_menus.py b/napari/_app_model/constants/_menus.py index 606760ba508..f1491ae953c 100644 --- a/napari/_app_model/constants/_menus.py +++ b/napari/_app_model/constants/_menus.py @@ -96,9 +96,10 @@ class MenuGroup: PLUGIN_SINGLE_CONTRIBUTIONS = '3_plugin_contributions' # File menubar OPEN = '1_open' - PREFERENCES = '2_preferences' - SAVE = '3_save' - CLOSE = '4_close' + UTIL = '2_util' + PREFERENCES = '3_preferences' + SAVE = '4_save' + CLOSE = '5_close' class LAYERLIST_CONTEXT: CONVERSION = '1_conversion' diff --git a/napari/_app_model/utils.py b/napari/_app_model/utils.py new file mode 100644 index 00000000000..8e56312ee63 --- /dev/null +++ b/napari/_app_model/utils.py @@ -0,0 +1,95 @@ +from typing import Union + +from app_model.expressions import parse_expression +from app_model.types import Action, MenuItem, SubmenuItem + +from napari._app_model import get_app +from napari._app_model.constants import MenuGroup, MenuId + +MenuOrSubmenu = Union[MenuItem, SubmenuItem] + + +def contains_dummy_action(menu_items: list[MenuOrSubmenu]) -> bool: + """True if one of the menu_items is the dummy action, otherwise False. + + Parameters + ---------- + menu_items : list[MenuOrSubmenu] + menu items belonging to a given menu + + Returns + ------- + bool + True if menu_items contains dummy item otherwise false + """ + for item in menu_items: + if hasattr(item, 'command') and 'empty_dummy' in item.command.id: + return True + return False + + +def is_empty_menu(menu_id: str) -> bool: + """Return True if the given menu_id is empty, otherwise False + + Parameters + ---------- + menu_id : str + id of the menu to check + + Returns + ------- + bool + True if the given menu_id is empty, otherwise False + """ + app = get_app() + if menu_id not in app.menus: + return True + if len(app.menus.get_menu(menu_id)) == 0: + return True + if len(app.menus.get_menu(menu_id)) == 1 and contains_dummy_action( + app.menus.get_menu(menu_id) + ): + return True + return False + + +def no_op() -> None: + """Fully qualified no-op to use for dummy actions.""" + + +def get_dummy_action(menu_id: MenuId) -> tuple[Action, str]: + """Return a dummy action to be used for the given menu. + + The part of the menu_id after the final `/` will form + a unique id_key used for the action ID and the when + expression context key. + + Parameters + ---------- + menu_id: MenuId + id of the menu to add the dummy action to + + Returns + ------- + tuple[Action, str] + dummy action and the `when` expression context key + """ + # NOTE: this assumes the final word of each contributable + # menu path is unique, otherwise, we will clash. Once we + # move to using short menu keys, the key itself will be used + # here and this will no longer be a concern. + id_key = menu_id.split('/')[-1] + action = Action( + id=f'napari.{id_key}.empty_dummy', + title='Empty', + callback=no_op, + menus=[ + { + 'id': menu_id, + 'group': MenuGroup.NAVIGATION, + 'when': parse_expression(context_key := f'{id_key}_empty'), + } + ], + enablement=False, + ) + return action, context_key diff --git a/napari/_qt/_qapp_model/qactions/__init__.py b/napari/_qt/_qapp_model/qactions/__init__.py index e7732b329c1..d7ab69a26a1 100644 --- a/napari/_qt/_qapp_model/qactions/__init__.py +++ b/napari/_qt/_qapp_model/qactions/__init__.py @@ -1,11 +1,15 @@ from __future__ import annotations -from functools import lru_cache +from functools import lru_cache, partial from itertools import chain +from typing import TYPE_CHECKING from napari._qt._qapp_model.injection._qprocessors import QPROCESSORS from napari._qt._qapp_model.injection._qproviders import QPROVIDERS +if TYPE_CHECKING: + from app_model.types import Context + # Submodules should be able to import from most modules, so to # avoid circular imports, don't import submodules at the top level here, # import them inside the init_qactions function. @@ -84,3 +88,33 @@ def init_qactions() -> None: app.menus.append_menu_items( chain(FILE_SUBMENUS, VIEW_SUBMENUS, DEBUG_SUBMENUS, LAYERS_SUBMENUS) ) + + +def add_dummy_actions(context: Context) -> None: + """Register dummy 'Empty' actions for all contributable menus. + + Each action is registered with its own `when` condition, that + ensures the action is not visible once the menu is populated. + The context key used in the `when` condition is also added to + the given `context` and assigned to a partial function that + returns True if the menu is empty, and otherwise False. + + + Parameters + ---------- + context : Context + context to store functional keys used in `when` conditions + """ + from napari._app_model import get_app + from napari._app_model.constants._menus import MenuId + from napari._app_model.utils import get_dummy_action, is_empty_menu + + app = get_app() + + actions = [] + for menu_id in MenuId.contributables(): + dummmy_action, context_key = get_dummy_action(menu_id) + if dummmy_action.id not in app.commands: + actions.append(dummmy_action) + context[context_key] = partial(is_empty_menu, menu_id) + app.register_actions(actions) diff --git a/napari/_qt/_qapp_model/qactions/_file.py b/napari/_qt/_qapp_model/qactions/_file.py index b69861f4fb4..acd7562022e 100644 --- a/napari/_qt/_qapp_model/qactions/_file.py +++ b/napari/_qt/_qapp_model/qactions/_file.py @@ -55,7 +55,7 @@ SubmenuItem( submenu=MenuId.FILE_IO_UTILITIES, title=trans._('IO Utilities'), - group=MenuGroup.OPEN, + group=MenuGroup.UTIL, order=101, ), ), @@ -64,7 +64,7 @@ SubmenuItem( submenu=MenuId.FILE_ACQUIRE, title=trans._('Acquire'), - group=MenuGroup.OPEN, + group=MenuGroup.UTIL, order=101, ), ), diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py index da265fc7ee9..3f7ddd50513 100644 --- a/napari/_qt/_tests/test_qt_viewer.py +++ b/napari/_qt/_tests/test_qt_viewer.py @@ -264,11 +264,52 @@ def test_screenshot(make_napari_viewer): # Take screenshot with pytest.warns(FutureWarning): - screenshot = viewer.window.qt_viewer.screenshot(flash=False) + viewer.window.qt_viewer.screenshot(flash=False) screenshot = viewer.window.screenshot(flash=False, canvas_only=True) assert screenshot.ndim == 3 +def test_export_figure(make_napari_viewer, tmp_path): + viewer = make_napari_viewer() + + np.random.seed(0) + # Add image + data = np.random.randint(150, 250, size=(250, 250)) + layer = viewer.add_image(data) + + camera_center = viewer.camera.center + camera_zoom = viewer.camera.zoom + img = viewer.export_figure(flash=False, path=str(tmp_path / 'img.png')) + + assert viewer.camera.center == camera_center + assert viewer.camera.zoom == camera_zoom + assert img.shape == (250, 250, 4) + assert np.all(img != np.array([0, 0, 0, 0])) + + assert (tmp_path / 'img.png').exists() + + layer.scale = [0.12, 0.24] + img = viewer.export_figure(flash=False) + # allclose accounts for rounding errors when computing size in hidpi aka + # retina displays + np.testing.assert_allclose(img.shape, (250, 499, 4), atol=1) + + layer.scale = [0.12, 0.12] + img = viewer.export_figure(flash=False) + assert img.shape == (250, 250, 4) + + viewer.camera.center = [100, 100] + camera_center = viewer.camera.center + camera_zoom = viewer.camera.zoom + img = viewer.export_figure() + + assert viewer.camera.center == camera_center + assert viewer.camera.zoom == camera_zoom + assert img.shape == (250, 250, 4) + assert np.all(img != np.array([0, 0, 0, 0])) + viewer.close() + + @pytest.mark.skip('new approach') def test_screenshot_dialog(make_napari_viewer, tmpdir): """Test save screenshot functionality.""" diff --git a/napari/_qt/qt_main_window.py b/napari/_qt/qt_main_window.py index c9ef50095a3..75493a0c181 100644 --- a/napari/_qt/qt_main_window.py +++ b/napari/_qt/qt_main_window.py @@ -21,6 +21,7 @@ ) from weakref import WeakValueDictionary +import numpy as np from qtpy.QtCore import ( QEvent, QEventLoop, @@ -48,7 +49,7 @@ from napari._app_model.constants import MenuId from napari._app_model.context import create_context, get_context from napari._qt._qapp_model import build_qmodel_menu -from napari._qt._qapp_model.qactions import init_qactions +from napari._qt._qapp_model.qactions import add_dummy_actions, init_qactions from napari._qt._qapp_model.qactions._debug import _is_set_trace_active from napari._qt._qplugins import ( _rebuild_npe1_plugins_menu, @@ -677,6 +678,16 @@ def __init__(self, viewer: 'Viewer', *, show: bool = True) -> None: index_npe1_adapters() self._add_menus() + # TODO: the dummy actions should **not** live on the layerlist context + # as they are unrelated. However, we do not currently have a suitable + # enclosing context where we could store these keys, such that they + # **and** the layerlist context key are available when we update + # menus. We need a single context to contain all keys required for + # menu update, so we add them to the layerlist context for now. + if self._qt_viewer._layers is not None: + add_dummy_actions( + self._qt_viewer._layers.model().sourceModel()._root._ctx + ) self._update_theme() self._update_theme_font_size() get_settings().appearance.events.theme.connect(self._update_theme) @@ -1559,7 +1570,12 @@ def _restart(self): self._qt_window.restart() def _screenshot( - self, size=None, scale=None, flash=True, canvas_only=False + self, + size: Optional[tuple[int, int]] = None, + scale: Optional[float] = None, + flash: bool = True, + canvas_only: bool = False, + fit_to_data_extent: bool = False, ) -> 'QImage': """Capture screenshot of the currently displayed viewer. @@ -1568,10 +1584,11 @@ def _screenshot( flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. - size : tuple (int, int) - Size (resolution height x width) of the screenshot. By default, the currently displayed size. - Only used if `canvas_only` is True. - scale : float + size : tuple of two ints, optional + Size (resolution height x width) of the screenshot. By default, the + currently displayed size. Only used if `canvas_only` is True. This + argument is ignored if fit_to_data_extent is set to True. + scale : float, optional Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. Only used if `canvas_only` is True. @@ -1579,6 +1596,10 @@ def _screenshot( If True, screenshot shows only the image display canvas, and if False include the napari viewer frame in the screenshot, By default, True. + fit_to_data_extent: bool + Tightly fit the canvas around the data to prevent margins from + showing in the screenshot. If False, a screenshot of the currently + visible canvas will be generated. Returns ------- @@ -1586,40 +1607,129 @@ def _screenshot( """ from napari._qt.utils import add_flash_animation - if canvas_only: - canvas = self._qt_viewer.canvas - prev_size = canvas.size - if size is not None: - if len(size) != 2: - raise ValueError( - trans._( - 'screenshot size must be 2 values, got {len_size}', - len_size=len(size), - ) - ) - # Scale the requested size to account for HiDPI - size = tuple( - int(dim / self._qt_window.devicePixelRatio()) - for dim in size + canvas = self._qt_viewer.canvas + prev_size = canvas.size + camera = self._qt_viewer.viewer.camera + old_center = camera.center + old_zoom = camera.zoom + ndisplay = self._qt_viewer.viewer.dims.ndisplay + + # Part 1: validate incompatible parameters + if not canvas_only and ( + fit_to_data_extent or size is not None or scale is not None + ): + raise ValueError( + trans._( + 'scale, size, and fit_to_data_extent can only be set for ' + 'canvas_only screenshots.', + deferred=True, + ) + ) + if fit_to_data_extent and ndisplay > 2: + raise NotImplementedError( + trans._( + 'fit_to_data_extent is not yet implemented for 3D view.', + deferred=True, ) - canvas.size = size - if scale is not None: - # multiply canvas dimensions by the scale factor to get new size - canvas.size = tuple(int(dim * scale) for dim in canvas.size) + ) + if size is not None and len(size) != 2: + raise ValueError( + trans._( + 'screenshot size must be 2 values, got {len_size}', + deferred=True, + len_size=len(size), + ) + ) + + # Part 2: compute canvas size and view based on parameters + if fit_to_data_extent: + extent_world = self._qt_viewer.viewer.layers.extent.world[1][ + -ndisplay: + ] + extent_step = min( + self._qt_viewer.viewer.layers.extent.step[-ndisplay:] + ) + size = extent_world / extent_step + 1 + if size is not None: + size = np.asarray(size) / self._qt_window.devicePixelRatio() + else: + size = np.asarray(prev_size) + if scale is not None: + # multiply canvas dimensions by the scale factor to get new size + size *= scale + + # Part 3: take the screenshot + if canvas_only: + canvas.size = tuple(size.astype(int)) + if fit_to_data_extent: + # tight view around data + self._qt_viewer.viewer.reset_view(margin=0) try: img = canvas.screenshot() if flash: add_flash_animation(self._qt_viewer._welcome_widget) finally: # make sure we always go back to the right canvas size - if size is not None or scale is not None: - canvas.size = prev_size + canvas.size = prev_size + camera.center = old_center + camera.zoom = old_zoom else: img = self._qt_window.grab().toImage() if flash: add_flash_animation(self._qt_window) return img + def export_figure( + self, + path: Optional[str] = None, + scale: float = 1, + flash=True, + ) -> np.ndarray: + """Export an image of the full extent of the displayed layer data. + + This function finds a tight boundary around the data, resets the view + around that boundary (and, when scale=1, such that 1 captured pixel is + equivalent to one data pixel), takes a screenshot, then restores the + previous zoom and canvas sizes. Currently, only works when 2 dimensions + are displayed. + + Parameters + ---------- + path : str, optional + Filename for saving screenshot image. + scale : float + Scale factor used to increase resolution of canvas for the + screenshot. By default, a scale of 1. + flash : bool + Flag to indicate whether flash animation should be shown after + the screenshot was captured. + By default, True. + + Returns + ------- + image : array + Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the + upper-left corner of the rendered region. + """ + if not isinstance(scale, (float, int)): + raise TypeError( + trans._( + 'Scale must be a float or an int.', + deferred=True, + ) + ) + img = QImg2array( + self._screenshot( + scale=scale, + flash=flash, + canvas_only=True, + fit_to_data_extent=True, + ) + ) + if path is not None: + imsave(path, img) + return img + def screenshot( self, path=None, size=None, scale=None, flash=True, canvas_only=False ): diff --git a/napari/_vispy/canvas.py b/napari/_vispy/canvas.py index 73a52685547..ab04e261ad1 100644 --- a/napari/_vispy/canvas.py +++ b/napari/_vispy/canvas.py @@ -167,6 +167,12 @@ def __init__( self.viewer.layers.events.removed.connect(self._remove_layer) self.destroyed.connect(self._disconnect_theme) + @property + def events(self): + # This is backwards compatible with the old events system + # https://github.com/napari/napari/issues/7054#issuecomment-2205548968 + return self._scene_canvas.events + @property def destroyed(self) -> pyqtBoundSignal: return self._scene_canvas._backend.destroyed diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py index 1a92008fcf7..5c55da54f7b 100644 --- a/napari/components/viewer_model.py +++ b/napari/components/viewer_model.py @@ -377,8 +377,15 @@ def _sliced_extent_world_augmented(self) -> np.ndarray: ) return self.layers._extent_world_augmented[:, self.dims.displayed] - def reset_view(self) -> None: - """Reset the camera view.""" + def reset_view(self, *, margin: float = 0.05) -> None: + """Reset the camera view. + + Parameters + ---------- + margin : float in [0, 1) + Margin as fraction of the canvas, showing blank space around the + data. + """ extent = self._sliced_extent_world_augmented scene_size = extent[1] - extent[0] @@ -402,12 +409,23 @@ def reset_view(self) -> None: # zoom is definied as the number of canvas pixels per world pixel # The default value used below will zoom such that the whole field # of view will occupy 95% of the canvas on the most filled axis + + if 0 <= margin < 1: + scale_factor = 1 - margin + else: + raise ValueError( + trans._( + 'margin must be between 0 and 1; got {margin} instead.', + deferred=True, + margin=margin, + ) + ) if np.max(size) == 0: - self.camera.zoom = 0.95 * np.min(self._canvas_size) + self.camera.zoom = scale_factor * np.min(self._canvas_size) else: scale = np.array(size[-2:]) scale[np.isclose(scale, 0)] = 1 - self.camera.zoom = 0.95 * np.min( + self.camera.zoom = scale_factor * np.min( np.array(self._canvas_size) / scale ) self.camera.angles = (0, 0, 90) diff --git a/napari/layers/_tests/test_layer_actions.py b/napari/layers/_tests/test_layer_actions.py index 4a78d1c0496..2986263366f 100644 --- a/napari/layers/_tests/test_layer_actions.py +++ b/napari/layers/_tests/test_layer_actions.py @@ -12,15 +12,58 @@ _hide_selected, _hide_unselected, _link_selected_layers, + _merge_stack, _project, _show_selected, _show_unselected, + _split_rgb, + _split_stack, _toggle_visibility, ) REG = pint.get_application_registry() +def test_split_stack(): + layer_list = LayerList() + layer_list.append(Image(np.random.rand(8, 8, 8))) + assert len(layer_list) == 1 + + layer_list.selection.active = layer_list[0] + _split_stack(layer_list) + assert len(layer_list) == 8 + + for idx in range(8): + assert layer_list[idx].data.shape == (8, 8) + + +def test_split_rgb(): + layer_list = LayerList() + layer_list.append(Image(np.random.random((8, 8, 3)))) + assert len(layer_list) == 1 + assert layer_list[0].rgb is True + + layer_list.selection.active = layer_list[0] + _split_rgb(layer_list) + assert len(layer_list) == 3 + + for idx in range(3): + assert layer_list[idx].data.shape == (8, 8) + + +def test_merge_stack(): + layer_list = LayerList() + layer_list.append(Image(np.random.rand(8, 8))) + layer_list.append(Image(np.random.rand(8, 8))) + assert len(layer_list) == 2 + + layer_list.selection.active = layer_list[0] + layer_list.selection.add(layer_list[1]) + _merge_stack(layer_list) + assert len(layer_list) == 1 + assert layer_list[0].data.shape == (2, 8, 8) + + def test_toggle_visibility(): """Test toggling visibility of a layer.""" layer_list = LayerList() diff --git a/napari/utils/__init__.py b/napari/utils/__init__.py index dbac3fe4fd6..266f00ae8b1 100644 --- a/napari/utils/__init__.py +++ b/napari/utils/__init__.py @@ -5,7 +5,10 @@ DirectLabelColormap, ) from napari.utils.info import citation_text, sys_info -from napari.utils.notebook_display import NotebookScreenshot, nbscreenshot +from napari.utils.notebook_display import ( + NotebookScreenshot, + nbscreenshot, +) from napari.utils.progress import cancelable_progress, progrange, progress __all__ = ( diff --git a/napari/viewer.py b/napari/viewer.py index af3a9dc49fd..fb92478f50a 100644 --- a/napari/viewer.py +++ b/napari/viewer.py @@ -3,6 +3,7 @@ from weakref import WeakSet import magicgui as mgui +import numpy as np from napari.components.viewer_model import ViewerModel from napari.utils import _magicgui @@ -88,31 +89,84 @@ def update_console(self, variables): return self.window._qt_viewer.console.push(variables) + def export_figure( + self, + path: Optional[str] = None, + *, + scale_factor: float = 1, + flash: bool = True, + ) -> np.ndarray: + """Export an image of the full extent of the displayed layer data. + + This function finds a tight boundary around the data, resets the view + around that boundary, takes a screenshot for which each pixel is equal + to the pixel resolution of the data, then restores the previous zoom + and canvas sizes. + + The pixel resolution can be upscaled or downscaled by the given + `scale_factor`. For example, an image with 800 x 600 pixels with + scale_factor 1 will be saved as 800 x 600, or 1200 x 900 with + scale_factor 1.5. + + For anisotropic images, the resolution is set by the highest-resolution + dimension. For an anisotropic 800 x 600 image with scale set to + [0.25, 0.5], the screenshot will be 800 x 1200, or 1200 x 1800 with a + scale_factor of 1.5. + + Upscaling will be done using the interpolation mode set on each layer. + + Parameters + ---------- + path : str, optional + Filename for saving screenshot image. + scale_factor : float + By default, the zoom will export approximately 1 pixel per + smallest-scale pixel on the viewer. For example, if a layer has + scale 0.004nm/pixel and another has scale 1µm/pixel, the exported + figure will have 0.004nm/pixel. Upscaling by 2 will produce a + figure with 0.002nm/pixel through the interpolation mode set on + each layer. + flash : bool + Flag to indicate whether flash animation should be shown after + the screenshot was captured. By default, True. + + Returns + ------- + image : array + Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the + upper-left corner of the rendered region. + """ + return self.window.export_figure( + path=path, + scale=scale_factor, + flash=flash, + ) + def screenshot( self, - path=None, + path: Optional[str] = None, *, - size=None, - scale=None, - canvas_only=True, + size: Optional[tuple[str, str]] = None, + scale: Optional[float] = None, + canvas_only: bool = True, flash: bool = True, ): """Take currently displayed screen and convert to an image array. Parameters ---------- - path : str + path : str, optional Filename for saving screenshot image. - size : tuple (int, int) - Size (resolution height x width) of the screenshot. By default, the currently displayed size. - Only used if `canvas_only` is True. - scale : float - Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. - Only used if `canvas_only` is True. + size : tuple of two ints, optional + Size (resolution height x width) of the screenshot. By default, the currently + displayed size. Only used if `canvas_only` is True. + scale : float, optional + Scale factor used to increase resolution of canvas for the screenshot. + By default, the currently displayed resolution.Only used if `canvas_only` is + True. canvas_only : bool - If True, screenshot shows only the image display canvas, and - if False include the napari viewer frame in the screenshot, - By default, True. + If True, screenshot shows only the image display canvas, and if False include + the napari viewer frame in the screenshot, By default, True. flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured.