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.
- scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
- scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
- scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
- scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- skop/__init__.py +14 -0
- skop/envs/cellpose3/pixi.toml +46 -0
- skop/envs/cupy/pixi.toml +35 -0
- skop/envs/minimal/pixi.toml +25 -0
- skop/envs/pytorch/pixi.toml +88 -0
- skop/envs/sdeconv/pixi.toml +85 -0
- skop/envs/segment-everything/pixi.toml +96 -0
- skop/envs/skimage/pixi.toml +28 -0
- skop/envs/stardist-tf/pixi.toml +91 -0
- skop/envs/unseg-cv/pixi.toml +27 -0
- skop/models.py +145 -0
- skop/ops/__init__.py +19 -0
- skop/ops/_util.py +115 -0
- skop/ops/deconvolve/__init__.py +18 -0
- skop/ops/deconvolve/_edges.py +92 -0
- skop/ops/deconvolve/_pad.py +117 -0
- skop/ops/deconvolve/richardson_lucy.py +100 -0
- skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
- skop/ops/detect/__init__.py +28 -0
- skop/ops/detect/_result.py +45 -0
- skop/ops/detect/fastsam.py +103 -0
- skop/ops/detect/jdll_yolo.py +115 -0
- skop/ops/detect/object_aware_yolo.py +112 -0
- skop/ops/detect/yolo.py +441 -0
- skop/ops/edges.py +382 -0
- skop/ops/generate.py +77 -0
- skop/ops/kernels/__init__.py +39 -0
- skop/ops/kernels/_fluorophore.py +42 -0
- skop/ops/kernels/_recenter.py +60 -0
- skop/ops/kernels/gibson_lanni.py +147 -0
- skop/ops/kernels/paraxial.py +128 -0
- skop/ops/kernels/psf.py +59 -0
- skop/ops/labels.py +144 -0
- skop/ops/mask/__init__.py +29 -0
- skop/ops/mask/_result.py +40 -0
- skop/ops/mask/microsam.py +114 -0
- skop/ops/mask/mobilesam.py +176 -0
- skop/ops/morphology.py +214 -0
- skop/ops/normalize.py +85 -0
- skop/ops/segment/__init__.py +24 -0
- skop/ops/segment/cellpose3.py +245 -0
- skop/ops/segment/cellpose4.py +127 -0
- skop/ops/segment/instanseg.py +162 -0
- skop/ops/segment/stardist2d.py +192 -0
- skop/ops/segment/starfun3d.py +104 -0
- skop/ops/segment/unseg/__init__.py +138 -0
- skop/ops/segment/unseg/_algorithm.py +1618 -0
- skop/ops/smooth.py +452 -0
- skop/ops/threshold.py +278 -0
- skop/ops/toy.py +114 -0
- skop/ops/train/__init__.py +81 -0
- skop/ops/train/_util.py +152 -0
- skop/ops/train/cellpose3.py +189 -0
- skop/ops/train/cellpose4.py +164 -0
- skop/ops/train/stardist2d.py +439 -0
- skop/ops/workflows/__init__.py +43 -0
- skop/ops/workflows/deconvolve/__init__.py +7 -0
- skop/ops/workflows/deconvolve/with_psf.py +103 -0
- skop/ops/workflows/mask/__init__.py +7 -0
- skop/ops/workflows/mask/detect_then_mask.py +97 -0
- skop/ops/workflows/segment/__init__.py +7 -0
- 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,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))
|