Skip to content

Commit

Permalink
Add Container example and expand other examples for widgets (#168)
Browse files Browse the repository at this point in the history
* Add container example and more docs

* Fix annotation for qwidget

* Fix some tests

* Fix import

* Update precommit stuff

* Add container test

* Sort imports

* Document the test

* Threshold on checkbox changed

* Fix test

* Rename some functions and cast images as float before thresholding

* Add a bit more detail to the docstring

* Try to fix precommit

* Remove whitespace...

* Add float to autogenerate test

* Remove unused import
  • Loading branch information
DragaDoncila authored Oct 27, 2023
1 parent 8b1a768 commit 350abd8
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 50 deletions.
1 change: 1 addition & 0 deletions {{cookiecutter.plugin_name}}/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand All @@ -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 -%}
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
122 changes: 102 additions & 20 deletions {{cookiecutter.plugin_name}}/src/{{cookiecutter.module_name}}/_widget.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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}")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 %}

0 comments on commit 350abd8

Please sign in to comment.