diff --git a/{{cookiecutter.plugin_name}}/setup.cfg b/{{cookiecutter.plugin_name}}/setup.cfg index 75976a4..30b7f33 100644 --- a/{{cookiecutter.plugin_name}}/setup.cfg +++ b/{{cookiecutter.plugin_name}}/setup.cfg @@ -64,6 +64,7 @@ install_requires = numpy {% if cookiecutter.include_widget_plugin == 'y' %} magicgui qtpy + scikit-image {% endif %} python_requires = >=3.8 include_package_data = True diff --git a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/__init__.py b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/__init__.py index 92d36ef..39cc84c 100644 --- a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/__init__.py +++ b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/__init__.py @@ -12,7 +12,7 @@ {% endif %}{% if cookiecutter.include_sample_data_plugin == 'y' -%} from ._sample_data import make_sample_data {% endif %}{% if cookiecutter.include_widget_plugin == 'y' -%} -from ._widget import ExampleQWidget, example_magic_widget +from ._widget import ExampleQWidget, ImageThreshold, threshold_autogenerate_widget, threshold_magic_widget {% endif %}{% if cookiecutter.include_writer_plugin == 'y' -%} from ._writer import write_multiple, write_single_image {% endif %} @@ -26,6 +26,8 @@ "make_sample_data", {% endif %}{% if cookiecutter.include_widget_plugin == 'y' -%} "ExampleQWidget", - "example_magic_widget", + "ImageThreshold", + "threshold_autogenerate_widget", + "threshold_magic_widget", {% endif -%} ) diff --git a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_tests/test_widget.py b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_tests/test_widget.py index 7bbfa77..d83e713 100644 --- a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_tests/test_widget.py +++ b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_tests/test_widget.py @@ -1,9 +1,54 @@ import numpy as np -from {{cookiecutter.module_name}} import ExampleQWidget, example_magic_widget +from {{cookiecutter.module_name}}._widget import ( + ExampleQWidget, + ImageThreshold, + threshold_autogenerate_widget, + threshold_magic_widget, +) + + +def test_threshold_autogenerate_widget(): + # because our "widget" is a pure function, we can call it and + # test it independently of napari + im_data = np.random.random((100, 100)) + thresholded = threshold_autogenerate_widget(im_data, 0.5) + assert thresholded.shape == im_data.shape + # etc. # make_napari_viewer is a pytest fixture that returns a napari viewer object +# you don't need to import it, as long as napari is installed +# in your testing environment +def test_threshold_magic_widget(make_napari_viewer): + viewer = make_napari_viewer() + layer = viewer.add_image(np.random.random((100, 100))) + + # our widget will be a MagicFactory or FunctionGui instance + my_widget = threshold_magic_widget() + + # if we "call" this object, it'll execute our function + thresholded = my_widget(viewer.layers[0], 0.5) + assert thresholded.shape == layer.data.shape + # etc. + + +def test_image_threshold_widget(make_napari_viewer): + viewer = make_napari_viewer() + layer = viewer.add_image(np.random.random((100, 100))) + my_widget = ImageThreshold(viewer) + + # because we saved our widgets as attributes of the container + # we can set their values without having to "interact" with the viewer + my_widget._image_layer_combo.value = layer + my_widget._threshold_slider.value = 0.5 + + # this allows us to run our functions directly and ensure + # correct results + my_widget._threshold_im() + assert len(viewer.layers) == 2 + + # capsys is a pytest fixture that captures stdout and stderr output streams def test_example_q_widget(make_napari_viewer, capsys): # make viewer and add an image layer using our fixture @@ -19,18 +64,3 @@ def test_example_q_widget(make_napari_viewer, capsys): # read captured output and check that it's as we expected captured = capsys.readouterr() assert captured.out == "napari has 1 layers\n" - - -def test_example_magic_widget(make_napari_viewer, capsys): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - - # this time, our widget will be a MagicFactory or FunctionGui instance - my_widget = example_magic_widget() - - # if we "call" this object, it'll execute our function - my_widget(viewer.layers[0]) - - # read captured output and check that it's as we expected - captured = capsys.readouterr() - assert captured.out == f"you have selected {layer}\n" diff --git a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_widget.py b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_widget.py index fe7e1e7..ed8c358 100644 --- a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_widget.py +++ b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_widget.py @@ -1,28 +1,122 @@ """ -This module is an example of a barebones QWidget plugin for napari +This module contains four napari widgets declared in +different ways: -It implements the Widget specification. -see: https://napari.org/stable/plugins/guides.html?#widgets +- a pure Python function flagged with `autogenerate: true` + in the plugin manifest. Type annotations are used by + magicgui to generate widgets for each parameter. Best + suited for simple processing tasks - usually taking + in and/or returning a layer. +- a `magic_factory` decorated function. The `magic_factory` + decorator allows us to customize aspects of the resulting + GUI, including the widgets associated with each parameter. + Best used when you have a very simple processing task, + but want some control over the autogenerated widgets. If you + find yourself needing to define lots of nested functions to achieve + your functionality, maybe look at the `Container` widget! +- a `magicgui.widgets.Container` subclass. This provides lots + of flexibility and customization options while still supporting + `magicgui` widgets and convenience methods for creating widgets + from type annotations. If you want to customize your widgets and + connect callbacks, this is the best widget option for you. +- a `QWidget` subclass. This provides maximal flexibility but requires + full specification of widget layouts, callbacks, events, etc. + +References: +- Widget specification: https://napari.org/stable/plugins/guides.html?#widgets +- magicgui docs: https://pyapp-kit.github.io/magicgui/ Replace code below according to your needs. """ from typing import TYPE_CHECKING from magicgui import magic_factory +from magicgui.widgets import CheckBox, Container, create_widget from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget +from skimage.util import img_as_float if TYPE_CHECKING: import napari +# Uses the `autogenerate: true` flag in the plugin manifest +# to indicate it should be wrapped as a magicgui to autogenerate +# a widget. +def threshold_autogenerate_widget( + img: "napari.types.ImageData", + threshold: "float", +) -> "napari.types.LabelsData": + return img_as_float(img) > threshold + + +# the magic_factory decorator lets us customize aspects of our widget +# we specify a widget type for the threshold parameter +# and use auto_call=True so the function is called whenever +# the value of a parameter changes +@magic_factory( + threshold={"widget_type": "FloatSlider", "max": 1}, auto_call=True +) +def threshold_magic_widget( + img_layer: "napari.layers.Image", threshold: "float" +) -> "napari.types.LabelsData": + return img_as_float(img_layer.data) > threshold + + +# if we want even more control over our widget, we can use +# magicgui `Container` +class ImageThreshold(Container): + def __init__(self, viewer: "napari.viewer.Viewer"): + super().__init__() + self._viewer = viewer + # use create_widget to generate widgets from type annotations + self._image_layer_combo = create_widget( + label="Image", annotation="napari.layers.Image" + ) + self._threshold_slider = create_widget( + label="Threshold", annotation=float, widget_type="FloatSlider" + ) + self._threshold_slider.min = 0 + self._threshold_slider.max = 1 + # use magicgui widgets directly + self._invert_checkbox = CheckBox(text="Keep pixels below threshold") + + # connect your own callbacks + self._threshold_slider.changed.connect(self._threshold_im) + self._invert_checkbox.changed.connect(self._threshold_im) + + # append into/extend the container with your widgets + self.extend( + [ + self._image_layer_combo, + self._threshold_slider, + self._invert_checkbox, + ] + ) + + def _threshold_im(self): + image_layer = self._image_layer_combo.value + if image_layer is None: + return + + image = img_as_float(image_layer.data) + name = image_layer.name + "_thresholded" + threshold = self._threshold_slider.value + if self._invert_checkbox.value: + thresholded = image < threshold + else: + thresholded = image > threshold + if name in self._viewer.layers: + self._viewer.layers[name].data = thresholded + else: + self._viewer.add_labels(thresholded, name=name) + + class ExampleQWidget(QWidget): # your QWidget.__init__ can optionally request the napari viewer instance - # in one of two ways: - # 1. use a parameter called `napari_viewer`, as done here - # 2. use a type annotation of 'napari.viewer.Viewer' for any parameter - def __init__(self, napari_viewer): + # use a type annotation of 'napari.viewer.Viewer' for any parameter + def __init__(self, viewer: "napari.viewer.Viewer"): super().__init__() - self.viewer = napari_viewer + self.viewer = viewer btn = QPushButton("Click me!") btn.clicked.connect(self._on_click) @@ -32,15 +126,3 @@ def __init__(self, napari_viewer): def _on_click(self): print("napari has", len(self.viewer.layers), "layers") - - -@magic_factory -def example_magic_widget(img_layer: "napari.layers.Image"): - print(f"you have selected {img_layer}") - - -# Uses the `autogenerate: true` flag in the plugin manifest -# to indicate it should be wrapped as a magicgui to autogenerate -# a widget. -def example_function_widget(img_layer: "napari.layers.Image"): - print(f"you have selected {img_layer}") diff --git a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/napari.yaml b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/napari.yaml index 8c549a8..3daf333 100644 --- a/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/napari.yaml +++ b/{{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/napari.yaml @@ -18,15 +18,18 @@ contributions: - id: {{cookiecutter.plugin_name}}.make_sample_data python_name: {{cookiecutter.module_name}}._sample_data:make_sample_data title: Load sample data from {{cookiecutter.display_name}}{% endif %}{% if cookiecutter.include_widget_plugin == 'y' %} + - id: {{cookiecutter.plugin_name}}.make_container_widget + python_name: {{cookiecutter.module_name}}:ImageThreshold + title: Make threshold Container widget + - id: {{cookiecutter.plugin_name}}.make_magic_widget + python_name: {{cookiecutter.module_name}}:threshold_magic_widget + title: Make threshold magic widget + - id: {{cookiecutter.plugin_name}}.make_function_widget + python_name: {{cookiecutter.module_name}}:threshold_autogenerate_widget + title: Make threshold function widget{% endif %}{% if cookiecutter.include_reader_plugin == 'y' %} - id: {{cookiecutter.plugin_name}}.make_qwidget - python_name: {{cookiecutter.module_name}}._widget:ExampleQWidget + python_name: {{cookiecutter.module_name}}:ExampleQWidget title: Make example QWidget - - id: {{cookiecutter.plugin_name}}.make_magic_widget - python_name: {{cookiecutter.module_name}}._widget:example_magic_widget - title: Make example magic widget - - id: {{cookiecutter.plugin_name}}.make_func_widget - python_name: {{cookiecutter.module_name}}._widget:example_function_widget - title: Make example function widget{% endif %}{% if cookiecutter.include_reader_plugin == 'y' %} readers: - command: {{cookiecutter.plugin_name}}.get_reader accepts_directories: false @@ -43,10 +46,12 @@ contributions: display_name: {{cookiecutter.display_name}} key: unique_id.1{% endif %}{% if cookiecutter.include_widget_plugin == 'y' %} widgets: - - command: {{cookiecutter.plugin_name}}.make_qwidget - display_name: Example QWidget + - command: {{cookiecutter.plugin_name}}.make_container_widget + display_name: Container Threshold - command: {{cookiecutter.plugin_name}}.make_magic_widget - display_name: Example Magic Widget - - command: {{cookiecutter.plugin_name}}.make_func_widget + display_name: Magic Threshold + - command: {{cookiecutter.plugin_name}}.make_function_widget autogenerate: true - display_name: Example Function Widget{% endif %} + display_name: Autogenerate Threshold + - command: {{cookiecutter.plugin_name}}.make_qwidget + display_name: Example QWidget{% endif %}