scikit-ops 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
  2. scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
  3. scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  5. skop/__init__.py +14 -0
  6. skop/envs/cellpose3/pixi.toml +46 -0
  7. skop/envs/cupy/pixi.toml +35 -0
  8. skop/envs/minimal/pixi.toml +25 -0
  9. skop/envs/pytorch/pixi.toml +88 -0
  10. skop/envs/sdeconv/pixi.toml +85 -0
  11. skop/envs/segment-everything/pixi.toml +96 -0
  12. skop/envs/skimage/pixi.toml +28 -0
  13. skop/envs/stardist-tf/pixi.toml +91 -0
  14. skop/envs/unseg-cv/pixi.toml +27 -0
  15. skop/models.py +145 -0
  16. skop/ops/__init__.py +19 -0
  17. skop/ops/_util.py +115 -0
  18. skop/ops/deconvolve/__init__.py +18 -0
  19. skop/ops/deconvolve/_edges.py +92 -0
  20. skop/ops/deconvolve/_pad.py +117 -0
  21. skop/ops/deconvolve/richardson_lucy.py +100 -0
  22. skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
  23. skop/ops/detect/__init__.py +28 -0
  24. skop/ops/detect/_result.py +45 -0
  25. skop/ops/detect/fastsam.py +103 -0
  26. skop/ops/detect/jdll_yolo.py +115 -0
  27. skop/ops/detect/object_aware_yolo.py +112 -0
  28. skop/ops/detect/yolo.py +441 -0
  29. skop/ops/edges.py +382 -0
  30. skop/ops/generate.py +77 -0
  31. skop/ops/kernels/__init__.py +39 -0
  32. skop/ops/kernels/_fluorophore.py +42 -0
  33. skop/ops/kernels/_recenter.py +60 -0
  34. skop/ops/kernels/gibson_lanni.py +147 -0
  35. skop/ops/kernels/paraxial.py +128 -0
  36. skop/ops/kernels/psf.py +59 -0
  37. skop/ops/labels.py +144 -0
  38. skop/ops/mask/__init__.py +29 -0
  39. skop/ops/mask/_result.py +40 -0
  40. skop/ops/mask/microsam.py +114 -0
  41. skop/ops/mask/mobilesam.py +176 -0
  42. skop/ops/morphology.py +214 -0
  43. skop/ops/normalize.py +85 -0
  44. skop/ops/segment/__init__.py +24 -0
  45. skop/ops/segment/cellpose3.py +245 -0
  46. skop/ops/segment/cellpose4.py +127 -0
  47. skop/ops/segment/instanseg.py +162 -0
  48. skop/ops/segment/stardist2d.py +192 -0
  49. skop/ops/segment/starfun3d.py +104 -0
  50. skop/ops/segment/unseg/__init__.py +138 -0
  51. skop/ops/segment/unseg/_algorithm.py +1618 -0
  52. skop/ops/smooth.py +452 -0
  53. skop/ops/threshold.py +278 -0
  54. skop/ops/toy.py +114 -0
  55. skop/ops/train/__init__.py +81 -0
  56. skop/ops/train/_util.py +152 -0
  57. skop/ops/train/cellpose3.py +189 -0
  58. skop/ops/train/cellpose4.py +164 -0
  59. skop/ops/train/stardist2d.py +439 -0
  60. skop/ops/workflows/__init__.py +43 -0
  61. skop/ops/workflows/deconvolve/__init__.py +7 -0
  62. skop/ops/workflows/deconvolve/with_psf.py +103 -0
  63. skop/ops/workflows/mask/__init__.py +7 -0
  64. skop/ops/workflows/mask/detect_then_mask.py +97 -0
  65. skop/ops/workflows/segment/__init__.py +7 -0
  66. skop/ops/workflows/segment/connect_2d_in_3d.py +145 -0
@@ -0,0 +1,439 @@
1
+ """Train a StarDist 2D model from a list of image/label file pairs.
2
+
3
+ Ported from napari-ai-lab's ``StardistSegmenter.train``, with the directory
4
+ convention left behind: that segmenter globbed ``input0/`` and
5
+ ``ground_truth0/`` and read an ``info.json`` for the axes. Here the caller has
6
+ already done that, and passes the result. See design 0011 for why.
7
+
8
+ Shares the 'stardist-tf' environment with the inference ops next door, so a
9
+ caller that trains and then predicts builds one environment, not two.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Annotated
15
+
16
+ import numpy as np
17
+
18
+ from opspec.op import op
19
+ from opspec.runtime import progress
20
+ from opspec.types import ImageOf
21
+
22
+
23
+ def _take_only_what_is_needed(tf) -> None:
24
+ """Ask TensorFlow for the memory it uses, not for the whole card.
25
+
26
+ By default TF claims almost all free VRAM the first time it touches the
27
+ GPU. That is fine for a process that owns the machine and wrong for one
28
+ of several -- a napari session holding torch models, a second worker, or
29
+ just this op called twice. The symptom is a RESOURCE_EXHAUSTED failure
30
+ while nvidia-smi shows most of the memory held by a process that is not
31
+ using it.
32
+
33
+ Must run before any GPU work; TensorFlow refuses to change this once the
34
+ device is initialised, which is why the failure is ignored rather than
35
+ raised.
36
+ """
37
+ for gpu in tf.config.list_physical_devices("GPU"):
38
+ try:
39
+ tf.config.experimental.set_memory_growth(gpu, True)
40
+ except RuntimeError: # already initialised; nothing to be done here
41
+ pass
42
+
43
+
44
+ @op(env="stardist-tf")
45
+ def train_stardist2d(
46
+ images: list[str],
47
+ labels: list[str],
48
+ model_dir: str,
49
+ name: str = "stardist_model",
50
+ image_axes: str = "YX",
51
+ epochs: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 5000}] = 100,
52
+ steps_per_epoch: Annotated[
53
+ int, {"widget_type": "SpinBox", "min": 1, "max": 1000}
54
+ ] = 100,
55
+ train_patch_size: Annotated[
56
+ int, {"widget_type": "SpinBox", "min": 32, "max": 2048, "step": 32}
57
+ ] = 128,
58
+ train_batch_size: Annotated[
59
+ int, {"widget_type": "SpinBox", "min": 1, "max": 64}
60
+ ] = 4,
61
+ unet_n_depth: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 6}] = 3,
62
+ grid_size_xy: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 8}] = 1,
63
+ n_rays: Annotated[int, {"widget_type": "SpinBox", "min": 4, "max": 128}] = 32,
64
+ val_size: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 100}] = 2,
65
+ initial_model: str = "",
66
+ dataset_id: str = "",
67
+ sparse: bool = False,
68
+ ) -> str:
69
+ """Train a StarDist 2D model and write it to disk.
70
+
71
+ Args:
72
+ images: Paths to the input patches, in order.
73
+ labels: Paths to the label patches. ``labels[k]`` is the truth for
74
+ ``images[k]`` -- the pairing is positional, and the caller owns it.
75
+ model_dir: Directory to write the model into. StarDist creates
76
+ ``<model_dir>/<name>`` and puts its config and weights there.
77
+ name: Name for the trained model, and the subdirectory it lands in.
78
+ image_axes: Axes of the input patches: "YX" for single channel,
79
+ "YXC" when they carry a channel axis. A trivial channel axis is
80
+ appended for "YX", since StarDist wants one. Named
81
+ ``image_axes`` and not ``axes`` because ``Runner.run`` has an
82
+ ``axes`` parameter of its own -- a bare ``axes=`` kwarg binds
83
+ to that one, and the op never sees it.
84
+ epochs: Number of training epochs.
85
+ steps_per_epoch: Batches per epoch.
86
+ train_patch_size: Size of the patches StarDist samples while training.
87
+ Must not exceed the size of the patches on disk.
88
+ train_batch_size: Patches per batch.
89
+ unet_n_depth: Depth of the U-Net backbone.
90
+ grid_size_xy: Predict on a subsampled grid, widening the field of
91
+ view. ``train_patch_size`` must stay divisible by
92
+ ``grid_size_xy * 2 ** unet_n_depth``.
93
+ n_rays: Number of radial directions the star-convex polygons use.
94
+ initial_model: Continue training from this model rather than from
95
+ random weights. Either a directory holding a StarDist model, or
96
+ the name of a pretrained one ("2D_versatile_fluo"), downloaded
97
+ on first use -- resolved here rather than by the caller, since
98
+ only this environment has StarDist to ask. Whichever it is, it is copied to the destination
99
+ first, so the model trained from is left alone unless it *is* the
100
+ destination. Its saved architecture and patch/batch settings are
101
+ used, and the corresponding arguments above are ignored -- they
102
+ are baked into a trained model and cannot change.
103
+ dataset_id: Opaque label for the training data, recorded in
104
+ history.csv so a plot can mark where it changed. Nothing here
105
+ interprets it -- what identifies a dataset is the caller's
106
+ business.
107
+ val_size: How many pairs, taken from the end, to hold out for
108
+ validation.
109
+ sparse: Set when the labels use StarDist's sparse convention, where
110
+ unlabeled pixels are marked rather than assumed to be background.
111
+ Shifts labels down by one so those pixels go negative and their
112
+ loss is switched off.
113
+
114
+ Returns:
115
+ The path the model was written to: ``<model_dir>/<name>``. A
116
+ ``history.csv`` is written there too -- epoch, loss, val_loss, plus
117
+ the run number, the model name and the dataset id -- and appended to
118
+ when training continues. So the curve spans every run the model and
119
+ its ancestors have had, and a change in any of the last three columns
120
+ marks where something about the training changed.
121
+ """
122
+ import csv
123
+ import json
124
+ import os
125
+ import shutil
126
+
127
+ import keras
128
+ import tensorflow as tf
129
+ from stardist.models import Config2D, StarDist2D
130
+ from tifffile import imread
131
+
132
+ _take_only_what_is_needed(tf)
133
+
134
+ history = []
135
+
136
+ class _ProgressCallback(keras.callbacks.Callback):
137
+ """Relay Keras epoch events to whoever is running the op.
138
+
139
+ Defined here rather than at module level: keras is importable only
140
+ inside the worker, and this module is imported on the host to read
141
+ the op's signature.
142
+ """
143
+
144
+ def on_epoch_end(self, epoch, logs=None):
145
+ logs = logs or {}
146
+ loss = logs.get("loss", float("nan"))
147
+ val_loss = logs.get("val_loss", float("nan"))
148
+ history.append((epoch + 1, loss, val_loss))
149
+ progress(
150
+ f"Epoch {epoch + 1}/{epochs} — "
151
+ f"loss {loss:.4f}, val_loss {val_loss:.4f}",
152
+ epoch + 1,
153
+ epochs,
154
+ )
155
+
156
+ if len(images) != len(labels):
157
+ raise ValueError(
158
+ f"{len(images)} images and {len(labels)} labels: a training set "
159
+ f"is pairs, so the two lists must be the same length."
160
+ )
161
+ if len(images) <= val_size:
162
+ raise ValueError(
163
+ f"{len(images)} pairs is not enough to hold {val_size} back for "
164
+ f"validation and still train on the rest."
165
+ )
166
+
167
+ # The channel count comes from the patches, not from a guess: "YXC" says
168
+ # there is a channel axis, not how wide it is.
169
+ add_trivial_channel = not image_axes.endswith("C")
170
+
171
+ progress(f"Reading {len(images)} pairs", 0, len(images))
172
+ X, Y = [], []
173
+ for i, (image_path, label_path) in enumerate(zip(images, labels)):
174
+ x = imread(image_path)
175
+ if add_trivial_channel:
176
+ x = x[..., np.newaxis]
177
+ X.append(x)
178
+ Y.append(imread(label_path))
179
+ progress(current=i + 1)
180
+
181
+ # Held out from the end, matching what the pairs were ordered by. Not a
182
+ # random split: a caller who wants one shuffles its own lists, where it
183
+ # can record the seed alongside the patches.
184
+ X_train, Y_train = X[:-val_size], Y[:-val_size]
185
+ X_val, Y_val = X[-val_size:], Y[-val_size:]
186
+
187
+ if sparse:
188
+ Y_train = [y.astype(np.int32) - 1 for y in Y_train]
189
+ Y_val = [y.astype(np.int32) - 1 for y in Y_val]
190
+
191
+ n_channel_in = X[0].shape[-1]
192
+
193
+ # Lists of arrays, not one stacked array. StarDist takes either, and
194
+ # stacking demands every patch be the same size -- which a patch
195
+ # directory written across several sessions is not. It crops
196
+ # train_patch_size out of each one anyway, so only being at least that
197
+ # big matters.
198
+ X_train = [x.astype(np.float32) for x in X_train]
199
+ Y_train = [y.astype(np.int32) for y in Y_train]
200
+ X_val = [x.astype(np.float32) for x in X_val]
201
+ Y_val = [y.astype(np.int32) for y in Y_val]
202
+
203
+ out_dir = os.path.join(model_dir, name)
204
+
205
+ if initial_model:
206
+ # A builtin is a directory too -- from_pretrained downloads it and
207
+ # loads it exactly as this does, so resolving one is just finding
208
+ # where it landed.
209
+ source = initial_model
210
+ if not os.path.isdir(source):
211
+ from csbdeep.models.pretrained import get_model_folder
212
+
213
+ # Not from_pretrained: that builds the model and then nulls its
214
+ # basedir, so the folder is only reachable by accident. This
215
+ # downloads and returns the folder, which is all that is wanted.
216
+ source = str(get_model_folder(StarDist2D, initial_model))
217
+
218
+ n_channel_pretrained = None
219
+ if os.path.isfile(os.path.join(source, "config.json")):
220
+ with open(os.path.join(source, "config.json")) as f:
221
+ n_channel_pretrained = json.load(f).get("n_channel_in")
222
+ if (
223
+ n_channel_pretrained is not None
224
+ and n_channel_pretrained != n_channel_in
225
+ ):
226
+ raise ValueError(
227
+ f"{initial_model} takes {n_channel_pretrained}-channel input "
228
+ f"and the patches have {n_channel_in}. A model's first layer "
229
+ f"is fixed at training time, so this one cannot continue from "
230
+ f"these patches."
231
+ )
232
+
233
+ # Copied rather than trained in place, so continuing produces a new
234
+ # model and leaves its parent intact -- unless the caller aimed at the
235
+ # parent on purpose, which is how "keep training this one" is said.
236
+ if os.path.abspath(source) != os.path.abspath(out_dir):
237
+ shutil.copytree(source, out_dir, dirs_exist_ok=True)
238
+ # config=None loads the saved config and weights from out_dir.
239
+ net = StarDist2D(config=None, name=name, basedir=model_dir)
240
+ progress(f"Continuing from {source}")
241
+ else:
242
+ config = Config2D(
243
+ n_rays=n_rays,
244
+ axes=image_axes if image_axes.endswith("C") else image_axes + "C",
245
+ n_channel_in=n_channel_in,
246
+ train_patch_size=(train_patch_size, train_patch_size),
247
+ train_batch_size=train_batch_size,
248
+ unet_n_depth=unet_n_depth,
249
+ grid=(grid_size_xy, grid_size_xy),
250
+ )
251
+ net = StarDist2D(config=config, name=name, basedir=model_dir)
252
+
253
+ net.prepare_for_training()
254
+ net.callbacks.append(_ProgressCallback())
255
+
256
+ # Keras' own progress bar has to go, and StarDist hardcodes verbose=1
257
+ # (model2d.py), so force it off at the one call it reaches.
258
+ #
259
+ # The bar writes to stdout with \r and no newline -- and stdout is the
260
+ # pipe Appose carries its protocol on. Appose then prints an UPDATE onto
261
+ # that unterminated line, the host cannot parse the result, and drops it
262
+ # (service.py, _stdout_loop). The symptom is every epoch event vanishing
263
+ # while the ones before and after training arrive fine.
264
+ _fit = net.keras_model.fit
265
+ net.keras_model.fit = lambda *a, **kw: _fit(*a, **{**kw, "verbose": 0})
266
+
267
+ progress(
268
+ f"Training {name}: {len(X_train)} train, {len(X_val)} val, "
269
+ f"{epochs} epochs",
270
+ 0,
271
+ epochs,
272
+ )
273
+ net.train(
274
+ X_train,
275
+ Y_train,
276
+ validation_data=(X_val, Y_val),
277
+ epochs=epochs,
278
+ steps_per_epoch=steps_per_epoch,
279
+ )
280
+
281
+ # Appended, not overwritten: a model that has been continued has one
282
+ # curve across every run, which is the thing worth plotting.
283
+ # Rewritten rather than appended to: a history from before a column
284
+ # existed has a shorter header, and appending wider rows to it produces a
285
+ # file that reads back wrong rather than failing.
286
+ columns = ["epoch", "loss", "val_loss", "run", "model", "dataset"]
287
+ history_path = os.path.join(out_dir, "history.csv")
288
+ previous = []
289
+ if os.path.exists(history_path):
290
+ with open(history_path) as f:
291
+ previous = list(csv.DictReader(f))
292
+
293
+ last_run = int(previous[-1].get("run") or 0) if previous else 0
294
+ done = len(previous)
295
+
296
+ with open(history_path, "w", newline="") as f:
297
+ writer = csv.DictWriter(f, columns, restval="")
298
+ writer.writeheader()
299
+ for row in previous:
300
+ writer.writerow({k: row.get(k, "") for k in columns})
301
+ for epoch, loss, val_loss in history:
302
+ writer.writerow(
303
+ {
304
+ "epoch": done + epoch,
305
+ "loss": loss,
306
+ "val_loss": val_loss,
307
+ "run": last_run + 1,
308
+ "model": name,
309
+ "dataset": dataset_id,
310
+ }
311
+ )
312
+
313
+ return out_dir
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Before training: what can the network see?
318
+ #
319
+ # A network cannot segment an object larger than its receptive field -- the
320
+ # information does not reach the output. The symptom is one large object
321
+ # detected as several small ones, which reads as a training problem and is
322
+ # not.
323
+ #
324
+ # This lives beside the training op rather than in a category of its own
325
+ # because it takes that op's architecture parameters: grid, depth and kernel
326
+ # size are what set the receptive field, and a change to one is a change to
327
+ # both. It is a pre-flight check for training, not a separate kind of work.
328
+ # ---------------------------------------------------------------------------
329
+
330
+ @op(env="stardist-tf")
331
+ def receptive_field_stardist2d(
332
+ model_dir: Annotated[str, {"widget_type": "FileEdit", "mode": "d"}] = "",
333
+ grid_size_xy: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 8}] = 1,
334
+ unet_n_depth: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 6}] = 3,
335
+ unet_kernel_size: Annotated[
336
+ int, {"widget_type": "SpinBox", "min": 3, "max": 7, "step": 2}
337
+ ] = 3,
338
+ n_channel_in: Annotated[int, {"widget_type": "SpinBox", "min": 1, "max": 8}] = 1,
339
+ img_size: Annotated[
340
+ int, {"widget_type": "SpinBox", "min": 64, "max": 4096, "step": 64}
341
+ ] = 512,
342
+ ) -> ImageOf[np.ndarray]:
343
+ """The impulse response of a StarDist 2D network, as an image.
344
+
345
+ Args:
346
+ model_dir: Measure the model in this directory. When empty, a network
347
+ is built from the parameters below instead -- which is the point:
348
+ the answer is available before anything is trained.
349
+ grid_size_xy: Predict on a subsampled grid. Multiplies the receptive
350
+ field, because StarDist pools the input down to grid size before
351
+ the U-Net.
352
+ unet_n_depth: Depth of the U-Net backbone. Each level roughly doubles
353
+ the receptive field.
354
+ unet_kernel_size: Convolution kernel size.
355
+ n_channel_in: Input channels. Does not affect the extent, but the
356
+ network has to be built with the right shape.
357
+ img_size: Size of the test image. Must be a power of two, and must be
358
+ **larger than the receptive field being measured** -- otherwise
359
+ the response fills the image and what comes back is the size of
360
+ the image rather than the size of the field. The op raises if the
361
+ response reaches the border.
362
+
363
+ Returns:
364
+ The impulse response, ``img_size`` square: the absolute difference
365
+ between the network's output for a single bright central pixel and its
366
+ output for an empty image. Non-zero exactly where information from
367
+ that pixel reached. ``skop.ops.inspect.extent`` turns it into a
368
+ number, and it displays directly as a picture of the field.
369
+ """
370
+ import numpy as np
371
+ import tensorflow as tf
372
+ from scipy.ndimage import zoom
373
+ from stardist.models import Config2D, StarDist2D
374
+
375
+ _take_only_what_is_needed(tf)
376
+
377
+ # Measuring several architectures in one session otherwise leaves every
378
+ # network resident: TensorFlow keeps the graph, and a worker is reused
379
+ # across calls. A sweep of five then fills the card before anything is
380
+ # trained.
381
+ tf.keras.backend.clear_session()
382
+
383
+ if img_size & (img_size - 1):
384
+ raise ValueError(
385
+ f"img_size must be a power of two, not {img_size}: StarDist's own "
386
+ f"measurement asserts it."
387
+ )
388
+
389
+ if model_dir:
390
+ progress(f"Loading {model_dir}")
391
+ import os
392
+
393
+ net = StarDist2D(
394
+ config=None,
395
+ name=os.path.basename(os.path.normpath(model_dir)),
396
+ basedir=os.path.dirname(os.path.normpath(model_dir)),
397
+ )
398
+ else:
399
+ progress("Building an untrained network")
400
+ net = StarDist2D(
401
+ config=Config2D(
402
+ n_rays=32,
403
+ axes="YXC",
404
+ n_channel_in=n_channel_in,
405
+ grid=(grid_size_xy, grid_size_xy),
406
+ unet_n_depth=unet_n_depth,
407
+ unet_kernel_size=(unet_kernel_size, unet_kernel_size),
408
+ ),
409
+ name=None,
410
+ basedir=None,
411
+ )
412
+
413
+ # The same impulse StarDist sends: one bright pixel, and an empty image to
414
+ # subtract the network's constant response to nothing.
415
+ shape = (img_size, img_size)
416
+ mid = tuple(s // 2 for s in shape)
417
+ x = np.zeros((1,) + shape + (net.config.n_channel_in,), dtype=np.float32)
418
+ z = np.zeros_like(x)
419
+ x[(0,) + mid + (slice(None),)] = 1
420
+
421
+ progress("Sending an impulse through the network")
422
+ y = net.keras_model.predict(x, verbose=0)[0][0, ..., 0]
423
+ y0 = net.keras_model.predict(z, verbose=0)[0][0, ..., 0]
424
+
425
+ grid = tuple((np.array(shape) / np.array(y.shape)).astype(int))
426
+ response = np.abs(zoom(y, grid, order=0) - zoom(y0, grid, order=0))
427
+
428
+ reached = response > 0
429
+ if reached[0].any() or reached[-1].any() or reached[:, 0].any() or reached[:, -1].any():
430
+ raise ValueError(
431
+ f"The response reaches the border of a {img_size}px test image, so "
432
+ f"the receptive field is at least that wide and cannot be measured "
433
+ f"here. Raise img_size to the next power of two."
434
+ )
435
+
436
+ result = response.astype(np.float32)
437
+ del net
438
+ tf.keras.backend.clear_session()
439
+ return result
@@ -0,0 +1,43 @@
1
+ """Workflows: ops built out of other ops.
2
+
3
+ A workflow is an op with no environment. ``@op(env=...)`` is what pins an op to
4
+ a worker, and a workflow has nothing to pin -- the ops it calls each bring
5
+ their own -- so it runs on the host, where the runner lives, and dispatches
6
+ from there. See docs/spec/workflow-ops.md.
7
+
8
+ **They live here rather than beside the ops they compose.** That is a
9
+ navigation decision before it is a technical one: somebody arriving at a
10
+ collection of sixty ops should be able to see what has already been assembled,
11
+ and where to put what they assemble next, without reading sixty signatures. The
12
+ subdirectories mirror ``skop.ops`` itself, so a workflow sits under the name of
13
+ what it *produces* -- ``workflows/mask/`` makes masks, whatever it ran to get
14
+ there.
15
+
16
+ It falls out that the dependency runs one way. Everything here imports from
17
+ ``skop.ops``; nothing in ``skop.ops`` imports from here. Left mixed in, a
18
+ ``mask/__init__.py`` re-exporting a workflow would import ``skop.ops.detect``
19
+ and then reach back into its own half-initialized package -- which works until
20
+ somebody writes the import the other way round and it does not. A separate
21
+ tree makes that layering physical, and a cycle an obvious mistake rather than a
22
+ subtle one.
23
+
24
+ Nothing else distinguishes them. Discovery finds a workflow the same way it
25
+ finds any op, ``OpSpec.from_op()`` describes it the same way, and a front end runs it
26
+ through the same call. What tells them apart is ``OpSpec.is_workflow``, which
27
+ reads the missing environment -- never the module path.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from .deconvolve.with_psf import Deconvolved, deconvolve_with_psf
33
+ from .mask.detect_then_mask import Detected, detect_then_mask
34
+ from .segment.connect_2d_in_3d import Connected, connect_2d_in_3d
35
+
36
+ __all__ = [
37
+ "Connected",
38
+ "Deconvolved",
39
+ "Detected",
40
+ "connect_2d_in_3d",
41
+ "deconvolve_with_psf",
42
+ "detect_then_mask",
43
+ ]
@@ -0,0 +1,7 @@
1
+ """Workflows that produce a restored image."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .with_psf import Deconvolved, deconvolve_with_psf
6
+
7
+ __all__ = ["Deconvolved", "deconvolve_with_psf"]
@@ -0,0 +1,103 @@
1
+ """Generate a PSF and deconvolve with it, in one call.
2
+
3
+ The first workflow: an op with no environment, which runs on the host and
4
+ calls other ops through the runner. See docs/spec/workflow-ops.md.
5
+
6
+ Deconvolution needs a PSF and nobody has one lying about, so in practice every
7
+ deconvolution is already these two steps. Doing them by hand means running a
8
+ kernel op, finding its output layer, and feeding it back in -- and doing that
9
+ again for every change of numerical aperture. What the workflow adds is not
10
+ the composition, which is trivial, but the *pairing*: the two choosers sit in
11
+ one panel, so trying Gibson-Lanni on the GPU is two combo boxes rather than
12
+ two runs.
13
+
14
+ There is no shared-parameter problem here to speak of. The PSF ops take no
15
+ image -- they synthesize a kernel from optics alone -- so the only bound
16
+ parameters are the deconvolver's ``image`` and ``psf``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from typing import Annotated, NamedTuple
23
+
24
+ import numpy as np
25
+
26
+ from opspec.op import Choices, ParamsFor, op
27
+ from opspec.runtime import progress, run
28
+ from opspec.types import ImageOf
29
+ from skop.ops.deconvolve.richardson_lucy import richardson_lucy
30
+ from skop.ops.deconvolve.richardson_lucy_cupy import richardson_lucy_cupy
31
+ from skop.ops.kernels.gibson_lanni import gibson_lanni
32
+ from skop.ops.kernels.psf import gaussian_psf
33
+
34
+ #: Non-circulant, as clij2-fft and tnia-python deconvolve in tiles: it
35
+ #: handles the image's edges, where circulant wraps them onto each other
36
+ #: (0017, tiling.md). Settings given for the deconvolver are added to these.
37
+ DECON_DEFAULTS = {"noncirc": True}
38
+
39
+
40
+ class Deconvolved(NamedTuple):
41
+ """What each stage produced, in the order the stages ran.
42
+
43
+ Stage order rather than importance: a front end adds these to a viewer in
44
+ declaration order, so the pipeline reads down the layer list, and the last
45
+ stage -- the answer -- ends up on top where a new layer belongs.
46
+ """
47
+
48
+ #: The kernel the deconvolution used. Returned rather than discarded
49
+ #: because it is the parameter people actually iterate on, and looking at
50
+ #: it is the fastest way to see that the optics were entered wrong.
51
+ psf: ImageOf[np.ndarray]
52
+ #: The restored image.
53
+ image: ImageOf[np.ndarray]
54
+
55
+
56
+ @op()
57
+ def deconvolve_with_psf(
58
+ image: ImageOf[np.ndarray],
59
+ psf_op: Annotated[
60
+ Callable,
61
+ Choices(gaussian=gaussian_psf, gibson_lanni=gibson_lanni),
62
+ ] = gaussian_psf,
63
+ psf_args: Annotated[dict | None, ParamsFor("psf_op")] = None,
64
+ decon_op: Annotated[
65
+ Callable,
66
+ Choices(cpu=richardson_lucy, gpu=richardson_lucy_cupy),
67
+ ] = richardson_lucy,
68
+ decon_args: Annotated[
69
+ dict | None, ParamsFor("decon_op", binds=("image", "psf"))
70
+ ] = DECON_DEFAULTS,
71
+ ) -> Deconvolved:
72
+ """Deconvolve an image with a freshly generated PSF.
73
+
74
+ Args:
75
+ image: The image to restore.
76
+ psf_op: Which kernel op to build the PSF with. ``gaussian`` is an
77
+ approximation that is fast and always behaves; ``gibson_lanni``
78
+ models the optics, and wants real numbers for them.
79
+ psf_args: Settings for the chosen kernel op.
80
+ decon_op: Which deconvolver to use. ``gpu`` needs CUDA and cupy, and
81
+ picking it triggers an environment build the first time.
82
+ decon_args: Settings for the chosen deconvolver, minus the image and
83
+ the PSF, which this workflow supplies. ``noncirc`` is on unless
84
+ they turn it off.
85
+
86
+ Returns:
87
+ psf: The PSF it was deconvolved with.
88
+ image: The deconvolved image.
89
+
90
+ Note: the two halves are not checked against each other. A PSF of a
91
+ different rank than the image is a real technique rather than a mistake --
92
+ a 2-D PSF deconvolves a stack slice by slice, and a 3-D one restores
93
+ astigmatic single-molecule data -- so this passes whatever it is given
94
+ straight through and lets the deconvolver say what it thinks.
95
+ """
96
+ progress("Generating PSF")
97
+ psf = run(psf_op, **(psf_args or {}))
98
+
99
+ progress("Deconvolving")
100
+ settings = {**DECON_DEFAULTS, **(decon_args or {})}
101
+ restored = run(decon_op, image=image, psf=psf, **settings)
102
+
103
+ return Deconvolved(np.asarray(psf), np.asarray(restored))
@@ -0,0 +1,7 @@
1
+ """Workflows that produce a collection of masks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .detect_then_mask import Detected, detect_then_mask
6
+
7
+ __all__ = ["Detected", "detect_then_mask"]