A collection of image-processing ops, and skop — the machinery that runs
them.
An op is an ordinary Python function. The same function can be called directly, run in its own isolated environment through Appose, or wrapped in a GUI. Nothing about the op changes between those modes.
Design notes — why the machinery is shaped this way, and what else was
considered — are in docs/.
envs/<env-id>/pixi.toml environment definitions, named and shared between ops
envs/<env-id>/init.py optional; runs in each worker before its I/O loop
src/skop/ op-independent machinery
src/skop/ops/<ns>.py an op namespace, when its ops share one environment
src/skop/ops/<ns>/<op>.py an op namespace, when its ops do not
src/imgops/ napari/magicgui front ends (not published)
Environments are keyed by ID, and several ops may declare the same one. Ops
sharing an environment also share a warm worker process, unless one asks for
exclusive=True.
| Op | Environment | Does |
|---|---|---|
skop.ops.threshold:otsu |
skimage |
Otsu thresholding |
skop.ops.kernels.psf:gaussian_psf |
skimage |
Gaussian PSF, 2D or 3D |
skop.ops.deconvolve.richardson_lucy:richardson_lucy |
skimage |
Richardson-Lucy, on the CPU |
skop.ops.deconvolve.richardson_lucy_cupy:richardson_lucy_cupy |
cupy |
Richardson-Lucy, on an NVIDIA GPU |
skop.ops.segment.cellpose:cellpose |
cellpose |
Cellpose segmentation |
skop.ops.segment.stardist2d:stardist2d_fluo |
stardist-tf |
StarDist 2D, pretrained fluorescence |
skop.ops.segment.stardist2d:stardist2d_he |
stardist-tf |
StarDist 2D, pretrained H&E |
skop.ops.segment.starfun3d:segment_nuclei |
stardist-tf |
StarDist 3D nuclei |
skop.ops.generate:synthetic_nuclei |
skimage |
Synthetic 3D test volume |
skop.ops.segment.unseg:unseg |
unseg-cv |
Unsupervised nuclei + cells |
skop.ops.detect.fastsam:fastsam |
pytorch |
Class-agnostic boxes, via FastSAM |
skop.ops.detect.object_aware_yolo:object_aware_yolo |
segment-everything |
Class-agnostic boxes, via MobileSAMv2 |
skop.ops.toy:* |
minimal |
Exercises for skop itself |
Those are the fully qualified op IDs, as discover() reports them. Callers
import from the namespace instead, and never need to know whether it is one
module or a package of them:
from skop.ops.threshold import otsu
from skop.ops.segment import cellpose, stardist2d_fluostardist2d_fluo and segment_nuclei share one TensorFlow build and one warm
worker — the point of naming environments rather than tying them to ops.
unseg-cv shares nothing with anything: it pins Python 3.9, numpy 1.24 and an
old scikit-image, which is exactly why it needs an environment of its own.
The two Richardson-Lucy ops are the same algorithm against different hardware,
and there are two of them because @op(env=...) is fixed per function — a
backend cannot be chosen at call time. Their signatures are identical and a
test holds them numerically equivalent, so picking one is picking on speed.
To list what the collection currently offers:
import skop
specs, failures = skop.discover()from typing import Annotated, NamedTuple
from skop import op
from skop.types import ImageData, LabelsData
class Result(NamedTuple):
labels: LabelsData
count: int
@op(env="stardist-tf")
def segment(
image: ImageData,
prob_thresh: Annotated[
float, {"widget_type": "FloatSlider", "min": 0.0, "max": 1.0}
] = 0.5,
) -> Result:
"""Segment nuclei."""
from stardist.models import StarDist3D # NB: heavy imports go in the body.
...Two rules, and both exist for the same reason — ops are discovered by importing them into a minimal environment that has little more than numpy:
- Heavy imports go inside the function body. A module-scope
import tensorflowmakes the op undiscoverable. - Type your inputs and outputs with plain types. Annotations are
evaluated at discovery time, so
-> tf.Tensorfails even if the import is in the body.
Multiple outputs come from a NamedTuple: its field names become the names of
the op's outputs. skop.progress(...) and skop.cancel_requested() report
to whoever is running the op, and do nothing when it is called directly.
Every op here passes np.ndarray around, which tells a front end nothing
about how to display it. skop.types adds that missing half:
| Alias | Role | napari shows it as |
|---|---|---|
ImageData |
Role.image |
an Image layer |
LabelsData |
Role.labels |
a Labels layer |
PointsData |
Role.points |
a Points layer |
VectorsData |
Role.vectors |
a Vectors layer |
TracksData |
Role.tracks |
a Tracks layer |
BoxesData |
Role.shapes |
a Shapes layer |
They are Annotated[np.ndarray, ...] aliases, so nothing else changes: the
codec still sees an array, direct callers still get an array, and
ParamSpec.type is still np.ndarray. They compose with the rest —
Out[LabelsData] and Annotated[LabelsData, {"label": "Nuclei"}] both work,
in either order.
Roles are advisory and never guessed. An unannotated array reports
role is None, and deciding what to do with that is the front end's business,
not skop's. Nothing in skop.types imports napari; the names simply mirror
napari.types so that mapping is a lookup rather than a judgement call, and
Role is equally the hook a Fiji or plain-magicgui front end reads.
Ops live in a namespace under skop/ops/, named for what they do. How the
namespace is laid out inside depends on its ops, and the split point is the
environment:
- One module when the ops are variations on one call sharing one
environment — every
skop.ops.thresholdop is a few lines againstskimage.filters, so they sharethreshold.py. - A package, one module per op, when they do not —
skop.ops.segmentspans three environments and each op carries its own models and types, so each gets a file andsegment/__init__.pyre-exports them.
An op needing several files of its own becomes a package
(segment/unseg/__init__.py plus siblings). Underscore-prefixed modules are
private and never imported by discovery, which is where shared helpers and
vendored code belong.
Because from skop.ops.<ns> import <op> reads the same either way, a
namespace can grow from a module into a package without breaking callers.
Following SciJava Ops, an op is one of three forms, declared through its signature:
| Form | Signature | Meaning |
|---|---|---|
| function | def f(image) -> Result |
allocates and returns its output |
| computer | def f(image, out: Out[np.ndarray]) |
fills a buffer the caller owns |
| inplace | def f(image: Mut[np.ndarray]) |
mutates its input |
Out parameters are hidden from generated GUIs — a user is never asked for an
output buffer. Adapting between forms is not implemented yet: a computer or
inplace op must be handed its buffers.
import skop
from skop.ops import toy
# Directly, in this process, using this process's dependencies.
toy.add(2, 3)
# Or in the op's own environment, in a worker process.
with skop.Runner() as runner:
runner.run(toy.add, a=2, b=3)The first run of an environment builds it, which takes a while. Later runs reuse it, since Appose keys environments by name.
Because that build happens inside run(), a caller with a progress bar can
subscribe to it:
runner.subscribe_build_progress(lambda title, current, maximum: ...)
runner.subscribe_build_output(lambda text: ...)
runner.subscribe_build_error(lambda text: ...)These pass straight through to Appose's builder. Progress titles name the
phase — Solving, Installing conda packages, Downloading PyPI packages,
Installing PyPI packages, Done — and come with a real denominator.
Two things worth knowing. Subscribing to progress is what enables it:
Appose's PixiInstallMonitor runs pixi under -vv to read its phase
transitions, and only wires up when a progress subscriber exists. And
build_error is the stderr stream rather than a failure report — pixi
writes its ordinary status there, and under -vv its whole debug log as
well. A build that actually fails raises out of run().
This needs Appose newer than 0.11, which is why pyproject.toml currently
sources it from the main branch.
uv sync --all-groups
uv run pytest
uv run ruff check --fix && uv run ruff formatOps are exercised end to end in their own environments, in test_ops_e2e.py.
Each such test declares what it needs with @pytest.mark.env("<env-id>"), and
is skipped when that environment is not built — so a plain pytest run stays
fast and offline, testing against whatever happens to be installed already.
To run them all, building whatever is missing:
uv run pytest --build-envs # everything; slow the first time
uv run pytest --build-envs -m env # only the op tests
uv run pytest -m "not env" # only the fast onesCI runs --build-envs, since these ops are the whole point of the project and
nothing else validates them against the stacks they target. The flag is also
settable as PYTEST_ADDOPTS=--build-envs, which is handy when the pytest
invocation is not yours to edit.