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
itasc/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Top-level package metadata for ITASC.
2
+
3
+ The supported top-level API is intentionally small while ITASC is under
4
+ active research development. Import concrete workflow functions from their
5
+ subpackage modules.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("itasc")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.2.0"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,31 @@
1
+ """Cellpose stage + standalone segment/track tool.
2
+
3
+ Two roles share this package:
4
+
5
+ * **Integrated app** — the local Cellpose-SAM runner (``cellpose_runner``) and
6
+ the divergence-derived foreground/contour maps (``build_divergence_maps``) that
7
+ the orchestrator's in-app stage produces for nucleus tracking
8
+ (``itasc-tracking``) and cell segmentation.
9
+ * **Standalone ``itasc-cellpose`` tool** — ``native_masks`` captures the
10
+ Cellpose native masks the runner otherwise discards, and ``track_laptrack``
11
+ links them across time, giving a self-contained "segment then track" product.
12
+
13
+ The Cellpose model is the optional ``[cellpose]`` extra and ``laptrack`` is the
14
+ ``[laptrack]`` extra; both are imported lazily, so importing this package does
15
+ not require either.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from itasc.cellpose import cellpose_runner, native_masks, track_laptrack
20
+ from itasc.cellpose.divergence_maps import (
21
+ DivergenceMapsReport,
22
+ build_divergence_maps,
23
+ )
24
+
25
+ __all__ = [
26
+ "DivergenceMapsReport",
27
+ "build_divergence_maps",
28
+ "cellpose_runner",
29
+ "native_masks",
30
+ "track_laptrack",
31
+ ]
@@ -0,0 +1,334 @@
1
+ """Local Cellpose-SAM runner, Qt-free, used by the napari Cellpose widget."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+ from collections.abc import Callable
8
+
9
+ import numpy as np
10
+
11
+ from itasc.core.tiff import imwrite_grayscale
12
+
13
+ _NORMALIZE = {"tile_norm_blocksize": 128}
14
+
15
+ InputLayout = Literal["2D", "2D+t", "3D", "3D+t"]
16
+
17
+ # layout -> (has_time, has_z); canonical processing shape is always (T, Z, Y, X).
18
+ _LAYOUT_AXES: dict[str, tuple[bool, bool]] = {
19
+ "2D": (False, False),
20
+ "2D+t": (True, False),
21
+ "3D": (False, True),
22
+ "3D+t": (True, True),
23
+ }
24
+
25
+
26
+ def layout_has_time(layout: str) -> bool:
27
+ return _LAYOUT_AXES[layout][0]
28
+
29
+
30
+ def layout_has_z(layout: str) -> bool:
31
+ return _LAYOUT_AXES[layout][1]
32
+
33
+
34
+ def infer_layout_from_ndim(ndim: int) -> str | None:
35
+ """Best-effort layout from array ndim.
36
+
37
+ ``2 -> "2D"`` and ``4 -> "3D+t"`` are unambiguous; ``3`` is ambiguous
38
+ (``2D+t`` vs ``3D``) so returns ``None`` and the caller keeps the user's
39
+ explicit choice.
40
+ """
41
+ if ndim == 2:
42
+ return "2D"
43
+ if ndim == 4:
44
+ return "3D+t"
45
+ return None
46
+
47
+
48
+ def to_tzyx(arr: np.ndarray, layout: str) -> np.ndarray:
49
+ """Normalize an input array to canonical ``(T, Z, Y, X)`` for its layout.
50
+
51
+ Singleton ``T`` and/or ``Z`` axes are inserted so 2D/2D+t/3D/3D+t inputs all
52
+ become 4-D; the runner then iterates uniformly over frames and z-slices.
53
+ """
54
+ if layout not in _LAYOUT_AXES:
55
+ raise ValueError(f"unknown input layout {layout!r}")
56
+ has_time, has_z = _LAYOUT_AXES[layout]
57
+ arr = np.asarray(arr)
58
+ expected_ndim = 2 + int(has_time) + int(has_z)
59
+ if arr.ndim != expected_ndim:
60
+ raise ValueError(
61
+ f"{layout} input must be {expected_ndim}-D, got shape {arr.shape}"
62
+ )
63
+ if not has_time:
64
+ arr = arr[np.newaxis] # add T at axis 0
65
+ if not has_z:
66
+ arr = arr[:, np.newaxis] # add Z at axis 1 (after T)
67
+ return arr
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class NucleusParams:
72
+ do_3d: bool
73
+ anisotropy: float
74
+ diameter: float # 0 means "let cpsam decide" (None passed to model)
75
+ min_size: int
76
+ gamma: float
77
+ # Cellpose cellprob (logit) threshold; 0.0 is Cellpose's default. Defaulted so
78
+ # the app (which never sets it) and existing callers are byte-for-byte unchanged.
79
+ cellprob_threshold: float = 0.0
80
+ # Cellpose flow-error QC: masks whose flow error exceeds this are removed; 0.4
81
+ # is Cellpose's default (0 disables QC, higher keeps more masks).
82
+ flow_threshold: float = 0.4
83
+ # Euler-integration steps for the flow dynamics; 0 -> auto (None passed to
84
+ # Cellpose, which derives it from the diameter). Both defaulted, so the app and
85
+ # existing callers stay byte-for-byte unchanged.
86
+ niter: int = 0
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class CellParams:
91
+ diameter: float
92
+ min_size: int
93
+ gamma: float
94
+
95
+
96
+ def _apply_gamma(img: np.ndarray, gamma: float) -> np.ndarray:
97
+ """Min/max-normalized gamma correction matching cellpose_full.py."""
98
+ if gamma == 1.0:
99
+ return np.asarray(img)
100
+ img = np.asarray(img, dtype=np.float32)
101
+ img_min = float(np.min(img))
102
+ img_max = float(np.max(img))
103
+ if img_max <= img_min:
104
+ return img
105
+ scaled = (img - img_min) / (img_max - img_min)
106
+ return (scaled ** gamma) * (img_max - img_min) + img_min
107
+
108
+
109
+ def _diameter_kwarg(diameter: float) -> float | None:
110
+ return None if diameter == 0 else float(diameter)
111
+
112
+
113
+ _MODEL = None
114
+
115
+
116
+ def _cuda_available() -> bool:
117
+ try:
118
+ import torch
119
+ except ImportError:
120
+ return False
121
+ return bool(torch.cuda.is_available())
122
+
123
+
124
+ def device_label() -> str:
125
+ return "cuda:0" if _cuda_available() else "cpu"
126
+
127
+
128
+ def is_model_loaded() -> bool:
129
+ return _MODEL is not None
130
+
131
+
132
+ def get_model():
133
+ """Lazy-load the cpsam model once per process; cached at module level."""
134
+ global _MODEL
135
+ if _MODEL is not None:
136
+ return _MODEL
137
+ from cellpose.models import CellposeModel
138
+
139
+ use_gpu = _cuda_available()
140
+ _MODEL = CellposeModel(
141
+ gpu=use_gpu,
142
+ pretrained_model="cpsam",
143
+ use_bfloat16=use_gpu,
144
+ )
145
+ return _MODEL
146
+
147
+
148
+ def run_nucleus_frame(
149
+ frame: np.ndarray,
150
+ z: int | None,
151
+ params: NucleusParams,
152
+ ) -> tuple[np.ndarray, np.ndarray]:
153
+ """Single-frame nucleus inference.
154
+
155
+ If ``z`` is None, runs full 3D over (Z, Y, X) and returns
156
+ prob with shape (Z, Y, X) and dp with shape (3, Z, Y, X).
157
+ If ``z`` is an integer, runs 2D on frame[z] and returns
158
+ prob with shape (Y, X) and dp with shape (2, Y, X).
159
+ """
160
+ model = get_model()
161
+ diameter = _diameter_kwarg(params.diameter)
162
+ if z is None:
163
+ volume = _apply_gamma(frame, params.gamma)
164
+ _, flows, _ = model.eval(
165
+ volume,
166
+ do_3D=True,
167
+ z_axis=0,
168
+ diameter=diameter,
169
+ anisotropy=params.anisotropy,
170
+ min_size=params.min_size,
171
+ normalize=_NORMALIZE,
172
+ )
173
+ else:
174
+ slice_2d = _apply_gamma(frame[z], params.gamma)
175
+ _, flows, _ = model.eval(
176
+ slice_2d,
177
+ diameter=diameter,
178
+ min_size=params.min_size,
179
+ normalize=_NORMALIZE,
180
+ )
181
+ dp = np.asarray(flows[1], dtype=np.float32)
182
+ prob = np.asarray(flows[2], dtype=np.float32)
183
+ return prob, dp
184
+
185
+
186
+ class CancelledError(RuntimeError):
187
+ """Raised by run_*_stack when cancel_cb returns True between frames."""
188
+
189
+
190
+ def _check_cancel(cancel_cb: Callable[[], bool] | None) -> None:
191
+ if cancel_cb is not None and cancel_cb():
192
+ raise CancelledError("cellpose run cancelled")
193
+
194
+
195
+ def run_cell_frame(
196
+ frame: np.ndarray,
197
+ z: int,
198
+ params: CellParams,
199
+ ) -> tuple[np.ndarray, np.ndarray]:
200
+ """Single 2D-slice cell inference. Returns (prob (Y,X), dp (2,Y,X))."""
201
+ model = get_model()
202
+ diameter = _diameter_kwarg(params.diameter)
203
+ slice_2d = _apply_gamma(frame[z], params.gamma)
204
+ _, flows, _ = model.eval(
205
+ slice_2d,
206
+ diameter=diameter,
207
+ min_size=params.min_size,
208
+ normalize=_NORMALIZE,
209
+ )
210
+ dp = np.asarray(flows[1], dtype=np.float32)
211
+ prob = np.asarray(flows[2], dtype=np.float32)
212
+ return prob, dp
213
+
214
+
215
+ def run_nucleus_stack(
216
+ stack: np.ndarray,
217
+ params: NucleusParams,
218
+ *,
219
+ progress_cb: Callable[[int, int, str], None] | None = None,
220
+ cancel_cb: Callable[[], bool] | None = None,
221
+ ) -> tuple[np.ndarray, np.ndarray]:
222
+ """Process a (T, Z, Y, X) stack frame-by-frame.
223
+
224
+ Returns (prob_3dt, dp_3dt). For do_3d=True dp has shape (T, 3, Z, Y, X);
225
+ for do_3d=False dp has shape (T, Z, 2, Y, X).
226
+ """
227
+ if stack.ndim != 4:
228
+ raise ValueError(f"expected (T, Z, Y, X), got shape {stack.shape}")
229
+ T = stack.shape[0]
230
+ prob_frames: list[np.ndarray] = []
231
+ dp_frames: list[np.ndarray] = []
232
+ for t in range(T):
233
+ _check_cancel(cancel_cb)
234
+ if progress_cb is not None:
235
+ progress_cb(t, T, f"Nucleus: frame {t + 1}/{T}...")
236
+ if params.do_3d:
237
+ prob, dp = run_nucleus_frame(stack[t], z=None, params=params)
238
+ else:
239
+ Z = stack.shape[1]
240
+ slice_probs: list[np.ndarray] = []
241
+ slice_dps: list[np.ndarray] = []
242
+ for z in range(Z):
243
+ if progress_cb is not None:
244
+ progress_cb(
245
+ t,
246
+ T,
247
+ f"Nucleus: frame {t + 1}/{T}, z {z + 1}/{Z}...",
248
+ )
249
+ p, d = run_nucleus_frame(stack[t], z=z, params=params)
250
+ slice_probs.append(p)
251
+ slice_dps.append(d)
252
+ prob = np.stack(slice_probs, axis=0)
253
+ dp = np.stack(slice_dps, axis=0)
254
+ prob_frames.append(prob)
255
+ dp_frames.append(dp)
256
+ if progress_cb is not None:
257
+ progress_cb(t + 1, T, f"Nucleus: frame {t + 1}/{T}...")
258
+ return np.stack(prob_frames, axis=0), np.stack(dp_frames, axis=0)
259
+
260
+
261
+ def run_cell_stack(
262
+ stack: np.ndarray,
263
+ params: CellParams,
264
+ *,
265
+ progress_cb: Callable[[int, int, str], None] | None = None,
266
+ cancel_cb: Callable[[], bool] | None = None,
267
+ ) -> tuple[np.ndarray, np.ndarray]:
268
+ """Process a (T, Z, Y, X) stack slice-by-slice in 2D.
269
+
270
+ Returns (prob_3dt (T, Z, Y, X), dp_3dt (T, Z, 2, Y, X)).
271
+ """
272
+ if stack.ndim != 4:
273
+ raise ValueError(f"expected (T, Z, Y, X), got shape {stack.shape}")
274
+ T, Z = stack.shape[:2]
275
+ prob_frames: list[np.ndarray] = []
276
+ dp_frames: list[np.ndarray] = []
277
+ for t in range(T):
278
+ _check_cancel(cancel_cb)
279
+ if progress_cb is not None:
280
+ progress_cb(t, T, f"Cell: frame {t + 1}/{T}...")
281
+ slice_probs: list[np.ndarray] = []
282
+ slice_dps: list[np.ndarray] = []
283
+ for z in range(Z):
284
+ p, d = run_cell_frame(stack[t], z=z, params=params)
285
+ slice_probs.append(p)
286
+ slice_dps.append(d)
287
+ prob_frames.append(np.stack(slice_probs, axis=0))
288
+ dp_frames.append(np.stack(slice_dps, axis=0))
289
+ if progress_cb is not None:
290
+ progress_cb(t + 1, T, f"Cell: frame {t + 1}/{T}...")
291
+ return np.stack(prob_frames, axis=0), np.stack(dp_frames, axis=0)
292
+
293
+
294
+ def _dp_axes(dp_3dt: np.ndarray) -> str | None:
295
+ """Axis labels for the flow stack so singleton T/Z survive the TIFF round-trip.
296
+
297
+ 2D-per-slice flow is ``(T, Z, 2, Y, X)``; true-3D flow is ``(T, 3, Z, Y, X)``.
298
+ """
299
+ if dp_3dt.ndim == 5 and dp_3dt.shape[2] == 2:
300
+ return "TZCYX"
301
+ if dp_3dt.ndim == 5 and dp_3dt.shape[1] == 3:
302
+ return "TCZYX"
303
+ return None
304
+
305
+
306
+ def write_outputs(
307
+ prob_3dt: np.ndarray,
308
+ dp_3dt: np.ndarray,
309
+ output_dir: Path,
310
+ channel: Literal["nucleus", "cell"],
311
+ ) -> None:
312
+ """Write the two canonical TIFFs under output_dir.
313
+
314
+ Writes ``{channel}_prob.tif`` and ``{channel}_dp.tif``. Axis labels
315
+ are recorded as metadata so singleton ``T``/``Z`` axes (2D / 2D+t / single
316
+ 3D-stack inputs) survive the TIFF round-trip and are not misread downstream.
317
+ """
318
+ if channel not in ("nucleus", "cell"):
319
+ raise ValueError(f"channel must be 'nucleus' or 'cell', got {channel!r}")
320
+ if prob_3dt.ndim != 4:
321
+ raise ValueError(f"prob_3dt must be (T, Z, Y, X), got {prob_3dt.shape}")
322
+ output_dir = Path(output_dir)
323
+ output_dir.mkdir(parents=True, exist_ok=True)
324
+ prob_path = output_dir / f"{channel}_prob.tif"
325
+ dp_path = output_dir / f"{channel}_dp.tif"
326
+ imwrite_grayscale(
327
+ prob_path, prob_3dt.astype(np.float32),
328
+ compression="zlib", metadata={"axes": "TZYX"},
329
+ )
330
+ dp_kwargs: dict = {"compression": "zlib"}
331
+ dp_axes = _dp_axes(dp_3dt)
332
+ if dp_axes is not None:
333
+ dp_kwargs["metadata"] = {"axes": dp_axes}
334
+ imwrite_grayscale(dp_path, dp_3dt.astype(np.float32), **dp_kwargs)