itasc 0.2.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 (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,380 @@
1
+ """Divergence-based foreground & contour maps from Cellpose prob/dp outputs.
2
+
3
+ Replaces the (cellprob x z) mask sweep with a direct computation:
4
+
5
+ foreground = reduce_z(sigmoid(prob))
6
+ contours = reduce_z(clip(div(filter(dp)), 0, inf))
7
+
8
+ See ``notes/divergence_maps_spec.md`` for the rationale.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ import numpy as np
18
+ import tifffile
19
+ from scipy.ndimage import gaussian_filter, median_filter
20
+
21
+ from itasc.core.cancellation import CancelledError
22
+ from itasc.core.tiff import imwrite_grayscale
23
+
24
+
25
+ def _check_cancel(cancel: Callable[[], bool] | None) -> None:
26
+ if cancel is not None and cancel():
27
+ raise CancelledError("Operation cancelled.")
28
+
29
+
30
+ ZReduction = Literal["mean", "max"]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class DivergenceMapsReport:
35
+ """Summary returned by :func:`build_divergence_maps`."""
36
+
37
+ frames: int
38
+ foreground_z_reduction: ZReduction
39
+ contour_z_reduction: ZReduction
40
+ smoothing_sigma: float
41
+ median_radius: int
42
+ contours_path: Path
43
+ foreground_path: Path
44
+ foreground_smoothing_sigma: float = 0.0
45
+ foreground_median_radius: int = 0
46
+
47
+
48
+ def sigmoid(x: np.ndarray) -> np.ndarray:
49
+ """Numerically stable sigmoid on float32 logits."""
50
+ x = np.clip(np.asarray(x, dtype=np.float32), -88.0, 88.0)
51
+ return (1.0 / (1.0 + np.exp(-x))).astype(np.float32, copy=False)
52
+
53
+
54
+ def _reduce_z(arr_tzyx: np.ndarray, reduction: ZReduction) -> np.ndarray:
55
+ if reduction == "mean":
56
+ return arr_tzyx.mean(axis=1, dtype=np.float32).astype(np.float32, copy=False)
57
+ if reduction == "max":
58
+ return arr_tzyx.max(axis=1).astype(np.float32, copy=False)
59
+ raise ValueError(f"reduction must be 'mean' or 'max', got {reduction!r}")
60
+
61
+
62
+ def _filter_2d(
63
+ arr_yx: np.ndarray, *, smoothing_sigma: float, median_radius: int,
64
+ ) -> np.ndarray:
65
+ """Apply median -> gaussian to a single ``(Y, X)`` map. Order matters (spec)."""
66
+ out = np.asarray(arr_yx, dtype=np.float32)
67
+ if median_radius > 0:
68
+ out = median_filter(out, size=2 * int(median_radius) + 1)
69
+ if smoothing_sigma > 0.0:
70
+ out = gaussian_filter(out, sigma=float(smoothing_sigma))
71
+ return out.astype(np.float32, copy=False)
72
+
73
+
74
+ def foreground_from_prob(
75
+ prob_tzyx: np.ndarray,
76
+ *,
77
+ reduction: ZReduction,
78
+ smoothing_sigma: float = 0.0,
79
+ median_radius: int = 0,
80
+ ) -> np.ndarray:
81
+ """``sigmoid(prob)`` reduced across z, optionally smoothed.
82
+
83
+ Returns ``(T, Y, X)`` float32 in [0, 1]. When ``smoothing_sigma`` or
84
+ ``median_radius`` is set, each reduced frame is median- then gaussian-
85
+ filtered (defaults of ``0`` leave the foreground untouched).
86
+ """
87
+ p = sigmoid(prob_tzyx)
88
+ fg = _reduce_z(p, reduction)
89
+ if smoothing_sigma > 0.0 or median_radius > 0:
90
+ fg = np.stack(
91
+ [
92
+ _filter_2d(
93
+ fg[t], smoothing_sigma=smoothing_sigma, median_radius=median_radius,
94
+ )
95
+ for t in range(fg.shape[0])
96
+ ]
97
+ )
98
+ return fg
99
+
100
+
101
+ def divergence_2d(flow_yx: np.ndarray) -> np.ndarray:
102
+ """Divergence of a ``(2, Y, X)`` flow field with channels ``[dy, dx]``."""
103
+ flow_yx = np.asarray(flow_yx, dtype=np.float32)
104
+ if flow_yx.ndim != 3 or flow_yx.shape[0] != 2:
105
+ raise ValueError(
106
+ f"flow must be (2, Y, X) with channels [dy, dx]; got {flow_yx.shape}"
107
+ )
108
+ # np.gradient needs >= 2 samples along an axis; a singleton Y or X (e.g. a
109
+ # 1-pixel crop) has zero divergence contribution there, so skip it.
110
+ d_dy = (
111
+ np.gradient(flow_yx[0], axis=0)
112
+ if flow_yx.shape[1] >= 2
113
+ else np.zeros_like(flow_yx[0])
114
+ )
115
+ d_dx = (
116
+ np.gradient(flow_yx[1], axis=1)
117
+ if flow_yx.shape[2] >= 2
118
+ else np.zeros_like(flow_yx[1])
119
+ )
120
+ return (d_dy + d_dx).astype(np.float32, copy=False)
121
+
122
+
123
+ def _filter_flow(
124
+ flow_2yx: np.ndarray, *, smoothing_sigma: float, median_radius: int,
125
+ ) -> np.ndarray:
126
+ """Apply median -> gaussian per channel. Order matters (spec)."""
127
+ out = flow_2yx
128
+ if median_radius > 0:
129
+ size = 2 * int(median_radius) + 1
130
+ out = np.stack(
131
+ [median_filter(out[0], size=size), median_filter(out[1], size=size)],
132
+ axis=0,
133
+ )
134
+ if smoothing_sigma > 0.0:
135
+ sigma = float(smoothing_sigma)
136
+ out = np.stack(
137
+ [gaussian_filter(out[0], sigma=sigma), gaussian_filter(out[1], sigma=sigma)],
138
+ axis=0,
139
+ )
140
+ return out
141
+
142
+
143
+ def contour_from_dp(
144
+ dp_tzcyx: np.ndarray,
145
+ *,
146
+ smoothing_sigma: float,
147
+ median_radius: int,
148
+ reduction: ZReduction,
149
+ ) -> np.ndarray:
150
+ """Per (t, z): filter -> divergence -> clip(>=0); then reduce across z.
151
+
152
+ ``dp_tzcyx``: shape ``(T, Z, 2, Y, X)`` with channels ``[dy, dx]``.
153
+ Returns ``(T, Y, X)`` float32.
154
+ """
155
+ arr = np.asarray(dp_tzcyx, dtype=np.float32)
156
+ if arr.ndim != 5 or arr.shape[2] != 2:
157
+ raise ValueError(
158
+ f"dp must be (T, Z, 2, Y, X) with channels [dy, dx]; got {arr.shape}"
159
+ )
160
+ n_t, n_z, _, n_y, n_x = arr.shape
161
+ pos = np.empty((n_t, n_z, n_y, n_x), dtype=np.float32)
162
+ for t in range(n_t):
163
+ pos[t] = _positive_divergence_z_stack(
164
+ arr[t],
165
+ smoothing_sigma=smoothing_sigma,
166
+ median_radius=median_radius,
167
+ )
168
+ return _reduce_z(pos, reduction)
169
+
170
+
171
+ def _positive_divergence_z_stack(
172
+ dp_zcyx: np.ndarray,
173
+ *,
174
+ smoothing_sigma: float,
175
+ median_radius: int,
176
+ progress_cb: Callable[[int, int], None] | None = None,
177
+ ) -> np.ndarray:
178
+ n_z, _, n_y, n_x = dp_zcyx.shape
179
+ pos = np.empty((n_z, n_y, n_x), dtype=np.float32)
180
+ for z in range(n_z):
181
+ filt = _filter_flow(
182
+ dp_zcyx[z], smoothing_sigma=smoothing_sigma, median_radius=median_radius,
183
+ )
184
+ div = divergence_2d(filt)
185
+ np.clip(div, 0.0, None, out=div)
186
+ pos[z] = div
187
+ if progress_cb is not None:
188
+ progress_cb(z + 1, n_z)
189
+ return pos
190
+
191
+
192
+ class _LazyTiffStack:
193
+ """Frame-at-a-time reader for a (T,Z,Y,X) / (T,Z,2,Y,X) TIFF stack.
194
+
195
+ Reads only the TIFF header on construction (so callers can validate shapes
196
+ without paying for a full decompress) and decodes one frame's pages per
197
+ :meth:`frame` call. Compressed stacks store one page per 2D plane, so a
198
+ per-frame ``key`` slice touches only that frame's pages — this is what lets
199
+ :func:`build_divergence_maps` start reporting progress immediately instead
200
+ of stalling while both whole stacks are read up front.
201
+ """
202
+
203
+ def __init__(self, path: str | Path, *, ndim: int, name: str) -> None:
204
+ self._path = Path(path)
205
+ self._name = name
206
+ with tifffile.TiffFile(str(self._path)) as tf:
207
+ series = tf.series[0]
208
+ disk_shape = tuple(int(x) for x in series.shape)
209
+ axes = str(series.axes)
210
+ shape = self._canonical_shape(disk_shape, axes, ndim, name)
211
+ self.shape = shape
212
+ self._frame_shape = shape[1:]
213
+ # Pages per frame = product of the non-(Y,X) per-frame axes (Z, or Z*2).
214
+ self._pages_per_frame = int(np.prod(shape[1:-2], dtype=int))
215
+
216
+ @staticmethod
217
+ def _canonical_shape(
218
+ disk_shape: tuple[int, ...], axes: str, ndim: int, name: str,
219
+ ) -> tuple[int, ...]:
220
+ """Recover the logical ``(T,Z,Y,X)`` / ``(T,Z,2,Y,X)`` shape.
221
+
222
+ TIFF drops singleton leading axes on write, so a 2D+t prob ``(T,1,Y,X)``
223
+ lands on disk as ``(T,Y,X)`` — indistinguishable from a single z-stack by
224
+ shape alone. When the writer recorded axis labels (our ``write_outputs``),
225
+ map the surviving letters back to canonical order, inserting size-1 for
226
+ any dropped ``T``/``Z``/``C``. Otherwise fall back to the length heuristic
227
+ for legacy/metadata-less files (which are never singleton-squeezed).
228
+ """
229
+ canonical = "TZYX" if ndim == 4 else "TZCYX"
230
+ label = "Z×Y×X or T×Z×Y×X" if ndim == 4 else "Z×2×Y×X or T×Z×2×Y×X"
231
+ if len(axes) == len(disk_shape) and set("YX") <= set(axes) <= set(canonical):
232
+ present = dict(zip(axes, disk_shape))
233
+ shape = tuple(present.get(a, 1) for a in canonical)
234
+ else:
235
+ shape = disk_shape
236
+ # A frameless 3D/4D stack is a single timepoint: prepend T=1.
237
+ if len(shape) == ndim - 1:
238
+ shape = (1,) + shape
239
+ if len(shape) != ndim:
240
+ raise ValueError(f"{name} must be {label}.")
241
+ if ndim == 5 and shape[2] != 2:
242
+ if shape[1] == 3:
243
+ raise ValueError(
244
+ f"{name} has shape {shape}, which is Cellpose 3D flows "
245
+ f"(T×3×Z×Y×X from do_3d=True). The divergence builder needs "
246
+ f"per-z 2-component flows (T×Z×2×Y×X); re-run nucleus Cellpose "
247
+ f"with do_3d=False for the divergence pipeline."
248
+ )
249
+ raise ValueError(f"{name} must be Z×2×Y×X or T×Z×2×Y×X.")
250
+ return shape
251
+
252
+ def frame(self, t: int) -> np.ndarray:
253
+ """Frame ``t`` as float32, shaped ``(Z,Y,X)`` or ``(Z,2,Y,X)``."""
254
+ start = t * self._pages_per_frame
255
+ arr = tifffile.imread(
256
+ str(self._path), key=slice(start, start + self._pages_per_frame),
257
+ )
258
+ return np.asarray(arr, dtype=np.float32).reshape(self._frame_shape)
259
+
260
+
261
+ def build_divergence_maps(
262
+ prob_path: str | Path,
263
+ dp_path: str | Path,
264
+ contours_out: str | Path,
265
+ foreground_out: str | Path,
266
+ *,
267
+ foreground_z_reduction: ZReduction,
268
+ contour_z_reduction: ZReduction,
269
+ smoothing_sigma: float,
270
+ median_radius: int,
271
+ foreground_smoothing_sigma: float = 0.0,
272
+ foreground_median_radius: int = 0,
273
+ progress_cb: Callable[[int, int, str], None] | None = None,
274
+ cancel: Callable[[], bool] | None = None,
275
+ ) -> DivergenceMapsReport:
276
+ """Compute and write ``contours`` and ``foreground`` from Cellpose prob/dp.
277
+
278
+ Output stacks are ``T x Y x X`` float32. ``progress_cb`` is called for each
279
+ foreground frame, contour z-slice, and output write. ``smoothing_sigma`` /
280
+ ``median_radius`` filter the flow before divergence (contours); the
281
+ ``foreground_*`` knobs filter the reduced foreground map independently.
282
+ """
283
+ prob_stack = _LazyTiffStack(prob_path, ndim=4, name="prob")
284
+ dp_stack = _LazyTiffStack(dp_path, ndim=5, name="dp")
285
+ if prob_stack.shape[0] != dp_stack.shape[0]:
286
+ raise ValueError("prob and dp must have the same frame count.")
287
+ if prob_stack.shape[1] != dp_stack.shape[1]:
288
+ raise ValueError("prob and dp must have the same z count.")
289
+ if prob_stack.shape[2:] != dp_stack.shape[3:]:
290
+ raise ValueError("prob and dp must have the same Y×X shape.")
291
+
292
+ n_t = int(prob_stack.shape[0])
293
+ n_z = int(prob_stack.shape[1])
294
+ if n_t == 0:
295
+ raise ValueError("prob/dp stack has no frames (T=0); nothing to compute.")
296
+ total_steps = n_t + (n_t * n_z) + 2
297
+ progress_done = 0
298
+ contour_frames: list[np.ndarray] = []
299
+ foreground_frames: list[np.ndarray] = []
300
+ for t in range(n_t):
301
+ _check_cancel(cancel)
302
+ fg = foreground_from_prob(
303
+ prob_stack.frame(t)[np.newaxis],
304
+ reduction=foreground_z_reduction,
305
+ smoothing_sigma=foreground_smoothing_sigma,
306
+ median_radius=foreground_median_radius,
307
+ )
308
+ foreground_frames.append(fg[0])
309
+ progress_done += 1
310
+ if progress_cb is not None:
311
+ progress_cb(
312
+ progress_done,
313
+ total_steps,
314
+ f"Divergence maps: foreground frame {t + 1}/{n_t}",
315
+ )
316
+
317
+ def _progress_z(z_done: int, z_total: int) -> None:
318
+ nonlocal progress_done
319
+
320
+ _check_cancel(cancel)
321
+ progress_done += 1
322
+ if progress_cb is not None:
323
+ progress_cb(
324
+ progress_done,
325
+ total_steps,
326
+ (
327
+ f"Divergence maps: contours frame {t + 1}/{n_t} "
328
+ f"z {z_done}/{z_total}"
329
+ ),
330
+ )
331
+
332
+ pos_div = _positive_divergence_z_stack(
333
+ dp_stack.frame(t),
334
+ smoothing_sigma=smoothing_sigma,
335
+ median_radius=median_radius,
336
+ progress_cb=_progress_z,
337
+ )
338
+ contour_frames.append(
339
+ _reduce_z(pos_div[np.newaxis, ...], contour_z_reduction)[0]
340
+ )
341
+
342
+ _check_cancel(cancel)
343
+ contours_out = Path(contours_out)
344
+ foreground_out = Path(foreground_out)
345
+ contours_out.parent.mkdir(parents=True, exist_ok=True)
346
+ foreground_out.parent.mkdir(parents=True, exist_ok=True)
347
+ imwrite_grayscale(
348
+ contours_out, np.stack(contour_frames).astype(np.float32),
349
+ compression="zlib", metadata={"axes": "TYX"},
350
+ )
351
+ progress_done += 1
352
+ if progress_cb is not None:
353
+ progress_cb(progress_done, total_steps, "Divergence maps: writing contours")
354
+ imwrite_grayscale(
355
+ foreground_out, np.stack(foreground_frames).astype(np.float32),
356
+ compression="zlib", metadata={"axes": "TYX"},
357
+ )
358
+ progress_done += 1
359
+ if progress_cb is not None:
360
+ progress_cb(progress_done, total_steps, "Divergence maps: writing foreground")
361
+ return DivergenceMapsReport(
362
+ frames=n_t,
363
+ foreground_z_reduction=foreground_z_reduction,
364
+ contour_z_reduction=contour_z_reduction,
365
+ smoothing_sigma=float(smoothing_sigma),
366
+ median_radius=int(median_radius),
367
+ contours_path=contours_out,
368
+ foreground_path=foreground_out,
369
+ )
370
+
371
+
372
+ __all__ = [
373
+ "CancelledError",
374
+ "DivergenceMapsReport",
375
+ "build_divergence_maps",
376
+ "contour_from_dp",
377
+ "divergence_2d",
378
+ "foreground_from_prob",
379
+ "sigmoid",
380
+ ]
@@ -0,0 +1,283 @@
1
+ """Flow-following cell segmentation, Qt-free, for the standalone distro.
2
+
3
+ When the standalone tool has **both** a nucleus and a cell channel, it can do
4
+ better than independent native masks: it assigns each cell-foreground pixel to a
5
+ *nucleus*, giving one cell per nucleus with the cell sharing the nucleus' id.
6
+ The cell's *extent* is decided by Cellpose's own flow field rather than by naive
7
+ distance.
8
+
9
+ This is the proven two-phase algorithm (recovered from the pre-publication
10
+ ``segmentation/flow_following.py``):
11
+
12
+ 1. **Integrate.** Each foreground pixel is Euler-integrated along a blend of the
13
+ Cellpose flow vector and an EDT *gravity* vector pointing toward the nearest
14
+ nucleus (``flow_weight`` mixes the two). A pixel that lands on a nucleus pixel
15
+ during integration inherits that nucleus' label immediately.
16
+ 2. **Grow.** Remaining foreground pixels are assigned by growing the labelled
17
+ region outward in shells through the *displaced* (post-integration) positions,
18
+ so labels chain-propagate along the flow topology. Pixels whose displaced
19
+ position has no labelled neighbour within ``max_assign_radius`` stay
20
+ background (orphans are dropped, not force-assigned).
21
+
22
+ The numba kernel is lazily JIT-compiled when numba is importable (it ships with
23
+ the Cellpose stack) and falls back to the identical pure-Python function
24
+ otherwise, so importing this module never requires numba.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+
30
+ import numpy as np
31
+ from scipy.ndimage import distance_transform_edt
32
+
33
+ __all__ = [
34
+ "FlowFollowingParams",
35
+ "flow_follow_frame",
36
+ "flow_follow_movie",
37
+ ]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class FlowFollowingParams:
42
+ """Parameters for flow-following cell assignment."""
43
+
44
+ fg_threshold: float = 0.5
45
+ """Cell-foreground cutoff on the sigmoid probability (``sigmoid(prob) >
46
+ fg_threshold``). Exposed knob — pixels below it are not assigned."""
47
+
48
+ flow_weight: float = 0.5
49
+ """Blend between the Cellpose flow vector (``1``) and the EDT gravity vector
50
+ toward the nearest nucleus (``0``). ``0.5`` mixes both equally."""
51
+
52
+ flow_step_scale: float = 0.2
53
+ """Euler step size (multiplies the unit blended vector each iteration)."""
54
+
55
+ max_iterations: int = 100
56
+ """Maximum integration steps per pixel."""
57
+
58
+ shell_width: float = 5.0
59
+ """Per-iteration capture distance when growing labels in shells (px)."""
60
+
61
+ max_assign_radius: float = 30.0
62
+ """A displaced foreground pixel with no labelled pixel within this radius
63
+ stays background — orphan foreground is dropped, not force-assigned."""
64
+
65
+
66
+ # ── Phase 1: Euler integration (lazy-JIT kernel + pure-Python twin) ──────────
67
+
68
+ def _integrate_positions_py(
69
+ nuclear_labels, # (H, W) int32
70
+ flow, # (2, H, W) float32 — flow[0]=dy, flow[1]=dx
71
+ grav_y, # (H, W) float32
72
+ grav_x, # (H, W) float32
73
+ prob_mask, # (H, W) bool
74
+ n_steps, # int
75
+ flow_step_scale, # float
76
+ flow_weight, # float
77
+ ):
78
+ """Integrate each foreground pixel along ``flow_weight*flow + (1-w)*gravity``.
79
+
80
+ Returns ``(result, final_y, final_x)``: a label map where nucleus pixels keep
81
+ their label and foreground pixels that land directly on a nucleus inherit it,
82
+ plus the displaced position of every pixel after integration.
83
+ """
84
+ H = nuclear_labels.shape[0]
85
+ W = nuclear_labels.shape[1]
86
+ result = nuclear_labels.copy()
87
+ final_y = np.empty((H, W), dtype=np.float32)
88
+ final_x = np.empty((H, W), dtype=np.float32)
89
+
90
+ for i in range(H):
91
+ for j in range(W):
92
+ final_y[i, j] = np.float32(i)
93
+ final_x[i, j] = np.float32(j)
94
+
95
+ if nuclear_labels[i, j] > 0 or not prob_mask[i, j]:
96
+ continue
97
+
98
+ py = np.float32(i)
99
+ px = np.float32(j)
100
+ for _ in range(n_steps):
101
+ iy = min(max(int(py), 0), H - 1)
102
+ ix = min(max(int(px), 0), W - 1)
103
+ fy = (
104
+ flow_weight * flow[0, iy, ix]
105
+ + (np.float32(1.0) - flow_weight) * grav_y[iy, ix]
106
+ )
107
+ fx = (
108
+ flow_weight * flow[1, iy, ix]
109
+ + (np.float32(1.0) - flow_weight) * grav_x[iy, ix]
110
+ )
111
+ py = py + fy * flow_step_scale
112
+ px = px + fx * flow_step_scale
113
+ py = min(max(py, np.float32(0.0)), np.float32(H - 1))
114
+ px = min(max(px, np.float32(0.0)), np.float32(W - 1))
115
+ if nuclear_labels[int(py), int(px)] > 0:
116
+ result[i, j] = nuclear_labels[int(py), int(px)]
117
+ break
118
+
119
+ final_y[i, j] = py
120
+ final_x[i, j] = px
121
+
122
+ return result, final_y, final_x
123
+
124
+
125
+ def _integrate_positions(*args):
126
+ """Dispatch to the numba-compiled kernel when available, else pure Python."""
127
+ global _INTEGRATE
128
+ if _INTEGRATE is None:
129
+ try: # numba ships with the cellpose stack; optional otherwise.
130
+ import numba
131
+
132
+ _INTEGRATE = numba.njit(cache=True)(_integrate_positions_py)
133
+ except Exception: # pragma: no cover - exercised only without numba
134
+ _INTEGRATE = _integrate_positions_py
135
+ return _INTEGRATE(*args)
136
+
137
+
138
+ _INTEGRATE = None
139
+
140
+
141
+ # ── Phase 2: progressive shell growth (orphan-bounded) ───────────────────────
142
+
143
+ def _progressive_shell_assign(
144
+ labels: np.ndarray,
145
+ final_y: np.ndarray,
146
+ final_x: np.ndarray,
147
+ foreground: np.ndarray,
148
+ *,
149
+ shell_width: float,
150
+ max_assign_radius: float,
151
+ ) -> np.ndarray:
152
+ """Grow labels outward through displaced positions; drop distant orphans.
153
+
154
+ Each iteration computes the EDT from currently-labelled pixels and assigns any
155
+ unassigned foreground pixel whose *displaced* position is within
156
+ ``shell_width`` of a labelled pixel. Newly labelled pixels seed the next EDT,
157
+ so labels chain-propagate along the flow topology. The growth stops once no
158
+ displaced position is within ``max_assign_radius`` of a label — remaining
159
+ pixels stay background.
160
+ """
161
+ H, W = labels.shape
162
+ result = labels.copy()
163
+ unassigned = foreground & (result == 0)
164
+
165
+ fy = np.clip(np.round(final_y).astype(np.intp), 0, H - 1)
166
+ fx = np.clip(np.round(final_x).astype(np.intp), 0, W - 1)
167
+
168
+ while unassigned.any():
169
+ unlabelled = result == 0
170
+ if not unlabelled.any():
171
+ break
172
+ dist, indices = distance_transform_edt(unlabelled, return_indices=True)
173
+ d = dist[fy, fx]
174
+ nearest = result[indices[0][fy, fx], indices[1][fy, fx]]
175
+ can_assign = unassigned & (d <= shell_width) & (nearest > 0)
176
+ if not can_assign.any():
177
+ break
178
+ result[can_assign] = nearest[can_assign]
179
+ unassigned &= ~can_assign
180
+
181
+ # Bounded fallback (orphan drop): assign any remaining pixel only if a label
182
+ # sits within max_assign_radius of its displaced position; else leave it 0.
183
+ if unassigned.any() and (result > 0).any():
184
+ unlabelled = result == 0
185
+ if unlabelled.any():
186
+ dist, indices = distance_transform_edt(unlabelled, return_indices=True)
187
+ d = dist[fy, fx]
188
+ nearest = result[indices[0][fy, fx], indices[1][fy, fx]]
189
+ close = unassigned & (d <= max_assign_radius) & (nearest > 0)
190
+ result[close] = nearest[close]
191
+
192
+ return result
193
+
194
+
195
+ # ── Gravity field ────────────────────────────────────────────────────────────
196
+
197
+ def _gravity_toward_nuclei(labels_yx: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
198
+ """Unit vectors at every pixel pointing toward the nearest nucleus pixel."""
199
+ nucleus = labels_yx > 0
200
+ _dist, indices = distance_transform_edt(~nucleus, return_indices=True)
201
+ H, W = labels_yx.shape
202
+ yy, xx = np.mgrid[:H, :W]
203
+ dy = (indices[0] - yy).astype(np.float32)
204
+ dx = (indices[1] - xx).astype(np.float32)
205
+ norm = np.hypot(dy, dx).astype(np.float32)
206
+ norm[norm == 0.0] = 1.0
207
+ return (dy / norm).astype(np.float32), (dx / norm).astype(np.float32)
208
+
209
+
210
+ # ── Public: single frame + movie ─────────────────────────────────────────────
211
+
212
+ def flow_follow_frame(
213
+ foreground_yx: np.ndarray,
214
+ dp_cyx: np.ndarray,
215
+ labels_yx: np.ndarray,
216
+ params: FlowFollowingParams,
217
+ ) -> np.ndarray:
218
+ """Assign one frame's cell foreground to nuclei via flow-following.
219
+
220
+ ``foreground_yx`` is a bool mask, ``dp_cyx`` the Cellpose cell flow
221
+ ``(2, Y, X)`` (``dy, dx``), ``labels_yx`` the tracked nucleus labels. Returns
222
+ ``(Y, X)`` int32 cell labels carrying the nucleus ids (background ``0``).
223
+ """
224
+ foreground_yx = np.ascontiguousarray(foreground_yx, dtype=np.bool_)
225
+ labels_yx = np.ascontiguousarray(labels_yx, dtype=np.int32)
226
+ if not foreground_yx.any() or labels_yx.max() == 0:
227
+ return np.zeros(foreground_yx.shape, dtype=np.int32)
228
+
229
+ # Normalise flow by mean foreground magnitude so the step scale is comparable
230
+ # across frames/datasets.
231
+ flow = np.ascontiguousarray(dp_cyx, dtype=np.float32).copy()
232
+ mag = np.sqrt(flow[0] ** 2 + flow[1] ** 2)
233
+ mean_mag = float(mag[foreground_yx].mean()) if foreground_yx.any() else 1.0
234
+ if mean_mag > 0:
235
+ flow /= np.float32(mean_mag)
236
+
237
+ grav_y, grav_x = _gravity_toward_nuclei(labels_yx)
238
+ result, final_y, final_x = _integrate_positions(
239
+ labels_yx,
240
+ flow,
241
+ np.ascontiguousarray(grav_y),
242
+ np.ascontiguousarray(grav_x),
243
+ foreground_yx,
244
+ int(params.max_iterations),
245
+ np.float32(params.flow_step_scale),
246
+ np.float32(params.flow_weight),
247
+ )
248
+ return _progressive_shell_assign(
249
+ result, final_y, final_x, foreground_yx,
250
+ shell_width=float(params.shell_width),
251
+ max_assign_radius=float(params.max_assign_radius),
252
+ ).astype(np.int32)
253
+
254
+
255
+ def flow_follow_movie(
256
+ foreground_tyx: np.ndarray,
257
+ dp_tcyx: np.ndarray,
258
+ labels_tyx: np.ndarray,
259
+ params: FlowFollowingParams,
260
+ *,
261
+ progress_cb=None,
262
+ ) -> np.ndarray:
263
+ """Run :func:`flow_follow_frame` over a ``(T, Y, X)`` series.
264
+
265
+ ``dp_tcyx`` is ``(T, 2, Y, X)``. Cell labels inherit the (already tracked)
266
+ nucleus ids per frame, so tracking the nuclei tracks the cells. Returns
267
+ ``(T, Y, X)`` int32.
268
+ """
269
+ foreground = np.asarray(foreground_tyx, dtype=bool)
270
+ labels = np.asarray(labels_tyx, dtype=np.int32)
271
+ flow = np.asarray(dp_tcyx, dtype=np.float32)
272
+ if foreground.ndim != 3 or labels.shape != foreground.shape:
273
+ raise ValueError("foreground and labels must share shape (T, Y, X)")
274
+ T, Y, X = foreground.shape
275
+ if flow.shape != (T, 2, Y, X):
276
+ raise ValueError(f"dp_tcyx must be (T, 2, Y, X); got {flow.shape}")
277
+
278
+ out = np.zeros((T, Y, X), dtype=np.int32)
279
+ for t in range(T):
280
+ out[t] = flow_follow_frame(foreground[t], flow[t], labels[t], params)
281
+ if progress_cb is not None:
282
+ progress_cb(t + 1, T, f"Flow-following: frame {t + 1}/{T}...")
283
+ return out