Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

3. Custom Widgets & Interactions

Goal: Add interactive GUI widgets, custom keybindings, layer event callbacks, and mouse drag interactions to napari — turning analysis functions into interactive tools.

Setup

Let’s load the spots and nuclei data from Block 2 and get a fresh viewer:

import napari
import numpy as np
from napari.utils import nbscreenshot
from pathlib import Path
from skimage.io import imread

# Cross-environment path
data_dir = next(p for p in [Path('extend/data'), Path('data')] if p.exists())
nuclei = imread(data_dir / 'nuclei_cropped.tif')
spots = imread(data_dir / 'spots_cropped.tif')

viewer = napari.Viewer()
viewer.add_image(nuclei, name='nuclei', colormap='gray')
viewer.add_image(spots, name='spots', colormap='magenta', blending='additive')
<Image layer 'spots' at 0x7f0608367c50>
Loading...

1. Writing analysis functions (10 min)

First, let’s write the analysis function we’ll turn into a widget. The spots image has some background autofluorescence — we can clean it up with a gaussian high-pass filter: subtract a blurred version of the image from the original, keeping only the sharp, spot-like features.

from scipy import ndimage as ndi

def gaussian_high_pass(image, sigma):
    """Remove broad background signal by subtracting a gaussian-blurred copy.

    Parameters
    ----------
    image : np.ndarray
        The image to filter.
    sigma : float
        Width of the gaussian — larger values remove broader features.

    Returns
    -------
    high_passed : np.ndarray
        The filtered image with background suppressed.
    """
    low_pass = ndi.gaussian_filter(image, sigma)
    high_passed = (image - low_pass).clip(0)
    return high_passed

Let’s test it on our spots data with sigma=2:

high_passed_spots = gaussian_high_pass(spots, 3)

viewer.add_image(
    high_passed_spots,
    name='filtered spots',
    colormap='green',
    blending='additive'
)
<Image layer 'filtered spots' at 0x7f062f01cb90>
Loading...

The spots stand out much more clearly against the background! But what if we want to try a different sigma value? We’d have to re-run the cell manually each time — not exactly an interactive exploration.

2. Interactive filtering with magicgui (25 min)

In Block 2 we wrote a gaussian_high_pass function to clean up the spots image — but changing the sigma parameter meant re-running a cell each time. Let’s make it interactive with magicgui.

The @magicgui decorator reads type annotations on your function parameters and automatically generates corresponding GUI widgets:

from magicgui import magicgui
from napari.types import ImageData
from scipy import ndimage as ndi

@magicgui
def gaussian_high_pass(
    image: ImageData, sigma: float = 2.0
) -> ImageData:
    """Remove broad background signal by subtracting a gaussian-blurred copy.

    Parameters
    ----------
    image : np.ndarray
        The image to filter.
    sigma : float
        Width of the gaussian — larger values remove broader features.
    """
    low_pass = ndi.gaussian_filter(image, sigma)
    return (image - low_pass).clip(0)
viewer.window.add_dock_widget(gaussian_high_pass)
<napari._qt.widgets.qt_viewer_dock_widget.QtViewerDockWidget at 0x7f05e1f91770>
Loading...

Notice what just happened: magicgui read the ImageData type annotation and automatically created a dropdown that lists only image layers. The sigma parameter became a spin box. And because the return type is also ImageData, the result is automatically added as a new image layer.

Press the Run button — the filtered result appears. Change the sigma value and press Run again: the layer updates in place.

The gaussian_high_pass object is both a widget and a callable function:

# Read the current sigma value from the widget
print(gaussian_high_pass.sigma.value)

# Call it as a plain function — still works!
test_output = gaussian_high_pass(spots, sigma=5)
print(f'Output shape: {test_output.shape}')
2.0
Output shape: (492, 494)

This means you can use the same function in a script or as a widget in napari — no code duplication.

Adding sliders and auto-call

Let’s make it even more interactive: replace the spin box with a slider and have the function run automatically whenever we move it.

First, remove the old widget:

viewer.window.remove_dock_widget('all')

Now recreate it with widget configuration:

@magicgui(
    auto_call=True,
    sigma={"widget_type": "FloatSlider", "min": 0, "max": 20}
)
def gaussian_high_pass(
    image: ImageData, sigma: float = 2.0
) -> ImageData:
    """Remove broad background signal by subtracting a gaussian-blurred copy."""
    low_pass = ndi.gaussian_filter(image, sigma)
    return (image - low_pass).clip(0)

viewer.window.add_dock_widget(gaussian_high_pass)
<napari._qt.widgets.qt_viewer_dock_widget.QtViewerDockWidget at 0x7f05e1398af0>
Loading...

Now drag the slider — the filter updates instantly! auto_call=True means the function runs whenever any parameter changes, no Run button needed.

You can also set widget values programmatically:

gaussian_high_pass.sigma.value = 8.0

A more complete example: spot detection

Let’s build a widget for the full spot detection workflow. We’ll use skimage.feature.blob_log to detect spots and return them as a Points layer with custom styling.

When a function returns a LayerDataTuple, napari creates a new layer using whatever data and visualization settings you provide:

viewer.window.remove_dock_widget('all')

from skimage.feature import blob_log
from napari.types import LayerDataTuple

@magicgui(
    auto_call=True,
    high_pass_sigma={"widget_type": "FloatSlider", "min": 0, "max": 20},
    spot_threshold={"widget_type": "FloatSlider", "min": 0.01, "max": 1.0, "step": 0.01},
    blob_sigma={"widget_type": "FloatSlider", "min": 1, "max": 20},
)
def detect_spots(
    image: ImageData,
    high_pass_sigma: float = 2.0,
    spot_threshold: float = 0.1,
    blob_sigma: float = 5.0,
) -> LayerDataTuple:
    """Detect spots in an image using Laplacian of Gaussian.

    Parameters
    ----------
    image : np.ndarray
        The image in which to detect spots.
    high_pass_sigma : float
        Sigma for the background-suppressing high-pass filter.
    spot_threshold : float
        Relative threshold for spot detection (lower = more spots).
    blob_sigma : float
        Expected spot size — passed as max_sigma to the detector.
    """
    # Suppress background
    filtered = gaussian_high_pass(image, high_pass_sigma)

    # Detect spots with Laplacian of Gaussian
    blobs = blob_log(
        filtered,
        max_sigma=blob_sigma,
        threshold=None,
        threshold_rel=spot_threshold,
    )

    # Convert to points: first two columns are y, x coordinates
    coords = blobs[:, :2]
    # Third column is the detected sigma — convert to diameters for sizing
    sizes = 2 * np.sqrt(2) * blobs[:, 2]

    return (coords, {"size": sizes, "face_color": "yellow"}, "Points")
viewer.window.add_dock_widget(detect_spots, area="right")
<napari._qt.widgets.qt_viewer_dock_widget.QtViewerDockWidget at 0x7f05e10b4910>
Loading...

Try adjusting the sliders. The spots update in real time — change spot_threshold to detect more or fewer spots, adjust blob_sigma to match the spot size in your image.

3. Custom keybindings (15 min)

Keybindings let you trigger actions with keyboard shortcuts. napari makes this remarkably easy with the bind_key decorator.

Let’s bind Shift-D to report how many spots were detected in the current Points layer. We’ll attach it to the Points layer type so it only fires when a Points layer is active:

from napari.layers import Points
from napari.utils.notifications import show_info

@Points.bind_key("Shift-D")
def report_spot_count(points_layer: Points):
    """Print the number of detected spots in the active Points layer."""
    count = len(points_layer.data)
    show_info(f"Detected {count} spots")

Now select the Points layer created by detect_spots and press Shift-D. You should see a notification pop up in the viewer!

Keybindings can also be attached to the viewer (fires regardless of which layer is active):

@viewer.bind_key('Shift-R')
def run_detector(viewer):
    """Re-run spot detection with current settings."""
    detect_spots(viewer.layers['spots'].data)

4. Layer events (15 min)

napari layers emit events when their properties change — data, colormap, opacity, even individual point positions. You can connect custom functions (callbacks) to these events.

Let’s demonstrate with a cool example: warping an image when control points are moved. This has been adopted from the scikit-image Use thin-plate splines for image warping example.

import skimage as ski
from functools import partial

# Start a new viewer to get rid of the auto-running functions
viewer = napari.Viewer()

# Create a checkerboard image with four control points
image = ski.data.checkerboard()
src = np.array([[66, 66], [133, 66], [66, 133], [133, 133]])

viewer.add_image(image, name='checkerboard')
viewer.add_points(src, name='source_points', symbol='+', face_color='red', size=5)
moving_points = viewer.add_points(src.copy(), name='moving_points')
Loading...

The warp function

We’ll use thin-plate splines to warp the image based on point positions:

def warp(im_layer, src, dst):
    """Warp an image from source to destination points using TPS."""
    tps = ski.transform.ThinPlateSplineTransform.from_estimate(dst, src)
    # tps.from_estimate(dst, src)
    warped = ski.transform.warp(image, tps)
    im_layer.data = (warped * 255).astype(image.dtype)

# Pre-bind the image layer and source points
warp_checkerboard = partial(warp, viewer.layers['checkerboard'], src)

Connecting to the data event

We want the warp to happen whenever a point moves. We connect a callback to the layer’s data event:

def warp_on_point_changed(event):
    """Callback: warp the image when a point is moved."""
    if event.action == 'changed':
        warp_checkerboard(event.value)

moving_points.events.data.connect(warp_on_point_changed)
<function __main__.warp_on_point_changed(event)>

Now select the moving_points layer, switch to the Select points tool, and drag a point. The image warps when you release the mouse.

5. Mouse callbacks (15 min)

Layer events fire when a change completes. But what if you want to react while the user is dragging? That’s where mouse callbacks come in.

Mouse callbacks use a generator pattern — they yield to separate the logic for mouse press, drag, and release:

def some_mouse_callback(layer, event):
    # --- Mouse press ---
    print("Mouse pressed")
    yield  # ← this pauses; execution resumes on drag

    # --- Mouse drag ---
    while event.type == 'mouse_move':
        print("Dragging...")
        yield  # ← yields control each frame

    # --- Mouse release ---
    print("Mouse released")

Warping on drag

Let’s replace the layer event callback with a mouse drag callback that warps the image as you drag a point:

def warp_on_move(points_layer, event):
    """Warp the image as the user drags a control point."""
    # --- Mouse press ---
    yield

    # --- Mouse drag ---
    while event.type == 'mouse_move':
        # Find which point is being dragged
        idx = list(points_layer.selected_data)[-1]

        # Copy and update the dragged point's position
        dst = points_layer.data.copy()
        dst[idx] = event.position

        # Warp the image
        warp_checkerboard(dst)
        yield

    # Nothing to do on mouse release

# Attach the callback to the moving points layer
moving_points.mouse_drag_callbacks.append(warp_on_move)

Now select the moving_points layer and drag a point — the image warps in real time as you move the mouse!

Recap

In this block you learned to:

TechniqueWhat it doesHow to attach
magicguiAuto-generate GUI widgets from functions@magicgui + viewer.window.add_dock_widget()
Custom keybindingsTrigger actions with keyboard shortcuts@Points.bind_key('Shift-D') / @viewer.bind_key('key')
Layer eventsReact to property changeslayer.events.data.connect(callback)
Mouse callbacksReact to mouse drag in real timelayer.mouse_drag_callbacks.append(callback)

In Block 4, we’ll package our detect_spots widget into a pip-installable napari plugin that anyone can use.