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
skop/ops/threshold.py ADDED
@@ -0,0 +1,278 @@
1
+ """Global thresholding, via scikit-image.
2
+
3
+ Every op here is the same shape: pick one number from the image's histogram,
4
+ keep what lies above it. They differ only in how that number is chosen, and
5
+ which choice is right is a property of the image rather than of the method --
6
+ so they are variations on one call, sharing one module and one environment.
7
+
8
+ A global threshold consumes no particular axis -- it is a histogram over
9
+ whatever it is handed -- so none of these declare slots at all, and each works
10
+ on a line, a plane or a whole volume. Whether one threshold for a stack or one
11
+ per plane is the right answer depends on the experiment, so it is left to the
12
+ caller: by default the stack arrives whole, and a caller wanting a threshold
13
+ per plane iterates instead.
14
+
15
+ Otsu is the default worth reaching for first. Beyond it:
16
+ ``li`` minimizes cross-entropy and copes well with a small foreground;
17
+ ``triangle`` and ``yen`` suit a skewed histogram with no clear valley;
18
+ ``isodata`` and ``mean`` are the cheap classical answers; ``minimum``
19
+ insists on a genuinely bimodal histogram and raises when it cannot find one.
20
+ ``multiotsu`` is the odd one out -- it cuts into several classes rather than
21
+ two, so it returns those classes rather than a foreground.
22
+
23
+ Ported from src/imgops/implementations/skimagessegmenter.py.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Annotated
29
+
30
+ import numpy as np
31
+
32
+ from opspec.op import Axes, op
33
+ from opspec.types import ImageOf, LabelsOf
34
+
35
+ from ._util import to_gray
36
+
37
+ #: What every op here takes: any number of axes, an RGB(A) axis collapsed.
38
+ _Image = Annotated[ImageOf[np.ndarray], Axes(variadic=True)]
39
+
40
+
41
+ def _mask(gray: np.ndarray, value: float, invert: bool, label_objects: bool):
42
+ """Turn one threshold into the mask, or labels, an op returns."""
43
+ from skimage import measure
44
+
45
+ mask = gray <= value if invert else gray > value
46
+ if label_objects:
47
+ return measure.label(mask).astype(np.uint16)
48
+ return mask.astype(np.uint16)
49
+
50
+
51
+ @op(env="skimage")
52
+ def otsu(
53
+ image: _Image,
54
+ invert: bool = False,
55
+ label_objects: bool = True,
56
+ ) -> LabelsOf[np.ndarray]:
57
+ """Threshold an image by Otsu's method.
58
+
59
+ Chooses the threshold that minimizes the variance within the two classes
60
+ it creates, which is the same as maximizing the variance between them.
61
+ The standard first thing to try, and what it assumes -- two comparably
62
+ sized modes -- is also what it fails on.
63
+
64
+ Args:
65
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
66
+ invert: Whether to keep what falls below the threshold instead.
67
+ label_objects: Whether to label connected components, rather than
68
+ returning a plain binary mask.
69
+
70
+ Returns:
71
+ A label image, or a 0/1 mask when ``label_objects`` is off.
72
+ """
73
+ from skimage import filters
74
+
75
+ gray = to_gray(image)
76
+ return _mask(gray, filters.threshold_otsu(gray), invert, label_objects)
77
+
78
+
79
+ @op(env="skimage")
80
+ def isodata(
81
+ image: _Image,
82
+ invert: bool = False,
83
+ label_objects: bool = True,
84
+ ) -> LabelsOf[np.ndarray]:
85
+ """Threshold an image by the isodata (Ridler-Calvard) method.
86
+
87
+ Iterates until the threshold sits exactly halfway between the mean of
88
+ what falls below it and the mean of what falls above. Usually lands very
89
+ near Otsu, and is the method ImageJ's "Default" is descended from.
90
+
91
+ Args:
92
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
93
+ invert: Whether to keep what falls below the threshold instead.
94
+ label_objects: Whether to label connected components, rather than
95
+ returning a plain binary mask.
96
+
97
+ Returns:
98
+ A label image, or a 0/1 mask when ``label_objects`` is off.
99
+ """
100
+ from skimage import filters
101
+
102
+ gray = to_gray(image)
103
+ return _mask(gray, filters.threshold_isodata(gray), invert, label_objects)
104
+
105
+
106
+ @op(env="skimage")
107
+ def li(
108
+ image: _Image,
109
+ invert: bool = False,
110
+ label_objects: bool = True,
111
+ ) -> LabelsOf[np.ndarray]:
112
+ """Threshold an image by Li's minimum cross-entropy method.
113
+
114
+ Minimizes the information lost by replacing each class with its mean,
115
+ which does not require the two classes to be of similar size. The one to
116
+ try when Otsu swallows a sparse foreground.
117
+
118
+ Args:
119
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
120
+ invert: Whether to keep what falls below the threshold instead.
121
+ label_objects: Whether to label connected components, rather than
122
+ returning a plain binary mask.
123
+
124
+ Returns:
125
+ A label image, or a 0/1 mask when ``label_objects`` is off.
126
+ """
127
+ from skimage import filters
128
+
129
+ gray = to_gray(image)
130
+ return _mask(gray, filters.threshold_li(gray), invert, label_objects)
131
+
132
+
133
+ @op(env="skimage")
134
+ def mean(
135
+ image: _Image,
136
+ invert: bool = False,
137
+ label_objects: bool = True,
138
+ ) -> LabelsOf[np.ndarray]:
139
+ """Threshold an image at its mean intensity.
140
+
141
+ The simplest global threshold there is, and a reasonable baseline on an
142
+ image whose background is genuinely uniform.
143
+
144
+ Args:
145
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
146
+ invert: Whether to keep what falls below the threshold instead.
147
+ label_objects: Whether to label connected components, rather than
148
+ returning a plain binary mask.
149
+
150
+ Returns:
151
+ A label image, or a 0/1 mask when ``label_objects`` is off.
152
+ """
153
+ from skimage import filters
154
+
155
+ gray = to_gray(image)
156
+ return _mask(gray, filters.threshold_mean(gray), invert, label_objects)
157
+
158
+
159
+ @op(env="skimage")
160
+ def minimum(
161
+ image: _Image,
162
+ invert: bool = False,
163
+ label_objects: bool = True,
164
+ ) -> LabelsOf[np.ndarray]:
165
+ """Threshold an image at the valley between its two histogram peaks.
166
+
167
+ Smooths the histogram until exactly two maxima remain, then cuts at the
168
+ minimum between them. The most literal reading of "bimodal", and the
169
+ least forgiving: an image whose histogram will not smooth down to two
170
+ peaks raises rather than guessing.
171
+
172
+ Args:
173
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
174
+ invert: Whether to keep what falls below the threshold instead.
175
+ label_objects: Whether to label connected components, rather than
176
+ returning a plain binary mask.
177
+
178
+ Returns:
179
+ A label image, or a 0/1 mask when ``label_objects`` is off.
180
+ """
181
+ from skimage import filters
182
+
183
+ gray = to_gray(image)
184
+ return _mask(gray, filters.threshold_minimum(gray), invert, label_objects)
185
+
186
+
187
+ @op(env="skimage")
188
+ def triangle(
189
+ image: _Image,
190
+ invert: bool = False,
191
+ label_objects: bool = True,
192
+ ) -> LabelsOf[np.ndarray]:
193
+ """Threshold an image by the triangle method.
194
+
195
+ Draws a line from the histogram's peak to its far end and cuts where the
196
+ histogram is furthest from that line. Geometric rather than statistical,
197
+ which is why it holds up on the heavily skewed histogram of a mostly
198
+ empty fluorescence image.
199
+
200
+ Args:
201
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
202
+ invert: Whether to keep what falls below the threshold instead.
203
+ label_objects: Whether to label connected components, rather than
204
+ returning a plain binary mask.
205
+
206
+ Returns:
207
+ A label image, or a 0/1 mask when ``label_objects`` is off.
208
+ """
209
+ from skimage import filters
210
+
211
+ gray = to_gray(image)
212
+ return _mask(gray, filters.threshold_triangle(gray), invert, label_objects)
213
+
214
+
215
+ @op(env="skimage")
216
+ def yen(
217
+ image: _Image,
218
+ invert: bool = False,
219
+ label_objects: bool = True,
220
+ ) -> LabelsOf[np.ndarray]:
221
+ """Threshold an image by Yen's maximum correlation criterion.
222
+
223
+ An entropy method like Li's, but maximizing a correlation between the two
224
+ classes rather than minimizing a divergence. Tends to cut higher than
225
+ Otsu, keeping less.
226
+
227
+ Args:
228
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
229
+ invert: Whether to keep what falls below the threshold instead.
230
+ label_objects: Whether to label connected components, rather than
231
+ returning a plain binary mask.
232
+
233
+ Returns:
234
+ A label image, or a 0/1 mask when ``label_objects`` is off.
235
+ """
236
+ from skimage import filters
237
+
238
+ gray = to_gray(image)
239
+ return _mask(gray, filters.threshold_yen(gray), invert, label_objects)
240
+
241
+
242
+ @op(env="skimage")
243
+ def multiotsu(
244
+ image: _Image,
245
+ classes: int = 3,
246
+ label_objects: bool = False,
247
+ ) -> LabelsOf[np.ndarray]:
248
+ """Split an image into several intensity classes, by Otsu's criterion.
249
+
250
+ Otsu generalized: instead of one threshold there are ``classes - 1`` of
251
+ them, chosen together to minimize the variance within the classes they
252
+ make. Where the other ops here answer "foreground or not", this one
253
+ answers "which layer" -- background, dim, bright -- so it returns those
254
+ classes as a label image, numbered 0 upward by intensity.
255
+
256
+ Args:
257
+ image: Image to threshold. A trailing RGB(A) axis is collapsed.
258
+ classes: How many intensity classes to split into, at least 2.
259
+ label_objects: Whether to label the connected components of the
260
+ brightest class instead, discarding the rest. That makes this a
261
+ drop-in for the two-class ops, cutting nearer the bright end.
262
+
263
+ Returns:
264
+ A label image: one label per intensity class, or per object in the
265
+ brightest class when ``label_objects`` is on.
266
+ """
267
+ from skimage import filters, measure
268
+
269
+ if classes < 2:
270
+ raise ValueError(f"classes must be at least 2, got {classes}")
271
+
272
+ gray = to_gray(image)
273
+ thresholds = filters.threshold_multiotsu(gray, classes=classes)
274
+ if label_objects:
275
+ return measure.label(gray > thresholds[-1]).astype(np.uint16)
276
+ # Note: right=True so a pixel *at* a threshold falls below it, matching
277
+ # the strict `gray > value` the two-class ops here use.
278
+ return np.digitize(gray, bins=thresholds, right=True).astype(np.uint16)
skop/ops/toy.py ADDED
@@ -0,0 +1,114 @@
1
+ """Toy ops, used to exercise skop without dragging in a deep learning stack.
2
+
3
+ Note the shape of an op module: imports at the top are limited to what the
4
+ signatures need, so this module loads in a minimal environment. Anything an
5
+ op needs only in order to *run* is imported inside the function body.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Annotated, NamedTuple
12
+
13
+ import numpy as np
14
+
15
+ from opspec.op import Axes, Out, op
16
+ from opspec.runtime import cancel_requested, progress
17
+ from opspec.types import ImageOf, LabelsOf, PointsOf
18
+
19
+
20
+ @op(env="minimal")
21
+ def add(a: int, b: int) -> int:
22
+ """Add two numbers. The smallest op there is."""
23
+ return a + b
24
+
25
+
26
+ class ScaleResult(NamedTuple):
27
+ scaled: np.ndarray
28
+ total: float
29
+
30
+
31
+ @op(env="minimal")
32
+ def scale(
33
+ image: np.ndarray,
34
+ factor: Annotated[
35
+ float,
36
+ {"widget_type": "FloatSlider", "min": 0.0, "max": 10.0, "step": 0.1},
37
+ ] = 2.0,
38
+ ) -> ScaleResult:
39
+ """Scale an array, returning the result alongside its sum.
40
+
41
+ Exercises multi-output ops: the NamedTuple field names become the names
42
+ of the task's outputs.
43
+ """
44
+ scaled = image * factor
45
+ return ScaleResult(scaled=scaled, total=float(scaled.sum()))
46
+
47
+
48
+ @op(env="minimal")
49
+ def scale_into(
50
+ image: ImageOf[np.ndarray],
51
+ result: Out[ImageOf[np.ndarray]],
52
+ factor: float = 2.0,
53
+ ) -> None:
54
+ """Computer form: fill a buffer the caller allocated.
55
+
56
+ The caller owns ``result``, so nothing is sent back over the wire. Note
57
+ that ``Out`` and a role compose: nested Annotated flattens, so ``result``
58
+ is both an output buffer and an image.
59
+ """
60
+ np.multiply(image, factor, out=result, casting="unsafe")
61
+
62
+
63
+ @op(env="minimal")
64
+ def quadrants(
65
+ image: Annotated[ImageOf[np.ndarray], Axes("y", "x")],
66
+ ) -> LabelsOf[np.ndarray]:
67
+ """Label a plane's four quadrants, 1 through 4.
68
+
69
+ A strictly 2-D op, and the standing test case for adapting one: fed a
70
+ stack, every plane numbers its own objects from 1, so a correct
71
+ reassembly has to renumber them.
72
+ """
73
+ height, width = image.shape
74
+ labels = np.ones(image.shape, dtype=np.uint16)
75
+ labels[: height // 2, width // 2 :] = 2
76
+ labels[height // 2 :, : width // 2] = 3
77
+ labels[height // 2 :, width // 2 :] = 4
78
+ return labels
79
+
80
+
81
+ @op(env="minimal")
82
+ def slow_sum(image: np.ndarray, steps: int = 5) -> float:
83
+ """Sum an array slowly, reporting progress and honoring cancellation."""
84
+ total = 0.0
85
+ chunks = np.array_split(image.ravel(), steps)
86
+ for i, chunk in enumerate(chunks):
87
+ if cancel_requested():
88
+ break
89
+ progress(f"Summing chunk {i + 1} of {steps}", i, steps)
90
+ total += float(chunk.sum())
91
+ time.sleep(0.05)
92
+ return total
93
+
94
+
95
+ @op(env="minimal")
96
+ def probe() -> str:
97
+ """Report which interpreter is actually executing this op."""
98
+ import sys
99
+
100
+ return f"{sys.executable} | py={sys.version.split()[0]} | numpy={np.__version__}"
101
+
102
+
103
+ class Findings(NamedTuple):
104
+ labels: LabelsOf[np.ndarray]
105
+ points: PointsOf[np.ndarray]
106
+
107
+
108
+ @op(env="minimal")
109
+ def find_nothing(image: ImageOf[np.ndarray]) -> Findings:
110
+ """Return an empty result, as a segmentation that finds nothing would."""
111
+ return Findings(
112
+ labels=np.zeros_like(image, dtype=np.uint16),
113
+ points=np.zeros((0, 3), dtype=np.int32),
114
+ )
@@ -0,0 +1,81 @@
1
+ """Training ops: input/truth pairs in, a model on disk out.
2
+
3
+ Also what you want to know *before* training -- the receptive field of the
4
+ architecture you are about to build. It sits here rather than in a category of
5
+ its own because it takes the same architecture parameters the training op
6
+ does, and a change to one is a change to both.
7
+
8
+ Training ops differ from the rest of skop in two ways, both settled in
9
+ design 0011. They take **paths, not arrays** -- a manifest of image files and
10
+ the label files that pair with them, one list each, matched by position. And
11
+ they **return a path**: a trained TensorFlow or torch model cannot cross the
12
+ worker boundary, so the op writes it and hands back where.
13
+
14
+ The caller resolves its own directory layout into those two lists. Nothing in
15
+ here knows what a patch directory looks like, and nothing in here should.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+
22
+ from .cellpose4 import train_cellpose4
23
+ from .cellpose3 import train_cellpose3
24
+ from .stardist2d import receptive_field_stardist2d, train_stardist2d
25
+
26
+ __all__ = [
27
+ "train_cellpose4",
28
+ "train_cellpose3",
29
+ "train_stardist2d",
30
+ "receptive_field_stardist2d",
31
+ "extent",
32
+ "theoretical_receptive_field",
33
+ ]
34
+
35
+
36
+ def extent(response) -> tuple[int, int]:
37
+ """The height and width, in pixels, that an impulse response covers.
38
+
39
+ Takes what a ``receptive_field_*`` op returns. Pure numpy, so it runs
40
+ wherever the caller is rather than in the op's environment.
41
+ """
42
+ reached = np.asarray(response) > 0
43
+ if not reached.any():
44
+ return (0, 0)
45
+ rows, cols = np.where(reached)
46
+ return (int(rows.max() - rows.min() + 1), int(cols.max() - cols.min() + 1))
47
+
48
+
49
+ def theoretical_receptive_field(
50
+ unet_n_depth: int = 3,
51
+ grid_size_xy: int = 1,
52
+ unet_kernel_size: int = 3,
53
+ unet_pool: int = 2,
54
+ unet_n_conv_per_depth: int = 2,
55
+ ) -> int:
56
+ """What the architecture predicts, for comparison with the measurement.
57
+
58
+ Counted the usual way -- each convolution adds ``kernel - 1`` times the
59
+ current stride, each pooling multiplies the stride -- down through the
60
+ encoder, across the bottleneck and back up. ``grid`` multiplies the result
61
+ because StarDist pools the input to grid size before the U-Net, so the
62
+ whole network sees a coarser image.
63
+
64
+ An estimate, and the measurement is the authority: it counts the final
65
+ convolutions after the U-Net, which this does not, so it reads low.
66
+ Compare the two, and when they disagree believe the impulse.
67
+ """
68
+ k, pool = unet_kernel_size, unet_pool
69
+ r, stride = 1, 1
70
+ for _ in range(unet_n_depth):
71
+ for _ in range(unet_n_conv_per_depth):
72
+ r += (k - 1) * stride
73
+ r += (pool - 1) * stride
74
+ stride *= pool
75
+ for _ in range(unet_n_conv_per_depth):
76
+ r += (k - 1) * stride
77
+ for _ in range(unet_n_depth):
78
+ stride //= pool
79
+ for _ in range(unet_n_conv_per_depth):
80
+ r += (k - 1) * stride
81
+ return r * grid_size_xy
@@ -0,0 +1,152 @@
1
+ """Shared by the two Cellpose trainers.
2
+
3
+ Both wrap the same ``cellpose.train.train_seg`` in different versions of the
4
+ package. Pure python, so either environment can import it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import logging
11
+ import os
12
+ import re
13
+
14
+ from opspec.runtime import progress
15
+
16
+ #: The line train_seg logs at the end of an epoch, in both versions:
17
+ #: ``12, train_loss=0.4213, test_loss=0.5012, LR=0.000010, time 3.21s``
18
+ _EPOCH_LINE = re.compile(r"^(\d+), train_loss=(\S+), test_loss=(\S+),")
19
+
20
+
21
+ class relay_epochs:
22
+ """Turn Cellpose's epoch log line into ``progress()`` calls.
23
+
24
+ ``train_seg`` takes no callback, so its log is the only live signal. A
25
+ handler rather than a stream capture: Appose's protocol is on stdout.
26
+ Removed on exit, or the next run reports this one's epochs too.
27
+ """
28
+
29
+ def __init__(self, epochs: int):
30
+ self.epochs = epochs
31
+ self.history: list[tuple[int, float, float]] = []
32
+
33
+ def __enter__(self):
34
+ relay = self
35
+
36
+ class Handler(logging.Handler):
37
+ def emit(self, record):
38
+ match = _EPOCH_LINE.match(record.getMessage())
39
+ if match is None:
40
+ return
41
+ epoch = int(match.group(1)) + 1
42
+ loss, val_loss = float(match.group(2)), float(match.group(3))
43
+ relay.history.append((epoch, loss, val_loss))
44
+ progress(
45
+ f"Epoch {epoch}/{relay.epochs} — "
46
+ f"loss {loss:.4f}, val_loss {val_loss:.4f}",
47
+ epoch,
48
+ relay.epochs,
49
+ )
50
+
51
+ self._handler = Handler()
52
+ self._log = logging.getLogger("cellpose.train")
53
+ self._level = self._log.level
54
+ self._propagate = self._log.propagate
55
+ # INFO, or the record is dropped before any handler sees it. No
56
+ # propagation, or it reaches a stdout handler and breaks the protocol.
57
+ self._log.setLevel(logging.INFO)
58
+ self._log.propagate = False
59
+ self._log.addHandler(self._handler)
60
+ return self
61
+
62
+ def __exit__(self, *exc):
63
+ self._log.removeHandler(self._handler)
64
+ self._log.setLevel(self._level)
65
+ self._log.propagate = self._propagate
66
+
67
+
68
+ def continued_from(model_path, initial_model: str) -> bool:
69
+ """Whether this run carried on the model it is about to write.
70
+
71
+ True only when ``initial_model`` is the same file as ``model_path``.
72
+ Starting from a built-in, or from some *other* model of your own, is a new
73
+ lineage however familiar the name -- the weights do not continue, so
74
+ neither does the curve.
75
+ """
76
+ if not initial_model:
77
+ return False
78
+ try:
79
+ return os.path.samefile(initial_model, model_path)
80
+ except OSError:
81
+ # One of them does not exist yet -- the first run writing this name.
82
+ return os.path.abspath(initial_model) == os.path.abspath(model_path)
83
+
84
+
85
+ def write_history(
86
+ model_path, history, name: str, dataset_id: str, initial_model: str = ""
87
+ ) -> str:
88
+ """Write this run's epochs to ``<model file>_history.csv``. Columns match
89
+ ``train_stardist2d``. Rewritten rather than appended: an older file has a
90
+ shorter header, and wider rows read back wrong.
91
+
92
+ Kept epochs continue the old ones only when this run continued the old
93
+ *weights* -- see ``continued_from``. Otherwise the previous history is
94
+ dropped, because Cellpose has just overwritten the model it described:
95
+ keeping a curve for weights that no longer exist draws one training run
96
+ as though it were the tail of another.
97
+ """
98
+ columns = ["epoch", "loss", "val_loss", "run", "model", "dataset"]
99
+ path = f"{model_path}_history.csv"
100
+
101
+ previous = []
102
+ if continued_from(model_path, initial_model) and os.path.exists(path):
103
+ with open(path) as f:
104
+ previous = list(csv.DictReader(f))
105
+ last_run = int(previous[-1].get("run") or 0) if previous else 0
106
+ # Where the last run got to, not how many rows it wrote: Cellpose logs
107
+ # every tenth epoch, so rows and epochs are not the same count.
108
+ done = int(previous[-1].get("epoch") or 0) if previous else 0
109
+
110
+ with open(path, "w", newline="") as f:
111
+ writer = csv.DictWriter(f, columns, restval="")
112
+ writer.writeheader()
113
+ for row in previous:
114
+ writer.writerow({k: row.get(k, "") for k in columns})
115
+ for epoch, loss, val_loss in history:
116
+ writer.writerow(
117
+ {
118
+ "epoch": done + epoch,
119
+ "loss": loss,
120
+ "val_loss": val_loss,
121
+ "run": last_run + 1,
122
+ "model": name,
123
+ "dataset": dataset_id,
124
+ }
125
+ )
126
+ return path
127
+
128
+
129
+ def read_pairs(images: list[str], labels: list[str], val_size: int):
130
+ """Read the patches, validation split off the end. Not random -- a caller
131
+ wanting that shuffles its own lists and records the seed."""
132
+ from tifffile import imread
133
+
134
+ if len(images) != len(labels):
135
+ raise ValueError(
136
+ f"{len(images)} images and {len(labels)} labels: a training set "
137
+ f"is pairs, so the two lists must be the same length."
138
+ )
139
+ if len(images) <= val_size:
140
+ raise ValueError(
141
+ f"{len(images)} pairs is not enough to hold {val_size} back for "
142
+ f"validation and still train on the rest."
143
+ )
144
+
145
+ progress(f"Reading {len(images)} pairs", 0, len(images))
146
+ X, Y = [], []
147
+ for i, (image_path, label_path) in enumerate(zip(images, labels)):
148
+ X.append(imread(image_path))
149
+ Y.append(imread(label_path))
150
+ progress(current=i + 1)
151
+
152
+ return X[:-val_size], Y[:-val_size], X[-val_size:], Y[-val_size:]