Creating a napari plugin#
Overview#
In this tutorial, we will make a napari analysis plugin from the
detect_spots()
function we wrote in the first part of this practical session.
The primary steps in making a napari plugin are as follows:
Choose which manifest contribution(s) your plugin requires
Create your repository using the napari cookiecutter template
Implement your contributions
Share your plugin with the community
In the following sections, we will work through steps (1) - (3). For step (4), you can refer to the in depth plugin tutorial, or the instructions on napari.org. You can also use the lecture slides as reference material if you’d like.
Choosing a contribution#
A contribution is a construct in napari.yaml
(the manifest file), that napari
uses for each specific type of plugin. Each contribution conforms to a function
signature, i.e. the function linked to the contribution defines what napari
provides to the plugin (e.g., data and parameters) and what the plugin returns
to napari. napari is then able to use the functions pointed to in napari.yaml
to carry out the plugin tasks. Please see the
contribution reference and
contribution guide for more details.
Many plugins will declare multiple contributions to provide all of the desired
functionality.
The current categories of contributions are described below:
reader: allows loading of specified data formats into napari layers;
writer: this allows layer data to be written to disk in specified formats;
sample data: allows developers to provide users with sample data with their plugin;
widget: allows custom Qt widgets (GUIs) to be added to napari, either from a
magic_factory
widget, a plain function, or a subclass of QWidget;theme: allows customization of the entire napari viewer appearance e.g. light theme or dark theme.
In this tutorial, we will create a spot detection plugin by implementing a
widget contribution with the spot detection function (detect_spots()
) we
created in the first part of this practical session.
Implementing a function GUI#
In this step, we will implement our detect_spots()
function as a plugin
contribution. First, we will add our spot detection function to the plugin
package. Then, we will add the type annotations to the function to so that
napari can infer the correct GUI elements to add to our plugin.
To edit your plugin source code, open an integrated development environment (VSCode is a good, free option) or text editor.
In VSCode, open the directory you created with
cookiecutter
in the section above.From the “File” menu, select “Open…”
Navigate to and select the directory you created with
cookiecutter
(~/Documents/napari-spot-detector
if you called your pluginnapari-spot-detector)
.
You should now see your plugin directory in the “Explorer” pane in the left hand side of the window. You can double click on folders to expand them and files to open them in the editor.
Open the
<module_name>/_widget.py
file using VSCode by double clicking on it in the “Explorer” pane.You will see that it has already been populated with a few code blocks by cookiecutter.
At the top, you see the imports. You can leave unchanged for now.
from typing import TYPE_CHECKING from magicgui import magic_factory from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget if TYPE_CHECKING: import napari
Next, you see three different ways to add widgets to napari.
The first subclasses
QWidget
directly. This option provides maximum flexibility, but means you have to take care of adding all the different GUI elements, laying them out, and hooking them up to the viewer.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): super().__init__() self.viewer = napari_viewer btn = QPushButton("Click me!") btn.clicked.connect(self._on_click) self.setLayout(QHBoxLayout()) self.layout().addWidget(btn) def _on_click(self): print("napari has", len(self.viewer.layers), "layers")
The second option is to write a
magic_factory
decorated function. You might recognize this from ourintensify
widget in the lecture. With minimal extra work you can configure options for your GUI elements, such as min and max values for integers, or choices for dropdown boxes. See the magicgui configuration docs for details on what you can configure in the decorator.@magic_factory def example_magic_widget(img_layer: "napari.layers.Image"): print(f"you have selected {img_layer}")
Finally, you see the what looks like just a plain function. We don’t need complex GUI interactions for our plugin, and we don’t want to have to lay out the GUI ourselves, so we will modify this to incorporate our
detect_spots
function.# 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}")
Find the
Command
ID innapari.yaml
that points toexample_function_widget
, and then find thatCommand
ID in theWidgets
contribution section. Note that unlike the otherwidget
contributions, this one includesautogenerate: true
.- command: napari-spot-detector.make_func_widget autogenerate: true display_name: Example Function Widget
This means our function doesn’t need to know anything about magicgui. If we provide type annotations to the parameters, the GUI widgets will be generated for us without even a decorator!
Let’s edit
example_function_widget
to do our spot detection.
You can delete everything above
example_function_widget
in_widget.py
if you want. If you do, make sure to delete the associatedCommands
andWidget
contributions, and the imports innapari_spot_detector/__init__.py
!Copy the
gaussian_high_pass()
anddetect_spots()
functions from your notebook from the first part of the tutorial and paste it where the example functions were (the ones you deleted in the previous step).Next, we need to modify
detect_spots()
to return the necessary layer data so that napari can create a new Points layer with our detected spots. Ifdetect_spots()
returns aLayerDataTuple
, napari will add a new layer to the viewer using the data in theLayerDataTuple
. For more information on theLayerDataTuple
type, please see the docs.The layer data tuple should be:
(layer_data, layer_metadata, layer_type)
layer_data
: the data to be displayed in the new layer (i.e., the points coordinates)layer_metadata
: the display options for the layer stored as a dictionary. Some options to consider:symbol
,size
layer_type
: the name of the layer type as a string (i.e.,'Points'
)
Add type annotations to the function parameters (inputs). napari (via magicgui) will infer the required GUI elements from the type annotations. We have to add annotations to both the parameters (i.e., inputs to the function) and the return type.
Annotate the Return type as
"napari.types.LayerDataTuple"
.Add the required imports for the
scipy.ndimage
module andscikit-image
blob_log()
function to the top of the file.from scipy import ndimage as ndi
from skimage.feature import blob_log
_function.py solution#
See below for an example implementation of the _widget.py
file, and the
associated changes to napari.yaml
# _widget.py
from typing import TYPE_CHECKING
import numpy as np
from scipy import ndimage as ndi
from skimage.feature import blob_log
if TYPE_CHECKING:
import napari
def gaussian_high_pass(image: np.ndarray, sigma: float = 2):
"""Apply a gaussian high pass filter to an image.
Parameters
----------
image : np.ndarray
The image to be filtered.
sigma : float
The sigma (width) of the gaussian filter to be applied.
The default value is 2.
Returns
-------
high_passed_im : np.ndarray
The image with the high pass filter applied
"""
low_pass = ndi.gaussian_filter(image, sigma)
high_passed_im = image - low_pass
return high_passed_im
def detect_spots(
image: "napari.types.ImageData",
high_pass_sigma: float = 2,
spot_threshold: float = 0.01,
blob_sigma: float = 2
) -> "napari.types.LayerDataTuple":
"""Apply a gaussian high pass filter to an image.
Parameters
----------
image : napari.types.ImageData
The image in which to detect the spots.
high_pass_sigma : float
The sigma (width) of the gaussian filter to be applied.
The default value is 2.
spot_threshold : float
The threshold to be passed to the blob detector.
The default value is 0.01.
blob_sigma: float
The expected sigma (width) of the spots. This parameter
is passed to the "max_sigma" parameter of the blob
detector.
Returns
-------
layer_data : napari.types.LayerDataTuple
The layer data tuple to create a points layer
with the spot coordinates.
"""
# filter the image
filtered_spots = gaussian_high_pass(image, high_pass_sigma)
# detect the spots
blobs_log = blob_log(
filtered_spots,
max_sigma=blob_sigma,
num_sigma=1,
threshold=spot_threshold
)
points_coords = blobs_log[:, 0:2]
sizes = 3 * blobs_log[:, 2]
layer_data = (
points_coords,
{
"face_color": "magenta",
"size": sizes
},
"Points"
)
return layer_data
# napari_spot_detector/__init__.py
__version__ = "0.0.1"
#napari.yaml
name: napari-spot-detector
display_name: Spot Detection
contributions:
commands:
- id: napari-spot-detector.make_func_widget
python_name: napari_spot_detector._widget:detect_spots
title: Make spot detection widget
widgets:
- command: napari-spot-detector.make_func_widget
autogenerate: true
display_name: Spot Detection
Testing/Installing your plugin#
To test and use our plugin, we need to install it in our Python environment.
First, return to your terminal and verify you have the napari-tutorial
environment activated. Then, navigate to the directory that you created with the
cookiecutter. For example, if you named your plugin napari-spot-detector
, you
would enter the following into your terminal.
cd ~/Documents/napari-spot-detector
Then, we install the plugin with pip. pip is the package installer for Python
(see the documentation for more information).
We will use the -e
option to install in “editable” mode. This means that when
we make a change to our source code, it will update the installed package the
next time it is imported, without us having to reinstall it.
pip install -e .
To confirm if your installation completed successfully, you can launch napari from the command line.
napari
Once napari is open, you can open your plugin from the “Plugin” menu. You can
test your plugin by locating the spots image from the tutorial notebooks folder
we downloaded at the beginning of this tutorial in the File browser
(<path to notebook folder>/data/stardist_masks.tif
), dragging the image into
the napari viewer, and try running the plugin.
Congratulations! You have made your first napari plugin!
Test upload to PyPI#
First, build a wheel for your package. A wheel is a pre-compiled version of your package that makes installation easy. For a pure-Python package like this it’s quite simple, but can get a bit more complicated if you want to include a compiled code such as a Python C-extension.
We’re going to use build
to build the wheel. This will invoke the
build-backend
specified in your pyproject.toml
and create the wheel.
pip install build
python -m build
This should succeed and put your wheel (.whl file) in the dist
directory,
along with a tarball of your source (also called an sdist
).
dist
├── [your-package-name]-0.0.1.tar.gz
└── [your_package_name]-0.0.1-py3-none-any.whl
Tip
A wheel is just a zip archive. To install it, pip
(mostly) just unzips it in
the right spot. You can unzip it yourself to see exactly what is inside.
Now we’ll use twine
to upload your
package to the (test) Python Package Index (TestPyPI).
There are a number of ways to upload to PyPI, but twine is nice.
pip install twine
twine upload -r testpypi dist/*
This will prompt for a username/password, but you don’t have one! Head over to https://test.pypi.org to create an account. This system is entirely separate from the “real” PyPI, so you can feel safe uploading test packages, etc.
You also need to make an API token for your account. You can do this on the “Account Settings” page.
Give the token a name. For your fist project, you need to create a token with “Entire account” scope, but you can limit this to just your project after the first upload.
Conveniently, after making the token PyPI will give you some text to paste into
a file called $HOME/.pypirc
that twine will reference. You can also specify
this info via command line or environment variables. It should look something
like this:
# ~/.pypirc
[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weX...[kinda long token here]
Now, try to upload again:
twine upload -r testpypi dist/*
It should succeed this time, and show you a message similar to this:
Uploading distributions to https://test.pypi.org/legacy/
Uploading napari_testpypi-0.0.1-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 12.3/12.3 kB • 00:00 • ?
View at:
https://test.pypi.org/project/napari-testpypi/0.0.1/
Click the provided link, and you’ll see your project page live on TestPyPI!
This is the main landing page for your package, and you can control much of
what is shown. Most of it comes from your project README, and some comes from
the metadata you put in pyproject.toml
.
Note
PyPI has a flat namespace, so you need to make sure your project name does not conflict with an existing package before uploading.
You can also use twine to upload to the real PyPI of course, just use -r pypi
(or leave this off). You will need to create a separate account at
https://pypi.org, and generate and provide a different API token.
After publishing to PyPI, the napari plugin browser and napari-hub will index
your plugin automatically after 5 minutes or so. We recommend additionally
distributing your plugin package via conda-forge
,
but will not cover the specifics here. In short, adding a package to
conda-forge is accomplished by creating a pull request to the conda-forge
“staged recipes” repository. Be on the lookout for GitHub notifications,
because you might find someone else has done this step for you!
Bonus exercises#
In case you have finished all of the exercises with some time to spare, we have provided some ideas for ways that you can extend the plugin. Please feel free to give them a go and ask the teaching team if you have any questions.
Add sample data to your plugin. To do so, you would need to implement the sample data contribution
Add an option to your
detect_spots()
function plugin to return the filtered image in addition to the points layer.Add some tests to the
_tests/test_widget.py
file.Upload your plugin to github
Start your own plugin
Consult with the teaching team about integrating napari into your workflow