This release is a pre-release and may not be stable for production use.
scikit-ops
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/. How an op describes itself is
opspec, a separate package kept in this repository.
Install
Not on PyPI yet, so from GitHub:
pip install "git+https://github.com/apposed/scikit-ops"
That brings opspec with it. opspec on its own, standard library only, for a host or plugin that describes ops without running them:
pip install "opspec @ git+https://github.com/apposed/scikit-ops#subdirectory=opspec"
Working on the code instead, from a checkout: uv sync, which installs both,
editable. See Development.
Layout
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
opspec/ how an op describes itself: @op, OpSpec, roles, plans
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.
What's here
| Op | Environment | Does |
|---|---|---|
skop.ops.threshold:* |
skimage |
Global thresholding: Otsu, isodata, Li, mean, minimum, triangle, Yen, multi-Otsu |
skop.ops.smooth:* |
skimage |
Smoothing and denoising: Gaussian, median, mean, Kuwahara, bilateral, TV, wavelet, NL-means, Butterworth |
skop.ops.edges:* |
skimage |
Derivative filters: Sobel, Scharr, Prewitt, Farid, Roberts, Laplace, DoG, unsharp mask, Frangi, Sato, Meijering |
skop.ops.morphology:* |
skimage |
Grayscale morphology: erosion, dilation, opening, closing, white/black tophat |
skop.ops.kernels.psf:gaussian_psf |
skimage |
Gaussian PSF, 2D or 3D |
skop.ops.kernels.paraxial:paraxial_psf |
skimage |
Paraxial PSF, from wavelength and NA |
skop.ops.kernels.paraxial:paraxial_otf |
skimage |
Paraxial OTF, the transform of the above |
skop.ops.kernels.gibson_lanni:gibson_lanni |
sdeconv |
Gibson-Lanni 3D PSF, with spherical aberration |
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.stardist2d:stardist2d_custom |
stardist-tf |
StarDist 2D, model loaded from a directory |
skop.ops.train.stardist2d:train_stardist2d |
stardist-tf |
StarDist 2D training, file pairs in, model path out |
skop.ops.segment.starfun3d:segment_nuclei |
stardist-tf |
StarDist 3D nuclei |
skop.ops.labels:connect |
skimage |
One label per object along an axis, by linking overlaps |
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.detect.yolo:yolo |
pytorch |
Tiled YOLO detection, with automatic GPU batching, confidences and classes |
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_fluo
stardist2d_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 oprunner
specs, failures = oprunner.discover()
For a large image and a YOLO .pt model:
from pathlib import Path
from skop.ops.detect import yolo
with oprunner.Runner() as runner:
found = runner.run(
yolo, image=image, weights=Path("best.pt"),
object_size=100, overlap=0.15, gpu_fraction=0.9,
)
# found.boxes: [min_y, min_x, max_y, max_x]
# found.confidences and found.classes: one value per box
object_size is area in pixels²; None runs the whole image once. Areas
outside 0.1%-50% of the image select square tiles with side
ceil(sqrt(1.5 * object_size / 0.001)). Input size comes from the checkpoint.
Tiles align with the image edges. Class-aware GreedyNMM matches SAHI 0.12.8:
same-class boxes with IoS at least merge_threshold become an enclosing box
with the maximum confidence. This includes matches within a tile. The default
merge threshold is 0.5, independent of the per-tile NMS iou parameter.
Outputs follow class order, then SAHI's keeper order within each class.
A multiscale grid finds nearby candidates without an all-pairs matrix; the
runtime needs no SAHI dependency. To run the optional reference comparisons:
uv run --with sahi==0.12.8 pytest tests/test_yolo.py -k sahi
GPU batches are calibrated after warm-up, with intermediate sizes before larger batches. Measured peaks adjust the size up or down; an out-of-memory retry establishes an upper bound rather than permanently halving throughput. CUDA measures the reserved allocator footprint; MPS samples driver memory. One CPU batch is prepared ahead of inference, using pinned buffers for CUDA. Inference transfers only detection data back to the host. The image must fit in host RAM; only the current resized batch enters the GPU.
Writing an op
from typing import Annotated, NamedTuple
import numpy as np
from opspec.op import op
from opspec.types import ImageOf, LabelsOf
class Result(NamedTuple):
labels: LabelsOf[np.ndarray]
count: int
@op(env="stardist-tf")
def segment(
image: ImageOf[np.ndarray],
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. progress(...) and cancel_requested(), from
opspec.runtime, report to whoever is running the op, and do nothing when it
is called directly.
Saying what an array is
Every op here passes np.ndarray around, which tells a front end nothing
about how to display it. opspec's types add that missing half:
| Write | Role | napari shows it as |
|---|---|---|
ImageOf[np.ndarray] |
Role.image |
an Image layer |
LabelsOf[np.ndarray] |
Role.labels |
a Labels layer |
PointsOf[np.ndarray] |
Role.points |
a Points layer |
VectorsOf[np.ndarray] |
Role.vectors |
a Vectors layer |
TracksOf[np.ndarray] |
Role.tracks |
a Tracks layer |
BoxesOf[np.ndarray] |
Role.boxes |
a Shapes layer |
MasksOf[np.ndarray] |
Role.masks |
a Labels layer, once projected |
MasksOf is the odd one: an (N, Y, X) stack of masks that may overlap, so
it is not a label image and no viewer shows it directly. A front end projects
it with ophelper.masks — flattened to 2-D, or kept as a rotatable 3-D stack —
and gets to offer that as a choice, since re-projecting costs a numpy call
rather than another run of the op.
They are Annotated[np.ndarray, ...], 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[LabelsOf[np.ndarray]] and
Annotated[LabelsOf[np.ndarray], {"label": "Nuclei"}] both work.
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 opspec's. Nothing in opspec.types imports napari; the roles 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.
Where it goes
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.
Computation forms
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.
Running an op
import oprunner
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 oprunner.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.
Development
uv sync --all-groups
uv run pytest
uv run ruff check --fix && uv run ruff format
Testing the ops for real
Ops 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 ones
Running the notebooks and the interactive tests is a separate setup, since they need a host environment and the ops do not run in it: notebooks/README.md.
Checking that the environments still solve
Building is slow; solving is not. scripts/check_envs.py resolves every
envs/*/pixi.toml without downloading anything, and exits non-zero if one is
unsatisfiable:
python scripts/check_envs.py
This catches what the tests structurally cannot: they run inside an
environment, and so cannot tell you whether that environment could still be
created today. One already built keeps working from its lock file however
broken its pixi.toml becomes — which is how an unsolvable envs/pytorch
survived a week. Run it after editing anything under envs/.
CI 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.
Metadata
Release files for scikit-ops 0.1.0.dev0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scikit_ops-0.1.0.dev0-py3-none-any.whl | Python 3 | none | any | Details |
Release files / scikit_ops-0.1.0.dev0-py3-none-any.whl
| Download URL | scikit_ops-0.1.0.dev0-py3-none-any.whl |
|---|---|
| Size | 143.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
38e9d3759fffd9767073ca344b218013aec805cd02c1779a9879ca831acf0dfb
|
|
BLAKE2b-256 checksum How to use checksums |
6edabbffe2f39685143741e8527dd0492877548668cf781f92a95622413d4f19
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.13
|
Security for scikit-ops 0.1.0.dev0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.