Run scikit-ops ops from napari.
Adds one dock widget, Ops, which lists every op skop can discover, builds a form for the chosen one, and runs it in its own Appose environment — off the GUI thread, with progress and cancellation, and with its outputs added to the viewer as the layer types their roles call for.
uv sync --all-groups
uv run napariThen Plugins → scikit-ops → Ops.
An op is an ordinary Python function carrying enough annotation to describe itself. This package reads that description and nothing else — it knows about no op in particular.
| From the op | Becomes |
|---|---|
| a parameter's type | a magicgui widget |
Annotated[..., {...}] UI hints |
that widget's type, range, step |
the docstring's Args: section |
the widget's tooltip |
an ImageData/LabelsData/… role |
a layer combo box, not a file picker |
an Axes(...) declaration |
an axes field, a mapping per slot, a choice per leftover axis |
an Out[...] parameter |
nothing — a user is never asked for a buffer |
the op's env |
the Appose environment it runs in |
skop.progress(...) |
the progress bar |
skop.cancel_requested() |
the Cancel button |
| an output's role | an Image / Labels / Points / … layer |
| an output with no role | a row in the results panel |
| a failure | a napari error notification, with traceback |
That last pair is the point of skop's roles. otsu returns LabelsData, so
its result arrives as a Labels layer rather than a grayscale image; unseg
returns two LabelsData and two ints, so it produces two Labels layers and
two rows of counts.
src/skop_napari/_roles.py Role -> napari type; the only napari-specific part
src/skop_napari/_axes.py working out what a layer's axes are (no Qt)
src/skop_napari/_plans.py the axes field, slot mapping and axis dispositions
src/skop_napari/_widget.py OpSpec -> magicgui widgets (imports no napari)
src/skop_napari/_run.py running an op off the GUI thread
src/skop_napari/_panel.py the Ops widget itself
_widget.py deliberately imports nothing from napari: the one napari-shaped
decision, what type each parameter should be rendered as, is passed in as
annotation_for. If a second magicgui host ever turns up, that module is what
moves out.
The long form, with the alternatives considered and the evidence behind each
call, is in docs/. In brief:
One widget, not one per op. npe2 wants a static manifest, while skop finds
its ops by importing them, so a contribution per op would mean generating the
manifest at build time and losing the ability to drop in a new op and see it
appear. The napari team is working on dynamic registration; _panel.py is
what gets split up when it lands.
Widgets are built one parameter at a time, rather than by handing magicgui
a synthesized function. Real ops contain parameters magicgui cannot render —
unseg has tuple[int, ...] — and one of those must not take the whole panel
down. An unrenderable parameter with a default is quietly left at its default
and reported in the notes; one without a default disables the Run button and
says why.
Roles are guessed here, never in skop. skop reports role is None for an
unannotated array rather than assuming it is an image. This package makes that
assumption, because it has a viewer to make it for.
So are axes. An op declaring Axes("y", "x", "c?") consumes two or three
axes, so handing it a (z, y, x) stack means deciding something: which axes
feed the op, and what becomes of the rest. skop supplies a default answer and
flags where it looks doubtful, but forbids nothing — whether a stack should be
processed plane by plane or whole is a property of the experiment, not of the
op. This package works out what the layer's axes are in the first place — from
its metadata, an xarray's dims, NGFF axes, or napari's own axis_labels and
rgb — and leaves unnamed whatever none of those say, which napari calls -3
-2 -1 and so does the panel. The mapping is then seeded from the axes the
viewer is displaying, whatever they are called, and edited: a combo per slot,
and iterate/current-position/hand-to-the-op per leftover axis. Real names are
written back onto the layer and stamped onto results, so a session gets more
certain as it goes.
Errors go to napari, not into the panel. An op fails in another process,
running another interpreter, so the interesting part of the failure is that
process's traceback. notification_manager.receive_error carries it, keeps it
in the notification history, and looks like every other napari error — none of
which a one-line field in the widget could do.
Environment building reports through Appose's builder callbacks.
Runner.subscribe_build_progress/_output/_error feed the same progress bar
the op itself uses, since building is by far the slowest part of a first run
and happens inside run() with nothing else to show for it.
A fresh build drives the bar like this:
Preparing environment: probe (indeterminate)
Installing conda packages 0/30
Installing conda packages 30/30
Done 1/1
✔ The default environment has been installed.
Two things worth knowing about those channels:
- Subscribing to progress is what enables it. Appose's
PixiInstallMonitoronly wires up when a progress subscriber exists, and it works by running pixi under-vvand reading the phase transitions out of its log. Appose 0.11 has no monitor at all and reports only a final summary line, which is why both projects source Appose from its main branch for now. subscribe_erroris the stderr stream, not a failure report. Pixi writes ordinary status there, success message included — and under the-vvthe monitor injects, its entire debug log too. So this channel is logged in full and filtered before it reaches the progress bar: lines that look like log records (DEBUG pixi_config: ...) are noise, and only human-facing lines are shown. A build that genuinely fails raises out ofrun()and takes the normal error path.
The Preparing environment: <id> label covers the gap before pixi says
anything, which on a cold cache can be a while.
Ops run in another process, so a failure there is easy to lose. Three
increasingly loud ways to see what is going on, all from a terminal-launched
napari (uv run napari) or its built-in console:
1. The panel's own log — what it ran, with what, and what came back:
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("skop_napari").setLevel(logging.INFO)2. The worker's stdout and stderr — what the op is doing inside its own process, including anything it prints:
SKOP_NAPARI_DEBUG=1 uv run naparinapari builds this widget itself, so there is no call site at which to pass
Runner(debug=True); the environment variable is the way in.
3. Run the op without a GUI at all, which removes every layer of this package from the picture:
import skop
from skop.ops.threshold import otsu
with skop.Runner(debug=True) as runner:
print(runner.run(otsu, image=my_array))Errors from an op become napari error notifications, carrying the worker process's traceback — check the notification button in the status bar, since a notification that has come and gone is still in that list.
uv sync --all-groups
uv run pytest
uv run ruff check --fix && uv run ruff formatThe tests drive a real napari viewer and run real ops in the minimal and
skimage Appose environments, which are built on first use.
Note that they need a real Qt platform: QT_QPA_PLATFORM=offscreen has no GL
context, and napari's vispy canvas segfaults on startup without one.