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,1253 @@
1
+ """Standalone Cellpose *segment + track* widget for the ``itasc-cellpose`` tool.
2
+
3
+ This is the independently-shipped distribution's own surface — distinct from the
4
+ app's :class:`~itasc.napari.cellpose_widget.CellposeWidget`, which stays
5
+ untouched and keeps emitting divergence maps for the integrated pipeline.
6
+
7
+ The tool works on **one or two channels**, with no nucleus/cell vocabulary:
8
+
9
+ * **Channel 1** is the *anchor*. On its own it is segmented (Cellpose **native
10
+ masks**, :mod:`itasc.cellpose.native_masks`) and tracked across time with
11
+ **laptrack** (:mod:`itasc.cellpose.track_laptrack`) — the single-channel
12
+ segment + track product.
13
+ * **Channel 2** is optional. When it is present the tool runs **joint** mode and
14
+ *only* joint mode: Channel 1 is segmented + tracked, then each Channel 2
15
+ foreground pixel is flowed along Cellpose's flow field onto the nearest
16
+ Channel-1 object (:mod:`itasc.cellpose.flow_following`). You get one
17
+ Channel-2 object per Channel-1 object, sharing its track id. Channel 2 is never
18
+ segmented independently — there is no separate-masks alternative.
19
+
20
+ Conventionally Channel 1 is the nucleus (a clean, separable anchor) and Channel 2
21
+ the cell, but nothing here assumes that.
22
+
23
+ Each channel's input is the **active image layer**: select an image in the viewer
24
+ and click the channel's source pill (``⧉``) to bind it — there is no file loading
25
+ and no path text field. That pill then doubles as a **live status light**: it
26
+ stays lit while its bound layer is present in the viewer and goes dark (releasing
27
+ the channel) the moment that layer is removed. **There is no output directory**:
28
+ every result is added straight to the napari viewer as a layer (tagged
29
+ ``[Channel 1]`` / ``[Channel 2]``), and the user saves whichever layers they want
30
+ via napari's own *Save Selected Layers*.
31
+ The embedded corrector edits whichever Labels layer is active, with the full
32
+ DB-free toolkit — select / spawn / erase / merge / swap / split (mouse + Delete),
33
+ fill-holes / fragment cleanup, and a greedy retracker on Q/E.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+
39
+ import napari
40
+ import numpy as np
41
+ import tifffile
42
+ from napari.layers import Image, Labels
43
+ from napari.qt.threading import thread_worker
44
+ from qtpy.QtCore import Signal
45
+ from qtpy.QtWidgets import (
46
+ QHBoxLayout,
47
+ QLabel,
48
+ QProgressBar,
49
+ QSizePolicy,
50
+ QVBoxLayout,
51
+ QWidget,
52
+ )
53
+
54
+ from itasc.napari._widget_helpers import (
55
+ dslider as _dslider,
56
+ islider as _islider,
57
+ tool_btn as _tool_btn,
58
+ )
59
+ from itasc.napari.ui_gate import ControlClass, UiGate
60
+ from itasc.napari.ui_style import (
61
+ add_section_pair_row,
62
+ section_grid,
63
+ stage_header_action_button,
64
+ stage_header_label,
65
+ status_label,
66
+ )
67
+ from itasc.napari.widgets import CollapsibleSection
68
+ from itasc.napari.correction._correction_utils import frame_view_2d
69
+ from itasc.napari.correction.cell_correction_widget import CellCorrectionWidget
70
+ from itasc.cellpose import cellpose_runner, native_masks, track_laptrack
71
+ from itasc.cellpose import joint as joint_mod
72
+ from itasc.cellpose.flow_following import FlowFollowingParams
73
+ from itasc.cellpose.shape import to_canonical_tzyx
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+ # User-facing channel labels (no nucleus/cell vocabulary). Channel 1 is the
78
+ # anchor that is segmented + tracked; Channel 2 (optional) is flowed onto it.
79
+ _CH1_LABEL = "Channel 1"
80
+ _CH2_LABEL = "Channel 2"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Array-shape helpers (canonical compute shape is (T, Z, Y, X))
85
+ # ---------------------------------------------------------------------------
86
+ def _to_tzyx(arr: np.ndarray) -> np.ndarray:
87
+ """Coerce a label/image array to canonical ``(T, Z, Y, X)``.
88
+
89
+ A 3-D array is read as ``(T, Y, X)`` and gains a singleton Z (the inverse of
90
+ :func:`_squeeze_z`, so a round-trip through a squeezed napari layer is exact);
91
+ a 2-D array becomes a single ``(1, 1, Y, X)`` frame.
92
+ """
93
+ arr = np.asarray(arr)
94
+ if arr.ndim == 4:
95
+ return arr
96
+ if arr.ndim == 3:
97
+ return arr[:, np.newaxis]
98
+ if arr.ndim == 2:
99
+ return arr[np.newaxis, np.newaxis]
100
+ raise ValueError(f"expected a 2-D..4-D array, got shape {arr.shape}")
101
+
102
+
103
+ def _squeeze_z(arr: np.ndarray) -> np.ndarray:
104
+ """Drop a singleton Z from ``(T, 1, Y, X)`` → ``(T, Y, X)`` for napari/corrector.
105
+
106
+ 2D+t data (Z=1) displays without a spurious slider and matches the basic
107
+ corrector's 3-D ``(T, Y, X)`` expectation. True 3-D+t (Z>1) is left as-is. An
108
+ RGB map keeps its trailing channel axis: ``(T, 1, Y, X, 3)`` → ``(T, Y, X, 3)``.
109
+ """
110
+ arr = np.asarray(arr)
111
+ if arr.ndim >= 4 and arr.shape[1] == 1:
112
+ return arr[:, 0]
113
+ return arr
114
+
115
+
116
+ def _prob_to_cellprob(p: float) -> float:
117
+ """Reverse the sigmoid that produced the displayed prob map → ``cellprob_threshold``.
118
+
119
+ The prob-map layer shows ``sigmoid(cellprob)``; Cellpose thresholds the raw
120
+ pre-sigmoid ``cellprob``. So the ``[0, 1]`` cutoff ``p`` maps back by the inverse
121
+ sigmoid (logit), ``log(p / (1 - p))`` — the slider then reads in the same space
122
+ as the prob-map image. ``0.5 → 0.0`` (Cellpose's default); ``p`` is clamped off
123
+ the open ends to keep the logit finite.
124
+ """
125
+ p = min(max(float(p), 1e-4), 1.0 - 1e-4)
126
+ return float(np.log(p / (1.0 - p)))
127
+
128
+
129
+ def _layer_name(channel_label: str, kind: str) -> str:
130
+ """Channel-tagged layer name, e.g. ``[Channel 1] masks`` / ``[Channel 2] tracked``."""
131
+ return f"[{channel_label}] {kind}"
132
+
133
+
134
+ def _segment_done_status(masks_name: str, prob_name: str, flow_name: str) -> str:
135
+ return (
136
+ f"Channel 1 → '{masks_name}', '{prob_name}', '{flow_name}'. "
137
+ "Save from the layers. Track to link masks across time."
138
+ )
139
+
140
+
141
+ def _track_done_status(masks_name: str) -> str:
142
+ return (
143
+ f"Channel 1 tracked → '{masks_name}' updated in place. "
144
+ "Select it below to correct."
145
+ )
146
+
147
+
148
+ def _coerce_stack(source) -> np.ndarray:
149
+ """Canonical ``(T, Z, Y, X)`` from either source a channel can have.
150
+
151
+ ``source`` is an in-memory array (a napari image layer's ``data``) or a
152
+ ``.tif`` path — so a channel reads identically whether it came from disk or
153
+ from a layer already open in the viewer.
154
+ """
155
+ if isinstance(source, np.ndarray):
156
+ return to_canonical_tzyx(source)
157
+ return to_canonical_tzyx(np.asarray(tifffile.imread(str(source))))
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Qt-free compute steps (callable directly in tests; the worker just wraps them)
162
+ # ---------------------------------------------------------------------------
163
+ def segment_channel(
164
+ source,
165
+ channel: str,
166
+ params,
167
+ *,
168
+ progress_cb=None,
169
+ cancel_cb=None,
170
+ ) -> np.ndarray:
171
+ """Load a raw stack and return per-plane native masks ``(T, Z, Y, X)``.
172
+
173
+ ``source`` is a ``.tif`` path or an in-memory array (a viewer layer). It is
174
+ canonicalised layout-free (:func:`to_canonical_tzyx`) — no 2D/2D+t/3D/3D+t
175
+ declaration — and every plane is segmented individually. ``channel`` selects
176
+ the backend mask routine (the anchor uses ``"nucleus"``).
177
+ """
178
+ stack = _coerce_stack(source)
179
+ if channel == "nucleus":
180
+ return native_masks.run_nucleus_masks_stack(
181
+ stack, params, progress_cb=progress_cb, cancel_cb=cancel_cb
182
+ )
183
+ return native_masks.run_cell_masks_stack(
184
+ stack, params, progress_cb=progress_cb, cancel_cb=cancel_cb
185
+ )
186
+
187
+
188
+ def track_channel(
189
+ masks_tzyx: np.ndarray,
190
+ *,
191
+ max_distance: float,
192
+ max_frame_gap: int,
193
+ ) -> np.ndarray:
194
+ """Axis-by-axis linking of an in-memory mask stack: stitch z, then track t.
195
+
196
+ See :func:`track_laptrack.track_axiswise`. Single-slice input reduces to plain
197
+ time tracking.
198
+ """
199
+ masks = _to_tzyx(masks_tzyx)
200
+ return track_laptrack.track_axiswise(
201
+ masks, max_distance=max_distance, max_frame_gap=max_frame_gap
202
+ )
203
+
204
+
205
+ def preview_channel_masks(
206
+ stack_tzyx: np.ndarray,
207
+ channel: str,
208
+ params,
209
+ t: int,
210
+ z: int,
211
+ ) -> np.ndarray:
212
+ """Native masks for a single current frame, embedded in a full ``(T, Z, Y, X)``.
213
+
214
+ All other frames are background, so the preview overlays exactly the frame
215
+ the user is looking at while keeping the layer's dims aligned with the input.
216
+ """
217
+ stack = _to_tzyx(stack_tzyx)
218
+ out = np.zeros(stack.shape, dtype=np.int32)
219
+ frame = stack[t] # (Z, Y, X)
220
+ if channel == "nucleus" and getattr(params, "do_3d", False):
221
+ out[t] = native_masks.run_nucleus_masks_frame(frame, z=None, params=params)
222
+ elif channel == "nucleus":
223
+ out[t, z] = native_masks.run_nucleus_masks_frame(frame, z=z, params=params)
224
+ else:
225
+ out[t, z] = native_masks.run_cell_masks_frame(frame, z=z, params=params)
226
+ return out
227
+
228
+
229
+ def segment_track_joint(
230
+ ch1_source,
231
+ ch2_source,
232
+ ch1_params,
233
+ ch2_params,
234
+ flow_params: FlowFollowingParams,
235
+ *,
236
+ max_distance: float,
237
+ max_frame_gap: int,
238
+ progress_cb=None,
239
+ cancel_cb=None,
240
+ ) -> tuple[np.ndarray, np.ndarray]:
241
+ """Load both channels (layout-free) and run the joint anchor path.
242
+
243
+ Each ``*_source`` is a ``.tif`` path or an in-memory array (a viewer layer).
244
+ Channel 1 is the anchor (segmented + tracked); Channel 2 is flowed onto it.
245
+ Returns ``(ch1_tracked, ch2_tracked)`` as ``(T, Z, Y, X)`` int32 stacks that
246
+ share label ids (one Channel-2 object per Channel-1 object). The Channel-2
247
+ stack is tracked by inheriting the Channel-1 tracks, so it needs no tracker.
248
+ """
249
+ ch1_stack = _coerce_stack(ch1_source)
250
+ ch2_stack = _coerce_stack(ch2_source)
251
+ return joint_mod.joint_segment_track(
252
+ ch1_stack, ch2_stack, ch1_params, ch2_params, flow_params,
253
+ max_distance=max_distance, max_frame_gap=max_frame_gap,
254
+ progress_cb=progress_cb, cancel_cb=cancel_cb,
255
+ )
256
+
257
+
258
+ def preview_joint(
259
+ ch1_source,
260
+ ch2_source,
261
+ ch1_params,
262
+ ch2_params,
263
+ flow_params: FlowFollowingParams,
264
+ t: int,
265
+ *,
266
+ max_distance: float,
267
+ max_frame_gap: int,
268
+ progress_cb=None,
269
+ cancel_cb=None,
270
+ ) -> np.ndarray:
271
+ """Joint Channel-2 result for a single current frame, embedded full-size.
272
+
273
+ Runs the joint anchor path on just frame ``t`` of both channels (tracking one
274
+ frame is a no-op) and embeds the Channel-2 result into an otherwise-background
275
+ ``(T, Z, Y, X)`` stack — so the preview overlays exactly the frame on screen,
276
+ letting the Channel-2 params be tuned before committing to the whole stack.
277
+ """
278
+ ch1 = _coerce_stack(ch1_source)
279
+ ch2 = _coerce_stack(ch2_source)
280
+ _, ch2_frame = joint_mod.joint_segment_track(
281
+ ch1[t:t + 1], ch2[t:t + 1], ch1_params, ch2_params, flow_params,
282
+ max_distance=max_distance, max_frame_gap=max_frame_gap,
283
+ progress_cb=progress_cb, cancel_cb=cancel_cb,
284
+ )
285
+ out = np.zeros(ch1.shape, dtype=np.int32)
286
+ out[t] = ch2_frame[0]
287
+ return out
288
+
289
+
290
+ def _make_status() -> QLabel:
291
+ lbl = QLabel("")
292
+ lbl.setWordWrap(True)
293
+ lbl.setVisible(False)
294
+ status_label(lbl)
295
+ return lbl
296
+
297
+
298
+ def _make_progress() -> QProgressBar:
299
+ bar = QProgressBar()
300
+ bar.setRange(0, 100)
301
+ bar.setValue(0)
302
+ bar.setTextVisible(True)
303
+ bar.setVisible(False)
304
+ return bar
305
+
306
+
307
+ class CellposeSegmentTrackWidget(QWidget):
308
+ """Standalone segment+track: Channel 1 anchors; a Channel 2 makes it joint."""
309
+
310
+ _progress_signal = Signal(int, int, str)
311
+
312
+ def __init__(self, viewer: napari.Viewer, parent: QWidget | None = None) -> None:
313
+ super().__init__(parent)
314
+ self.viewer = viewer
315
+ self.gate = UiGate(self)
316
+ # Each channel is bound to a live viewer image layer (its only source).
317
+ # The reference is what the source pill's status light tracks: when the
318
+ # layer leaves the viewer the binding is dropped and the pill goes dark.
319
+ self._ch1_layer = None
320
+ self._ch2_layer = None
321
+ self._running: str | None = None # e.g. "ch1_seg", "ch1_track", "ch2_run"
322
+ self._worker = None
323
+ self._cancel_requested = False
324
+ # Live accumulators for the streaming Channel-1 segmentation: each frame
325
+ # the worker yields is written here and pushed to the viewer (None = idle).
326
+ self._stream: dict | None = None
327
+
328
+ self._setup_ui()
329
+ self._connect_signals()
330
+ self._register_gate_controls()
331
+ self._progress_signal.connect(self._progress)
332
+ self._connect_layer_events()
333
+ self._status(
334
+ "Bind Channel 1 to begin. "
335
+ "(Optional: also bind Channel 2 for joint segmentation.)"
336
+ )
337
+
338
+ # ------------------------------------------------------------------ UI
339
+ def _setup_ui(self) -> None:
340
+ root = QVBoxLayout(self)
341
+ root.setContentsMargins(2, 2, 2, 2)
342
+ root.setSpacing(6)
343
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
344
+
345
+ # ── Channel 1 row (anchor: segment + track) ──
346
+ # One row: the "Channel 1" pill, then its source pill (load from the active
347
+ # image layer) and its action buttons (params, preview, segment, track).
348
+ # There is no file loading and no path text field — the source pill binds
349
+ # whatever image layer is active and then acts as a status light for it.
350
+ self.ch1_layer_btn = self._make_source_button(_CH1_LABEL, self._on_load_layer_ch1)
351
+ self.ch1_params_btn = _tool_btn("⚙", "Channel 1 parameters.", checkable=True)
352
+ self.ch1_preview_btn = _tool_btn(
353
+ "▷", "Segment the current frame into the masks/prob/flow layers."
354
+ )
355
+ self.ch1_seg_btn = _tool_btn("▶", "Segment Channel 1 (native masks).")
356
+ self.ch1_track_btn = _tool_btn("⊳", "Track Channel 1 masks (laptrack).")
357
+ for b in (
358
+ self.ch1_params_btn, self.ch1_preview_btn,
359
+ self.ch1_seg_btn, self.ch1_track_btn,
360
+ ):
361
+ stage_header_action_button(b, "cellpose")
362
+ self.ch1_section = self._build_ch1_params_section()
363
+ self.ch1_section.set_header_visible(False)
364
+ self.ch1_section.collapse()
365
+ self.ch1_params_btn.toggled.connect(
366
+ lambda checked: self.ch1_section._toggle.setChecked(checked)
367
+ )
368
+ ch1_label = self._stage_label("Channel 1")
369
+ ch1_label.setToolTip(
370
+ "Channel 1 — the anchor: segmented and tracked. Typically the nucleus."
371
+ )
372
+ root.addLayout(self._stage_row(
373
+ ch1_label,
374
+ self.ch1_layer_btn,
375
+ self.ch1_params_btn, self.ch1_preview_btn,
376
+ self.ch1_seg_btn, self.ch1_track_btn,
377
+ ))
378
+ root.addWidget(self.ch1_section)
379
+
380
+ # ── Channel 2 row (joint — the only mode for a second channel) ──
381
+ # Same single-row shape as Channel 1: the "Channel 2" pill, its source
382
+ # pickers, then its action buttons. A second channel is never segmented on
383
+ # its own — it is always flowed onto the tracked Channel 1 (one object per
384
+ # Channel-1 object, sharing its id). Preview (▷) runs that joint assignment
385
+ # on the current frame so the Channel-2 params can be tuned; Run (▶) commits
386
+ # it over the whole stack. Both need both inputs; Channel 1's own segment +
387
+ # track path is untouched.
388
+ self.ch2_layer_btn = self._make_source_button(_CH2_LABEL, self._on_load_layer_ch2)
389
+ self.ch2_params_btn = _tool_btn("⚙", "Channel 2 parameters.", checkable=True)
390
+ self.ch2_preview_btn = _tool_btn(
391
+ "▷",
392
+ "Preview the joint Channel-2 result on the current frame "
393
+ "(needs both channels).",
394
+ )
395
+ self.ch2_run_btn = _tool_btn(
396
+ "▶",
397
+ "Run joint: segment + track Channel 1, then flow Channel 2 onto it "
398
+ "(needs both channels).",
399
+ )
400
+ for b in (self.ch2_params_btn, self.ch2_preview_btn, self.ch2_run_btn):
401
+ stage_header_action_button(b, "cellpose")
402
+ self.ch2_section = self._build_ch2_params_section()
403
+ self.ch2_section.set_header_visible(False)
404
+ self.ch2_section.collapse()
405
+ self.ch2_params_btn.toggled.connect(
406
+ lambda checked: self.ch2_section._toggle.setChecked(checked)
407
+ )
408
+ ch2_label = self._stage_label("Channel 2")
409
+ ch2_label.setToolTip(
410
+ "Channel 2 — optional. When set, runs joint mode: this channel is "
411
+ "flowed onto Channel 1 (never segmented on its own). Typically the cell."
412
+ )
413
+ root.addLayout(self._stage_row(
414
+ ch2_label,
415
+ self.ch2_layer_btn,
416
+ self.ch2_params_btn, self.ch2_preview_btn, self.ch2_run_btn,
417
+ ))
418
+ root.addWidget(self.ch2_section)
419
+
420
+ # ── Cell correction ──
421
+ # Reuse the app's cell corrector — the ultrack/OverlapDB-free one — bound
422
+ # to whatever Labels layer is active, so segment → track → correct is one
423
+ # surface with no on-disk handoff. ``full_editing`` unlocks the complete
424
+ # DB-free toolkit (spawn / erase / merge / swap / split + Q/E retrack) that
425
+ # the app keeps contour-only. The widget brings its own "Correction"
426
+ # header + ⏻ activate button; it edits the active layer in place and the
427
+ # user saves it via napari. ``intensity_frame_provider`` snaps a spawned
428
+ # cell to the prob layer sharing the corrected layer's ``[Channel N]``
429
+ # tag (see ``_spawn_intensity_frame``) instead of stamping a blind disk.
430
+ self.cell_correction = CellCorrectionWidget(
431
+ self.viewer,
432
+ active_labels_layer_provider=self._active_labels_layer,
433
+ intensity_frame_provider=self._spawn_intensity_frame,
434
+ full_editing=True,
435
+ parent=self,
436
+ )
437
+ root.addWidget(self.cell_correction)
438
+
439
+ # ── Status + progress ──
440
+ self.status_lbl = _make_status()
441
+ root.addWidget(self.status_lbl)
442
+ self.progress_bar = _make_progress()
443
+ root.addWidget(self.progress_bar)
444
+
445
+ def _make_source_button(self, label, on_load_layer):
446
+ """The single source pill for a channel → ``layer_btn``.
447
+
448
+ There is no file loading and no path text field: a channel's source is the
449
+ **active image layer**, bound by clicking this pill (``⧉``). It is checkable
450
+ and doubles as a status light — checked while its bound layer is present in
451
+ the viewer, unchecked once that layer is gone. The glyph matches the stage
452
+ buttons' thin geometric family.
453
+ """
454
+ layer_btn = _tool_btn(
455
+ "⧉", f"Load {label} from the active image layer.", checkable=True
456
+ )
457
+ stage_header_action_button(layer_btn, "cellpose")
458
+ layer_btn.clicked.connect(on_load_layer)
459
+ return layer_btn
460
+
461
+ def _build_ch1_params_section(self) -> CollapsibleSection:
462
+ # No input-layout / 3D-mode / anisotropy: segmentation is layout-free and
463
+ # per-plane (the shorter leading axis is treated as z for tracking).
464
+ body = QWidget(self)
465
+ grid = section_grid()
466
+ grid.setContentsMargins(8, 4, 4, 4)
467
+ body.setLayout(grid)
468
+ self.ch1_diameter_spin = _dslider(0.0, 500.0, 25.0, 1.0, 1)
469
+ self.ch1_min_size_spin = _islider(0, 100000, 15)
470
+ self.ch1_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
471
+ # Prob threshold: a [0, 1] cutoff read in the prob-map image's own space
472
+ # (0.5 == Cellpose's default), reversed through the inverse sigmoid to the
473
+ # raw cellprob Cellpose thresholds. See _prob_to_cellprob.
474
+ self.ch1_prob_thr_spin = _dslider(0.0, 1.0, 0.5, 0.05, 2)
475
+ # "Flow error tolerance" is Cellpose's flow_threshold, relabelled so its
476
+ # direction reads right: it is the per-mask flow-error budget, so HIGHER is
477
+ # more permissive (0.4 default; 0 disables the QC, keeping every mask).
478
+ # Iterations (niter): flow-dynamics steps, 0 = auto.
479
+ self.ch1_flow_thr_spin = _dslider(0.0, 3.0, 0.4, 0.1, 2)
480
+ self.ch1_niter_spin = _islider(0, 2000, 0)
481
+ add_section_pair_row(
482
+ grid, 0, "Diameter:", self.ch1_diameter_spin, "Min size:", self.ch1_min_size_spin
483
+ )
484
+ add_section_pair_row(
485
+ grid, 1, "Gamma:", self.ch1_gamma_spin, "Prob threshold:", self.ch1_prob_thr_spin
486
+ )
487
+ add_section_pair_row(
488
+ grid, 2,
489
+ "Flow error tolerance:", self.ch1_flow_thr_spin, "Iterations:", self.ch1_niter_spin
490
+ )
491
+ return CollapsibleSection("Channel 1 parameters", body, expanded=False)
492
+
493
+ def _build_ch2_params_section(self) -> CollapsibleSection:
494
+ # Channel 2 has no independent segmentation, but its Cellpose flow field
495
+ # (diameter / gamma) and the flow-following knobs both shape how its
496
+ # foreground is assigned to Channel-1 objects, so both live here. The
497
+ # tracking knobs live here too: they tune the Channel-1 tracker that the
498
+ # joint anchor (and Channel 1's own Track action) runs.
499
+ body = QWidget(self)
500
+ grid = section_grid()
501
+ grid.setContentsMargins(8, 4, 4, 4)
502
+ body.setLayout(grid)
503
+ self.ch2_diameter_spin = _dslider(0.0, 500.0, 0.0, 1.0, 1)
504
+ self.ch2_min_size_spin = _islider(0, 100000, 0)
505
+ self.ch2_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
506
+ self.ch2_fg_thr_spin = _dslider(0.0, 1.0, 0.5, 0.05, 2)
507
+ self.ch2_flow_weight_spin = _dslider(0.0, 1.0, 0.5, 0.05, 2)
508
+ self.ch2_radius_spin = _dslider(1.0, 200.0, 30.0, 1.0, 1)
509
+ self.track_max_dist_spin = _dslider(1.0, 200.0, 15.0, 1.0, 1)
510
+ self.track_gap_spin = _islider(0, 10, 0)
511
+ add_section_pair_row(
512
+ grid, 0, "Diameter:", self.ch2_diameter_spin, "Min size:", self.ch2_min_size_spin
513
+ )
514
+ add_section_pair_row(grid, 1, "Gamma:", self.ch2_gamma_spin)
515
+ add_section_pair_row(
516
+ grid, 2,
517
+ "FG threshold:", self.ch2_fg_thr_spin,
518
+ "Flow weight:", self.ch2_flow_weight_spin,
519
+ )
520
+ add_section_pair_row(grid, 3, "Max assign radius:", self.ch2_radius_spin)
521
+ add_section_pair_row(
522
+ grid, 4,
523
+ "Max distance:", self.track_max_dist_spin,
524
+ "Max frame gap:", self.track_gap_spin,
525
+ )
526
+ return CollapsibleSection("Channel 2 & tracking parameters", body, expanded=False)
527
+
528
+ @staticmethod
529
+ def _stage_label(text: str) -> QLabel:
530
+ return stage_header_label(QLabel(text), "cellpose")
531
+
532
+ @staticmethod
533
+ def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
534
+ row = QHBoxLayout()
535
+ row.setContentsMargins(0, 0, 0, 0)
536
+ row.setSpacing(4)
537
+ row.addWidget(label)
538
+ for w in trailing:
539
+ row.addWidget(w)
540
+ row.addStretch(1)
541
+ return row
542
+
543
+ # -------------------------------------------------------------- signals
544
+ def _connect_signals(self) -> None:
545
+ self.ch1_preview_btn.clicked.connect(lambda: self._on_ch1("preview"))
546
+ self.ch1_seg_btn.clicked.connect(lambda: self._on_ch1("seg"))
547
+ self.ch1_track_btn.clicked.connect(lambda: self._on_ch1("track"))
548
+ self.ch2_preview_btn.clicked.connect(lambda: self._on_ch2("preview"))
549
+ self.ch2_run_btn.clicked.connect(lambda: self._on_ch2("run"))
550
+
551
+ def _on_ch1(self, kind: str) -> None:
552
+ if self._running is not None:
553
+ self._on_cancel()
554
+ return
555
+ if kind == "seg":
556
+ self._run_segment()
557
+ elif kind == "track":
558
+ self._run_track()
559
+ else:
560
+ self._preview()
561
+
562
+ def _on_ch2(self, kind: str) -> None:
563
+ if self._running is not None:
564
+ self._on_cancel()
565
+ return
566
+ if kind == "preview":
567
+ self._preview_joint()
568
+ else:
569
+ self._run_joint()
570
+
571
+ def _on_cancel(self) -> None:
572
+ self._cancel_requested = True
573
+ worker = self._worker
574
+ if worker is not None and hasattr(worker, "quit"):
575
+ worker.quit()
576
+
577
+ # ------------------------------------------------------- source helpers
578
+ def _on_load_layer_ch1(self) -> None:
579
+ self._bind_active_layer(1)
580
+
581
+ def _on_load_layer_ch2(self) -> None:
582
+ self._bind_active_layer(2)
583
+
584
+ def _bind_active_layer(self, which: int) -> None:
585
+ """Bind the viewer's active image layer as this channel's source.
586
+
587
+ The active layer must be an :class:`~napari.layers.Image`; anything else
588
+ (or nothing selected) leaves the channel untouched and just reports why.
589
+ """
590
+ active = getattr(getattr(self.viewer.layers, "selection", None), "active", None)
591
+ if not isinstance(active, Image):
592
+ self._status("Select an image layer in the viewer, then click the pill.")
593
+ self._refresh_source_buttons(which)
594
+ return
595
+ self._set_channel_layer(which, active)
596
+
597
+ def _set_channel_layer(self, which: int, layer) -> None:
598
+ """Make ``layer`` this channel's source (testable seam), then re-gate.
599
+
600
+ ``None`` clears the channel. The pill's status light and the run buttons
601
+ follow from the binding via :meth:`_refresh_source_buttons` / the gate.
602
+ """
603
+ if which == 1:
604
+ self._ch1_layer = layer
605
+ else:
606
+ self._ch2_layer = layer
607
+ self._refresh_source_buttons(which)
608
+ self.gate.recompute()
609
+ if layer is not None:
610
+ label = _CH1_LABEL if which == 1 else _CH2_LABEL
611
+ msg = f"{label} ← layer '{layer.name}'."
612
+ if which == 1:
613
+ msg += " Preview a frame or Segment the full stack."
614
+ self._status(msg)
615
+
616
+ def _refresh_source_buttons(self, which: int) -> None:
617
+ """Mirror the channel's bound layer into its pill (status light + tooltip)."""
618
+ if which == 1:
619
+ layer_btn, layer, label = self.ch1_layer_btn, self._ch1_layer, _CH1_LABEL
620
+ else:
621
+ layer_btn, layer, label = self.ch2_layer_btn, self._ch2_layer, _CH2_LABEL
622
+ present = self._channel_present(which)
623
+ layer_btn.setChecked(present)
624
+ layer_btn.setToolTip(
625
+ f"{label} ← layer: {layer.name} (click to rebind; unlit if it is removed)"
626
+ if present else f"Load {label} from the active image layer."
627
+ )
628
+
629
+ def _connect_layer_events(self) -> None:
630
+ """Track viewer layer add/remove so a pill darkens when its layer leaves."""
631
+ events = getattr(getattr(self.viewer.layers, "events", None), "removed", None)
632
+ if events is not None:
633
+ events.connect(self._on_layers_changed)
634
+ inserted = getattr(getattr(self.viewer.layers, "events", None), "inserted", None)
635
+ if inserted is not None:
636
+ inserted.connect(self._on_layers_changed)
637
+ # Reflect whatever is already bound (nothing, at construction).
638
+ self._on_layers_changed()
639
+
640
+ def _on_layers_changed(self, event=None) -> None:
641
+ """Drop any binding whose layer has left the viewer, then refresh pills."""
642
+ for which in (1, 2):
643
+ layer = self._ch1_layer if which == 1 else self._ch2_layer
644
+ if layer is not None and not self._layer_in_viewer(layer):
645
+ if which == 1:
646
+ self._ch1_layer = None
647
+ else:
648
+ self._ch2_layer = None
649
+ self._refresh_source_buttons(which)
650
+ self.gate.recompute()
651
+
652
+ def _layer_in_viewer(self, layer) -> bool:
653
+ """Whether ``layer`` is still present in the viewer (identity, not name)."""
654
+ if layer is None:
655
+ return False
656
+ layers = self.viewer.layers
657
+ try:
658
+ if layer in layers: # napari LayerList: identity membership
659
+ return True
660
+ except TypeError:
661
+ pass
662
+ try: # dict-like fakes are keyed by name
663
+ return layers[layer.name] is layer
664
+ except (KeyError, TypeError):
665
+ return False
666
+
667
+ def _channel_source(self, which: int):
668
+ """A channel's input as a canonical ``(T, Z, Y, X)`` array, else None."""
669
+ layer = self._ch1_layer if which == 1 else self._ch2_layer
670
+ if layer is not None and self._layer_in_viewer(layer):
671
+ return to_canonical_tzyx(np.asarray(layer.data))
672
+ return None
673
+
674
+ def _channel_present(self, which: int) -> bool:
675
+ layer = self._ch1_layer if which == 1 else self._ch2_layer
676
+ return layer is not None and self._layer_in_viewer(layer)
677
+
678
+ # ------------------------------------------------------------- params
679
+ def _build_ch1_params(self) -> cellpose_runner.NucleusParams:
680
+ # The anchor uses the nucleus mask routine (clean, separable objects).
681
+ # Standalone segments every plane individually: do_3d is always off
682
+ # (anisotropy then unused) — true-3D segmentation is the app's domain.
683
+ return cellpose_runner.NucleusParams(
684
+ do_3d=False,
685
+ anisotropy=1.0,
686
+ diameter=float(self.ch1_diameter_spin.value()),
687
+ min_size=int(self.ch1_min_size_spin.value()),
688
+ gamma=float(self.ch1_gamma_spin.value()),
689
+ cellprob_threshold=_prob_to_cellprob(self.ch1_prob_thr_spin.value()),
690
+ flow_threshold=float(self.ch1_flow_thr_spin.value()),
691
+ niter=int(self.ch1_niter_spin.value()),
692
+ )
693
+
694
+ def _build_ch2_params(self) -> cellpose_runner.CellParams:
695
+ return cellpose_runner.CellParams(
696
+ diameter=float(self.ch2_diameter_spin.value()),
697
+ min_size=int(self.ch2_min_size_spin.value()),
698
+ gamma=float(self.ch2_gamma_spin.value()),
699
+ )
700
+
701
+ def _build_flow_params(self) -> FlowFollowingParams:
702
+ return FlowFollowingParams(
703
+ fg_threshold=float(self.ch2_fg_thr_spin.value()),
704
+ flow_weight=float(self.ch2_flow_weight_spin.value()),
705
+ max_assign_radius=float(self.ch2_radius_spin.value()),
706
+ )
707
+
708
+ def _both_inputs(self) -> bool:
709
+ return self._channel_present(1) and self._channel_present(2)
710
+
711
+ # --------------------------------------------------------------- run: seg
712
+ def _run_segment(self) -> None:
713
+ """Segment the whole Channel-1 stack, streaming each frame into the viewer.
714
+
715
+ Three layers — masks, the sigmoid prob map and the RGB flow — are created
716
+ up front (background) and filled frame-by-frame from a single eval per
717
+ plane, so the user watches results accrue instead of waiting for the end.
718
+ """
719
+ source = self._channel_source(1)
720
+ if source is None:
721
+ self._status("Missing Channel 1 input.")
722
+ return
723
+ stack = np.asarray(source) # already canonical (T, Z, Y, X)
724
+ params = self._build_ch1_params()
725
+ self._cancel_requested = False
726
+ progress_signal = self._progress_signal
727
+ # Pre-allocate accumulators + show empty layers so frames stream into them.
728
+ self._init_stream(stack)
729
+ self._push_stream_layers()
730
+ names = (
731
+ self._stream["masks_name"],
732
+ self._stream["prob_name"],
733
+ self._stream["flow_name"],
734
+ )
735
+
736
+ def _done(_result):
737
+ self._worker = None
738
+ self._set_running(None)
739
+ self._clear_progress()
740
+ self._stream = None
741
+ self._status(_segment_done_status(*names))
742
+
743
+ @thread_worker(connect={
744
+ "yielded": self._on_seg_frame, "returned": _done, "errored": self._errored,
745
+ "aborted": self._aborted,
746
+ })
747
+ def _worker():
748
+ yield from native_masks.iter_nucleus_maps_stack(
749
+ stack, params,
750
+ progress_cb=lambda d, tot, m: progress_signal.emit(int(d), int(tot), str(m)),
751
+ cancel_cb=lambda: self._cancel_requested,
752
+ )
753
+
754
+ self._set_running("ch1_seg")
755
+ self._status(
756
+ f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
757
+ if not cellpose_runner.is_model_loaded()
758
+ else "Segmenting Channel 1..."
759
+ )
760
+ self._worker = _worker()
761
+
762
+ def _init_stream(self, stack: np.ndarray) -> None:
763
+ """(Re)allocate the Channel 1 masks/prob/flow accumulators, sized to ``stack``.
764
+
765
+ Shared by :meth:`_run_segment` (fills every frame) and :meth:`_preview`
766
+ (fills one frame at a time on first use) so both write into — and
767
+ display — the very same persistent layers. If those layers already hold
768
+ data from a prior full **Segment** run (``self._stream`` is cleared on
769
+ completion, but the layers survive), that data seeds the new
770
+ accumulators instead of zeros — otherwise a lone "segment this frame"
771
+ after a full run would blank every other frame the instant it repaints
772
+ the layers, before its own single-frame result even comes back.
773
+ """
774
+ T, Z, Y, X = (int(s) for s in stack.shape)
775
+ masks_name = _layer_name(_CH1_LABEL, "masks")
776
+ prob_name = _layer_name(_CH1_LABEL, "prob")
777
+ flow_name = _layer_name(_CH1_LABEL, "flow")
778
+ self._stream = {
779
+ "masks": self._existing_layer_array(masks_name, (T, Z, Y, X), np.int32),
780
+ "prob": self._existing_layer_array(prob_name, (T, Z, Y, X), np.float32),
781
+ "flow": self._existing_layer_array(flow_name, (T, Z, Y, X, 3), np.uint8),
782
+ "masks_name": masks_name,
783
+ "prob_name": prob_name,
784
+ "flow_name": flow_name,
785
+ }
786
+
787
+ def _existing_layer_array(
788
+ self, name: str, shape: tuple[int, ...], dtype: np.dtype
789
+ ) -> np.ndarray:
790
+ """Reuse ``name``'s current viewer data if it matches ``shape``, else zeros.
791
+
792
+ Layer data may have a singleton Z squeezed out for display (see
793
+ :func:`_squeeze_z`); that's undone before the shape check.
794
+ """
795
+ layer = self.viewer.layers[name] if name in self.viewer.layers else None
796
+ if layer is not None:
797
+ arr = np.asarray(layer.data)
798
+ if arr.ndim == len(shape) - 1:
799
+ arr = arr[:, np.newaxis]
800
+ if arr.shape == shape:
801
+ return np.asarray(arr, dtype=dtype).copy()
802
+ return np.zeros(shape, dtype=dtype)
803
+
804
+ def _on_seg_frame(self, payload) -> None:
805
+ """Write one streamed frame into the accumulators and refresh the layers."""
806
+ st = self._stream
807
+ if st is None or not isinstance(payload, tuple) or len(payload) != 4:
808
+ return
809
+ t, masks, prob, flow = payload
810
+ st["masks"][t] = masks
811
+ st["prob"][t] = prob
812
+ st["flow"][t] = flow
813
+ self._push_stream_layers()
814
+ self._show_frame(t)
815
+
816
+ def _show_frame(self, t: int) -> None:
817
+ """Move the viewer's frame slider to ``t`` so the just-streamed frame shows.
818
+
819
+ Only the leading (time) axis is touched; any remaining slider positions
820
+ (e.g. Z) are preserved. napari clamps out-of-range values itself.
821
+ """
822
+ dims = getattr(self.viewer, "dims", None)
823
+ step = list(getattr(dims, "current_step", ()) or ())
824
+ if not step:
825
+ return
826
+ step[0] = int(t)
827
+ dims.current_step = tuple(step)
828
+
829
+ def _push_stream_layers(self) -> None:
830
+ st = self._stream
831
+ if st is None:
832
+ return
833
+ # napari stacks each newly-added layer above the ones already present,
834
+ # so adding flow, then prob, then masks last leaves them ordered top to
835
+ # bottom as masks / prob / flow / input.
836
+ self._add_image(st["flow_name"], st["flow"])
837
+ self._add_image(st["prob_name"], st["prob"])
838
+ self._add_labels(st["masks_name"], st["masks"])
839
+
840
+ # ------------------------------------------------------------- run: track
841
+ def _run_track(self) -> None:
842
+ masks_name = _layer_name(_CH1_LABEL, "masks")
843
+ if masks_name not in self.viewer.layers:
844
+ self._status(f"No '{masks_name}' layer — segment first.")
845
+ return
846
+ masks = _to_tzyx(np.asarray(self.viewer.layers[masks_name].data))
847
+ max_distance = float(self.track_max_dist_spin.value())
848
+ max_frame_gap = int(self.track_gap_spin.value())
849
+
850
+ def _done(result):
851
+ self._worker = None
852
+ self._set_running(None)
853
+ self._clear_progress()
854
+ self._add_labels(masks_name, result)
855
+ self._status(_track_done_status(masks_name))
856
+
857
+ @thread_worker(connect={
858
+ "yielded": self._on_progress, "returned": _done, "errored": self._errored,
859
+ "aborted": self._aborted,
860
+ })
861
+ def _worker():
862
+ yield (0, 1, "Tracking Channel 1 masks...")
863
+ return track_channel(
864
+ masks, max_distance=max_distance, max_frame_gap=max_frame_gap,
865
+ )
866
+
867
+ self._set_running("ch1_track")
868
+ self._status("Tracking Channel 1 masks (laptrack)...")
869
+ self._worker = _worker()
870
+
871
+ # --------------------------------------------------------------- run: joint
872
+ def _run_joint(self) -> None:
873
+ if not self._both_inputs():
874
+ self._status("Joint mode needs both Channel 1 and Channel 2 inputs.")
875
+ return
876
+ ch1_source = self._channel_source(1)
877
+ ch2_source = self._channel_source(2)
878
+ ch1_params = self._build_ch1_params()
879
+ ch2_params = self._build_ch2_params()
880
+ flow_params = self._build_flow_params()
881
+ max_distance = float(self.track_max_dist_spin.value())
882
+ max_frame_gap = int(self.track_gap_spin.value())
883
+ self._cancel_requested = False
884
+ progress_signal = self._progress_signal
885
+ ch1_name = _layer_name(_CH1_LABEL, "tracked")
886
+ ch2_name = _layer_name(_CH2_LABEL, "tracked")
887
+
888
+ def _done(result):
889
+ ch1_tracked, ch2_tracked = result
890
+ self._worker = None
891
+ self._set_running(None)
892
+ self._clear_progress()
893
+ self._add_labels(ch1_name, ch1_tracked)
894
+ self._add_labels(ch2_name, ch2_tracked)
895
+ self._status(
896
+ f"Joint → '{ch1_name}' + '{ch2_name}' (paired ids). Save from the layers."
897
+ )
898
+
899
+ @thread_worker(connect={
900
+ "yielded": self._on_progress, "returned": _done, "errored": self._errored,
901
+ "aborted": self._aborted,
902
+ })
903
+ def _worker():
904
+ yield (0, 4, "Loading inputs...")
905
+ return segment_track_joint(
906
+ ch1_source, ch2_source,
907
+ ch1_params, ch2_params, flow_params,
908
+ max_distance=max_distance, max_frame_gap=max_frame_gap,
909
+ progress_cb=lambda d, t, m: progress_signal.emit(int(d), int(t), str(m)),
910
+ cancel_cb=lambda: self._cancel_requested,
911
+ )
912
+
913
+ self._set_running("ch2_run")
914
+ self._status(
915
+ f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
916
+ if not cellpose_runner.is_model_loaded()
917
+ else "Running joint segmentation..."
918
+ )
919
+ self._worker = _worker()
920
+
921
+ # ------------------------------------------------------- preview: joint
922
+ def _preview_joint(self) -> None:
923
+ if not self._both_inputs():
924
+ self._status("Joint preview needs both Channel 1 and Channel 2 inputs.")
925
+ return
926
+ ch1_source = self._channel_source(1)
927
+ ch2_source = self._channel_source(2)
928
+ ch1_params = self._build_ch1_params()
929
+ ch2_params = self._build_ch2_params()
930
+ flow_params = self._build_flow_params()
931
+ max_distance = float(self.track_max_dist_spin.value())
932
+ max_frame_gap = int(self.track_gap_spin.value())
933
+ self._cancel_requested = False
934
+ progress_signal = self._progress_signal
935
+ try:
936
+ ch1_stack = to_canonical_tzyx(
937
+ ch1_source if isinstance(ch1_source, np.ndarray)
938
+ else np.asarray(tifffile.imread(str(ch1_source)))
939
+ )
940
+ ch2_stack = to_canonical_tzyx(
941
+ ch2_source if isinstance(ch2_source, np.ndarray)
942
+ else np.asarray(tifffile.imread(str(ch2_source)))
943
+ )
944
+ except Exception as exc:
945
+ self._status(f"Error: {exc}")
946
+ logger.exception("joint preview load error", exc_info=exc)
947
+ return
948
+ # Show Channel 2 underneath so the previewed assignment is visible.
949
+ self._add_image(_layer_name(_CH2_LABEL, "image"), ch2_stack)
950
+ t, _z = self._current_tz(int(ch1_stack.shape[0]), int(ch1_stack.shape[1]))
951
+ preview_name = _layer_name(_CH2_LABEL, "preview")
952
+
953
+ def _done(result):
954
+ self._worker = None
955
+ self._set_running(None)
956
+ self._clear_progress()
957
+ self._add_labels(preview_name, result)
958
+ self._status(
959
+ f"Channel 2 joint preview (frame t={t}) → '{preview_name}'."
960
+ )
961
+
962
+ @thread_worker(connect={
963
+ "yielded": self._on_progress, "returned": _done, "errored": self._errored,
964
+ "aborted": self._aborted,
965
+ })
966
+ def _worker():
967
+ yield (0, 4, "Previewing joint...")
968
+ return preview_joint(
969
+ ch1_stack, ch2_stack, ch1_params, ch2_params, flow_params, t,
970
+ max_distance=max_distance, max_frame_gap=max_frame_gap,
971
+ progress_cb=lambda d, tot, m: progress_signal.emit(int(d), int(tot), str(m)),
972
+ cancel_cb=lambda: self._cancel_requested,
973
+ )
974
+
975
+ self._set_running("ch2_preview")
976
+ self._status(
977
+ f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
978
+ if not cellpose_runner.is_model_loaded()
979
+ else "Previewing joint..."
980
+ )
981
+ self._worker = _worker()
982
+
983
+ # ----------------------------------------------------------- run: preview
984
+ def _preview(self) -> None:
985
+ """Segment Channel 1's current frame in place ("segment this frame").
986
+
987
+ Writes straight into the same masks / prob / flow layers **Segment**
988
+ fills — lazily initializing them (full-stack, background elsewhere) on
989
+ first use if no full run has happened yet — instead of spawning
990
+ disposable ``… preview`` layers. So the diameter / min-size / gamma /
991
+ prob-threshold can be tuned one frame at a time on the real stack
992
+ before (or while) committing the rest with **Segment**.
993
+ """
994
+ source = self._channel_source(1)
995
+ if source is None:
996
+ self._status("Missing Channel 1 input.")
997
+ return
998
+ params = self._build_ch1_params()
999
+ self._cancel_requested = False
1000
+ stack = np.asarray(source) # already canonical (T, Z, Y, X)
1001
+ t, z = self._current_tz(int(stack.shape[0]), int(stack.shape[1]))
1002
+ if self._stream is None:
1003
+ self._init_stream(stack)
1004
+ self._push_stream_layers()
1005
+ st = self._stream
1006
+ names = (st["masks_name"], st["prob_name"], st["flow_name"])
1007
+
1008
+ def _done(result):
1009
+ masks, prob, flow = result
1010
+ self._worker = None
1011
+ self._set_running(None)
1012
+ self._clear_progress()
1013
+ st["masks"][t, z] = masks
1014
+ st["prob"][t, z] = prob
1015
+ st["flow"][t, z] = flow
1016
+ self._push_stream_layers()
1017
+ self._status(
1018
+ f"Channel 1 frame t={t} segmented → '{names[0]}', '{names[1]}', "
1019
+ f"'{names[2]}'."
1020
+ )
1021
+
1022
+ @thread_worker(connect={
1023
+ "yielded": self._on_progress, "returned": _done, "errored": self._errored,
1024
+ "aborted": self._aborted,
1025
+ })
1026
+ def _worker():
1027
+ yield (0, 0, f"Segmenting frame on {cellpose_runner.device_label()}...")
1028
+ return native_masks.run_nucleus_maps_frame(stack[t], z=z, params=params)
1029
+
1030
+ self._set_running("ch1_preview")
1031
+ self._status(
1032
+ f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
1033
+ if not cellpose_runner.is_model_loaded()
1034
+ else "Segmenting current frame..."
1035
+ )
1036
+ self._worker = _worker()
1037
+
1038
+ def _current_tz(self, n_t: int, n_z: int) -> tuple[int, int]:
1039
+ """Current (t, z) from the viewer, clamped; z is 0 for single-slice data."""
1040
+ step = getattr(getattr(self.viewer, "dims", None), "current_step", (0, 0))
1041
+ t = int(step[0]) if len(step) >= 1 else 0
1042
+ z = 0 if n_z <= 1 else (int(step[1]) if len(step) >= 2 else 0)
1043
+ return min(max(t, 0), n_t - 1), min(max(z, 0), n_z - 1)
1044
+
1045
+ def _active_labels_layer(self):
1046
+ """The viewer's active layer iff it is a Labels layer, else None.
1047
+
1048
+ The embedded corrector binds to this; returning None when the active
1049
+ layer is not labels is what enforces the "must be a Labels layer" scope.
1050
+ """
1051
+ layer = getattr(getattr(self.viewer.layers, "selection", None), "active", None)
1052
+ return layer if isinstance(layer, Labels) else None
1053
+
1054
+ def _spawn_intensity_frame(self, t: int) -> np.ndarray | None:
1055
+ """Signal frame for spawn-snapping: the prob layer sharing the corrected
1056
+ layer's ``[Tag]`` (e.g. Channel 1's masks/tracked layer → its own prob
1057
+ map). Channel 2 has no prob layer of its own (it is never segmented
1058
+ independently), so spawning there falls back to a plain disk.
1059
+ """
1060
+ layer = self.cell_correction._correction_tracked_layer()
1061
+ if layer is None or not layer.name.startswith("["):
1062
+ return None
1063
+ tag = layer.name.split("]", 1)[0] + "]"
1064
+ prob_name = f"{tag} prob"
1065
+ if prob_name not in self.viewer.layers:
1066
+ return None
1067
+ data = np.asarray(self.viewer.layers[prob_name].data)
1068
+ return frame_view_2d(data, int(t))
1069
+
1070
+ def _errored(self, exc) -> None:
1071
+ self._worker = None
1072
+ self._set_running(None)
1073
+ self._clear_progress()
1074
+ # Keep whatever streamed so far visible, but stop accumulating into it.
1075
+ self._stream = None
1076
+ if isinstance(exc, cellpose_runner.CancelledError):
1077
+ self._status("Cancelled.")
1078
+ else:
1079
+ self._status(f"Error: {exc}")
1080
+ logger.exception("segment/track error", exc_info=exc)
1081
+
1082
+ def _aborted(self) -> None:
1083
+ """Return the UI to idle when a run is cancelled mid-flight.
1084
+
1085
+ ``_on_cancel`` calls ``worker.quit()``, and napari answers a quit with
1086
+ the ``aborted`` signal — not ``returned`` or ``errored`` — so without
1087
+ this handler the run buttons stay disabled and the progress bar stays
1088
+ frozen after Cancel. Mirrors the cancel branch of :meth:`_errored`.
1089
+ """
1090
+ self._worker = None
1091
+ self._set_running(None)
1092
+ self._clear_progress()
1093
+ self._stream = None
1094
+ self._status("Cancelled.")
1095
+
1096
+ # ------------------------------------------------------ layer output
1097
+ def _add_labels(self, name: str, data) -> None:
1098
+ arr = _squeeze_z(np.asarray(data)).astype(np.int32, copy=False)
1099
+ self._show_in_viewer(name, arr, self.viewer.add_labels)
1100
+
1101
+ def _add_image(self, name: str, data) -> None:
1102
+ arr = _squeeze_z(np.asarray(data))
1103
+ self._show_in_viewer(name, arr, self.viewer.add_image)
1104
+
1105
+ def _show_in_viewer(self, name: str, data, adder) -> None:
1106
+ if name in self.viewer.layers:
1107
+ try:
1108
+ self.viewer.layers[name].data = data
1109
+ return
1110
+ except Exception:
1111
+ self.viewer.layers.remove(self.viewer.layers[name])
1112
+ adder(data, name=name)
1113
+
1114
+ # ---------------------------------------------------------- public API
1115
+ def get_state(self) -> dict:
1116
+ return {
1117
+ "channel1": {
1118
+ "diameter": self.ch1_diameter_spin.value(),
1119
+ "min_size": self.ch1_min_size_spin.value(),
1120
+ "gamma": self.ch1_gamma_spin.value(),
1121
+ "prob_threshold": self.ch1_prob_thr_spin.value(),
1122
+ "flow_threshold": self.ch1_flow_thr_spin.value(),
1123
+ "niter": self.ch1_niter_spin.value(),
1124
+ },
1125
+ "channel2": {
1126
+ "diameter": self.ch2_diameter_spin.value(),
1127
+ "min_size": self.ch2_min_size_spin.value(),
1128
+ "gamma": self.ch2_gamma_spin.value(),
1129
+ "fg_threshold": self.ch2_fg_thr_spin.value(),
1130
+ "flow_weight": self.ch2_flow_weight_spin.value(),
1131
+ "max_assign_radius": self.ch2_radius_spin.value(),
1132
+ },
1133
+ "tracking": {
1134
+ "max_distance": self.track_max_dist_spin.value(),
1135
+ "max_frame_gap": self.track_gap_spin.value(),
1136
+ },
1137
+ }
1138
+
1139
+ # -------------------------------------------------------- state helpers
1140
+ def _status(self, msg: str) -> None:
1141
+ self.status_lbl.setText(msg)
1142
+ self.status_lbl.setVisible(bool(msg))
1143
+ if msg:
1144
+ logger.info(msg)
1145
+
1146
+ def _progress(self, done: int, total: int, msg: str) -> None:
1147
+ self.progress_bar.setVisible(True)
1148
+ self.progress_bar.setRange(0, total)
1149
+ self.progress_bar.setValue(done)
1150
+ self._status(msg)
1151
+
1152
+ def _on_progress(self, data) -> None:
1153
+ if isinstance(data, tuple):
1154
+ self._progress(*data)
1155
+ else:
1156
+ self._status(str(data))
1157
+
1158
+ def _clear_progress(self) -> None:
1159
+ self.progress_bar.setValue(0)
1160
+ self.progress_bar.setVisible(False)
1161
+
1162
+ def _joint_buttons(self):
1163
+ return (self.ch2_preview_btn, self.ch2_run_btn)
1164
+
1165
+ def _ch1_masks_available(self) -> bool:
1166
+ return _layer_name(_CH1_LABEL, "masks") in self.viewer.layers
1167
+
1168
+ def _ch1_track_reason(self) -> str:
1169
+ if not self._channel_present(1):
1170
+ return "Bind Channel 1 first — click ⧉."
1171
+ return "Segment Channel 1 first."
1172
+
1173
+ def _ch2_joint_reason(self) -> str:
1174
+ if not self._channel_present(1):
1175
+ return "Bind Channel 1 first."
1176
+ return "Bind Channel 2 for joint segmentation."
1177
+
1178
+ def _register_gate_controls(self) -> None:
1179
+ g = self.gate
1180
+
1181
+ def _own(btn) -> bool:
1182
+ return self._running is None or self._active_btn() is btn
1183
+
1184
+ g.register(self.ch1_params_btn, ControlClass.HARMLESS)
1185
+ g.register(self.ch2_params_btn, ControlClass.HARMLESS)
1186
+ g.register(
1187
+ self.ch1_preview_btn, ControlClass.RUN_VIEWER,
1188
+ when=lambda: _own(self.ch1_preview_btn) and self._channel_present(1),
1189
+ reason="Bind Channel 1 first — click ⧉.",
1190
+ )
1191
+ g.register(
1192
+ self.ch1_seg_btn, ControlClass.RUN_VIEWER,
1193
+ when=lambda: _own(self.ch1_seg_btn) and self._channel_present(1),
1194
+ reason="Bind Channel 1 first — click ⧉.",
1195
+ )
1196
+ g.register(
1197
+ self.ch1_track_btn, ControlClass.RUN_VIEWER,
1198
+ when=lambda: _own(self.ch1_track_btn) and self._ch1_masks_available(),
1199
+ reason=self._ch1_track_reason,
1200
+ )
1201
+ # Channel 2's actions (preview + run) are joint-only: both require both
1202
+ # inputs to be present.
1203
+ for btn in self._joint_buttons():
1204
+ g.register(
1205
+ btn, ControlClass.RUN_VIEWER,
1206
+ when=lambda b=btn: _own(b) and self._both_inputs(),
1207
+ reason=self._ch2_joint_reason,
1208
+ )
1209
+ g.recompute()
1210
+
1211
+ def _btn_for_key(self):
1212
+ return {
1213
+ "ch1_preview": self.ch1_preview_btn,
1214
+ "ch1_seg": self.ch1_seg_btn,
1215
+ "ch1_track": self.ch1_track_btn,
1216
+ "ch2_preview": self.ch2_preview_btn,
1217
+ "ch2_run": self.ch2_run_btn,
1218
+ }
1219
+
1220
+ def _active_btn(self):
1221
+ return self._btn_for_key().get(self._running)
1222
+
1223
+ _DEFAULT_GLYPHS = {
1224
+ "ch1_preview": "▷", "ch1_seg": "▶", "ch1_track": "⊳",
1225
+ "ch2_preview": "▷", "ch2_run": "▶",
1226
+ }
1227
+
1228
+ def _set_running(self, key: str | None) -> None:
1229
+ # restore all glyphs first
1230
+ for k, btn in self._btn_for_key().items():
1231
+ btn.setText(self._DEFAULT_GLYPHS[k])
1232
+ self._running = key
1233
+ if key is None:
1234
+ self._cancel_requested = False
1235
+ else:
1236
+ btn = self._active_btn()
1237
+ if btn is not None:
1238
+ btn.setText("✕")
1239
+ btn.setToolTip("Cancel.")
1240
+ self.gate.recompute()
1241
+
1242
+
1243
+ def make_cellpose_segment_track_widget(napari_viewer=None):
1244
+ """napari plugin factory for the standalone Cellpose segment+track tool."""
1245
+ try:
1246
+ from itasc.napari._napari_compat import patch_napari_layer_delegate
1247
+
1248
+ patch_napari_layer_delegate()
1249
+ except Exception:
1250
+ pass
1251
+ if napari_viewer is None:
1252
+ napari_viewer = napari.current_viewer()
1253
+ return CellposeSegmentTrackWidget(napari_viewer)