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,98 @@
1
+ """Joint nucleus-anchored cell segmentation + tracking, Qt-free.
2
+
3
+ When the standalone tool has both channels, this composes the existing pieces
4
+ into one nucleus-anchored path:
5
+
6
+ 1. **Nucleus native masks** (:func:`native_masks.run_nucleus_masks_stack`) →
7
+ **track** them (:func:`track_laptrack.track_masks`) → tracked nucleus labels.
8
+ 2. **Cell flows** (:func:`cellpose_runner.run_cell_stack` → ``prob``, ``dp``);
9
+ the cell foreground is ``sigmoid(prob) > fg_threshold``.
10
+ 3. Per z-plane, **flow-follow** each cell-foreground pixel to a nucleus
11
+ (:func:`flow_following.flow_follow_movie`). Cell labels inherit the *tracked*
12
+ nucleus ids, so the cell stack is tracked by construction — one cell per
13
+ nucleus, sharing its id.
14
+
15
+ Everything here reuses functions the app also imports (read-only); it adds no
16
+ new behaviour to the app's Cellpose stage.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Callable
21
+
22
+ import numpy as np
23
+
24
+ from itasc.cellpose import cellpose_runner, native_masks, track_laptrack
25
+ from itasc.cellpose.cellpose_runner import CellParams, NucleusParams
26
+ from itasc.cellpose.flow_following import FlowFollowingParams, flow_follow_movie
27
+
28
+ __all__ = ["cell_foreground_from_prob", "joint_segment_track"]
29
+
30
+
31
+ def cell_foreground_from_prob(prob: np.ndarray, threshold: float) -> np.ndarray:
32
+ """Boolean cell foreground from a Cellpose probability map via the sigmoid.
33
+
34
+ Matches the app's ``sigmoid(prob)`` foreground convention; ``threshold`` is
35
+ the exposed cutoff on the ``[0, 1]`` sigmoid.
36
+ """
37
+ sig = 1.0 / (1.0 + np.exp(-np.asarray(prob, dtype=np.float32)))
38
+ return sig > float(threshold)
39
+
40
+
41
+ def joint_segment_track(
42
+ nucleus_stack: np.ndarray,
43
+ cell_stack: np.ndarray,
44
+ nucleus_params: NucleusParams,
45
+ cell_params: CellParams,
46
+ flow_params: FlowFollowingParams,
47
+ *,
48
+ max_distance: float = 15.0,
49
+ max_frame_gap: int = 0,
50
+ progress_cb: Callable[[int, int, str], None] | None = None,
51
+ cancel_cb: Callable[[], bool] | None = None,
52
+ ) -> tuple[np.ndarray, np.ndarray]:
53
+ """Run the joint path; return ``(nucleus_tracked, cell_tracked)`` ``(T,Z,Y,X)``.
54
+
55
+ Both outputs are ``int32`` and share label ids: a cell carries the id of the
56
+ nucleus it was assigned to. ``max_distance``/``max_frame_gap`` tune the
57
+ nucleus tracker; the cell stack inherits its tracks.
58
+ """
59
+ nucleus_stack = np.asarray(nucleus_stack)
60
+ cell_stack = np.asarray(cell_stack)
61
+ if nucleus_stack.ndim != 4 or cell_stack.ndim != 4:
62
+ raise ValueError("nucleus_stack and cell_stack must be (T, Z, Y, X)")
63
+
64
+ def _report(frac_done: int, total: int, msg: str) -> None:
65
+ if progress_cb is not None:
66
+ progress_cb(frac_done, total, msg)
67
+
68
+ # 1. Nucleus masks → tracked nucleus labels.
69
+ _report(0, 4, "Nucleus masks...")
70
+ nuc_masks = native_masks.run_nucleus_masks_stack(
71
+ nucleus_stack, nucleus_params, cancel_cb=cancel_cb,
72
+ )
73
+ _report(1, 4, "Tracking nuclei...")
74
+ nuc_tracked = track_laptrack.track_axiswise(
75
+ nuc_masks, max_distance=max_distance, max_frame_gap=max_frame_gap,
76
+ ).astype(np.int32)
77
+
78
+ # 2. Cell flows + foreground.
79
+ _report(2, 4, "Cell flows...")
80
+ prob, dp = cellpose_runner.run_cell_stack(
81
+ cell_stack, cell_params, cancel_cb=cancel_cb,
82
+ )
83
+ foreground = cell_foreground_from_prob(prob, flow_params.fg_threshold)
84
+
85
+ # 3. Flow-follow per z-plane; cell labels inherit the tracked nucleus ids.
86
+ _report(3, 4, "Assigning cells to nuclei...")
87
+ T, Z = nuc_tracked.shape[:2]
88
+ cell_tracked = np.zeros_like(nuc_tracked, dtype=np.int32)
89
+ for z in range(Z):
90
+ # dp is (T, Z, 2, Y, X) → per-plane (T, 2, Y, X).
91
+ cell_tracked[:, z] = flow_follow_movie(
92
+ foreground[:, z],
93
+ dp[:, z],
94
+ nuc_tracked[:, z],
95
+ flow_params,
96
+ )
97
+ _report(4, 4, "Joint segmentation complete.")
98
+ return nuc_tracked, cell_tracked
@@ -0,0 +1,10 @@
1
+ name: itasc-cellpose
2
+ display_name: ITASC Cellpose
3
+ contributions:
4
+ commands:
5
+ - id: itasc-cellpose.segment_track
6
+ title: "Cellpose Segment + Track"
7
+ python_name: itasc.napari.cellpose_segment_track_widget:make_cellpose_segment_track_widget
8
+ widgets:
9
+ - command: itasc-cellpose.segment_track
10
+ display_name: "Cellpose Segment + Track"
@@ -0,0 +1,313 @@
1
+ """Capture Cellpose-SAM *native* masks, Qt-free, for the standalone distro.
2
+
3
+ The app's :mod:`itasc.cellpose.cellpose_runner` keeps only the probability /
4
+ flow maps (``_, flows, _ = model.eval(...)``) because the integrated pipeline
5
+ derives its labels from divergence maps + Ultrack. The independently-shipped
6
+ ``itasc-cellpose`` tool instead wants the labelled masks Cellpose computes and
7
+ the runner throws away — that is index ``0`` of ``model.eval``.
8
+
9
+ This module re-runs the same eval loop but keeps the masks, producing a canonical
10
+ ``(T, Z, Y, X)`` label stack per channel. It reuses the runner's model loading,
11
+ gamma correction and cancellation so the two paths stay consistent; it does not
12
+ touch the runner itself, so the app's behaviour is unchanged.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Literal
18
+ from collections.abc import Callable
19
+
20
+ import numpy as np
21
+
22
+ from itasc.core.tiff import imwrite_grayscale
23
+ from itasc.cellpose import cellpose_runner
24
+ from itasc.cellpose.cellpose_runner import (
25
+ CellParams,
26
+ NucleusParams,
27
+ _apply_gamma,
28
+ _check_cancel,
29
+ _diameter_kwarg,
30
+ _NORMALIZE,
31
+ )
32
+
33
+ __all__ = [
34
+ "run_nucleus_masks_frame",
35
+ "run_cell_masks_frame",
36
+ "run_nucleus_masks_stack",
37
+ "run_cell_masks_stack",
38
+ "run_nucleus_maps_frame",
39
+ "iter_nucleus_maps_stack",
40
+ "write_masks",
41
+ "offset_slice_labels",
42
+ ]
43
+
44
+
45
+ def _niter_kwarg(niter: int) -> int | None:
46
+ """``0`` means auto — pass ``None`` so Cellpose derives niter from the diameter."""
47
+ return None if int(niter) == 0 else int(niter)
48
+
49
+
50
+ def _sigmoid(x: np.ndarray) -> np.ndarray:
51
+ """Map Cellpose's cellprob logits to a ``[0, 1]`` probability map."""
52
+ return 1.0 / (1.0 + np.exp(-np.asarray(x, dtype=np.float32)))
53
+
54
+
55
+ def _flow_to_rgb(dp: np.ndarray) -> np.ndarray:
56
+ """HSV→RGB visualization of a ``(2, Y, X)`` flow field → ``(Y, X, 3)`` uint8.
57
+
58
+ Hue encodes flow direction (the colour wheel Cellpose users recognise) and
59
+ value encodes magnitude (per-frame normalised), so touching objects pulled
60
+ apart by their flows are visible. Saturation is fixed at 1.
61
+ """
62
+ dp = np.asarray(dp, dtype=np.float32)
63
+ dy, dx = dp[0], dp[1]
64
+ hue = (np.arctan2(dy, dx) + np.pi) / (2.0 * np.pi) # [0, 1)
65
+ mag = np.hypot(dy, dx)
66
+ mmax = float(mag.max()) if mag.size else 0.0
67
+ val = mag / mmax if mmax > 0.0 else np.zeros_like(mag)
68
+ h6 = hue * 6.0
69
+ i = np.floor(h6).astype(np.int32) % 6
70
+ f = h6 - np.floor(h6)
71
+ p = np.zeros_like(val) # saturation 1 → p = v*(1-s) = 0
72
+ q = val * (1.0 - f)
73
+ tt = val * f
74
+ conds = [i == 0, i == 1, i == 2, i == 3, i == 4, i == 5]
75
+ r = np.select(conds, [val, q, p, p, tt, val])
76
+ g = np.select(conds, [tt, val, val, q, p, p])
77
+ b = np.select(conds, [p, p, tt, val, val, q])
78
+ rgb = np.stack([r, g, b], axis=-1)
79
+ return (np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)
80
+
81
+
82
+ def _eval_maps(img: np.ndarray, **eval_kwargs):
83
+ """Run ``model.eval`` once and keep masks **and** the maps it would discard.
84
+
85
+ Returns ``(masks int32, prob (sigmoid of cellprob) float32, dp float32)`` —
86
+ the labelled masks, the probability map and the raw flow field — from a single
87
+ forward pass, so no channel is segmented twice to surface its intermediates.
88
+ """
89
+ model = cellpose_runner.get_model()
90
+ masks, flows, _styles = model.eval(img, normalize=_NORMALIZE, **eval_kwargs)
91
+ dp = np.asarray(flows[1], dtype=np.float32)
92
+ cellprob = np.asarray(flows[2], dtype=np.float32)
93
+ return np.asarray(masks, dtype=np.int32), _sigmoid(cellprob), dp
94
+
95
+
96
+ def offset_slice_labels(slices: list[np.ndarray]) -> np.ndarray:
97
+ """Stack per-z 2D label images into ``(Z, Y, X)`` with frame-unique labels.
98
+
99
+ Each 2D Cellpose run labels its objects from ``1`` independently, so naively
100
+ stacking would collide labels across z. We offset every slice's non-zero
101
+ labels by the running maximum so a frame's labels are globally unique; the
102
+ background ``0`` is preserved. For the common single-z case this is a no-op.
103
+ """
104
+ out: list[np.ndarray] = []
105
+ running_max = 0
106
+ for sl in slices:
107
+ sl = np.asarray(sl).astype(np.int32, copy=True)
108
+ nonzero = sl > 0
109
+ if nonzero.any():
110
+ sl[nonzero] += running_max
111
+ running_max = int(sl.max())
112
+ out.append(sl)
113
+ return np.stack(out, axis=0)
114
+
115
+
116
+ def _eval_masks(img: np.ndarray, **eval_kwargs) -> np.ndarray:
117
+ """Run ``model.eval`` and return its native masks (index 0) as ``int32``."""
118
+ model = cellpose_runner.get_model()
119
+ masks, _flows, _styles = model.eval(img, normalize=_NORMALIZE, **eval_kwargs)
120
+ return np.asarray(masks, dtype=np.int32)
121
+
122
+
123
+ def run_nucleus_masks_frame(
124
+ frame: np.ndarray,
125
+ z: int | None,
126
+ params: NucleusParams,
127
+ ) -> np.ndarray:
128
+ """Native nucleus masks for one frame.
129
+
130
+ ``z is None`` runs full 3D over ``(Z, Y, X)`` and returns labels ``(Z, Y, X)``;
131
+ an integer ``z`` runs 2D on ``frame[z]`` and returns labels ``(Y, X)``.
132
+ """
133
+ diameter = _diameter_kwarg(params.diameter)
134
+ niter = _niter_kwarg(params.niter)
135
+ if z is None:
136
+ volume = _apply_gamma(frame, params.gamma)
137
+ return _eval_masks(
138
+ volume,
139
+ do_3D=True,
140
+ z_axis=0,
141
+ diameter=diameter,
142
+ anisotropy=params.anisotropy,
143
+ min_size=params.min_size,
144
+ cellprob_threshold=params.cellprob_threshold,
145
+ flow_threshold=params.flow_threshold,
146
+ niter=niter,
147
+ )
148
+ slice_2d = _apply_gamma(frame[z], params.gamma)
149
+ return _eval_masks(
150
+ slice_2d,
151
+ diameter=diameter,
152
+ min_size=params.min_size,
153
+ cellprob_threshold=params.cellprob_threshold,
154
+ flow_threshold=params.flow_threshold,
155
+ niter=niter,
156
+ )
157
+
158
+
159
+ def run_nucleus_maps_frame(
160
+ frame: np.ndarray,
161
+ z: int,
162
+ params: NucleusParams,
163
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
164
+ """Native masks **+ sigmoid prob map + RGB flow** for one 2D z-slice.
165
+
166
+ A single ``model.eval`` pass yields ``(masks (Y, X) int32, prob (Y, X)
167
+ float32, flow_rgb (Y, X, 3) uint8)`` — the labels plus the two intermediates
168
+ (probability and flow) the standalone tool surfaces so Channel 1 can be tuned.
169
+ The standalone anchor is always per-plane 2D, so ``z`` is an integer slice.
170
+ """
171
+ diameter = _diameter_kwarg(params.diameter)
172
+ slice_2d = _apply_gamma(frame[z], params.gamma)
173
+ masks, prob, dp = _eval_maps(
174
+ slice_2d,
175
+ diameter=diameter,
176
+ min_size=params.min_size,
177
+ cellprob_threshold=params.cellprob_threshold,
178
+ flow_threshold=params.flow_threshold,
179
+ niter=_niter_kwarg(params.niter),
180
+ )
181
+ return masks, prob, _flow_to_rgb(dp)
182
+
183
+
184
+ def iter_nucleus_maps_stack(
185
+ stack: np.ndarray,
186
+ params: NucleusParams,
187
+ *,
188
+ progress_cb: Callable[[int, int, str], None] | None = None,
189
+ cancel_cb: Callable[[], bool] | None = None,
190
+ ):
191
+ """Yield per-frame ``(t, masks, prob, flow_rgb)`` so results stream into a viewer.
192
+
193
+ For a ``(T, Z, Y, X)`` input each yield carries one time-frame: ``masks`` and
194
+ ``prob`` are ``(Z, Y, X)`` and ``flow_rgb`` is ``(Z, Y, X, 3)`` uint8, with
195
+ per-frame-unique mask labels (:func:`offset_slice_labels`). Always per-plane 2D
196
+ (the standalone anchor never runs do_3d). A caller can drop each frame into a
197
+ napari layer as it arrives instead of waiting for the whole stack.
198
+ """
199
+ stack = np.asarray(stack)
200
+ if stack.ndim != 4:
201
+ raise ValueError(f"expected (T, Z, Y, X), got shape {stack.shape}")
202
+ T, Z = stack.shape[:2]
203
+ for t in range(T):
204
+ _check_cancel(cancel_cb)
205
+ mask_slices: list[np.ndarray] = []
206
+ prob_slices: list[np.ndarray] = []
207
+ flow_slices: list[np.ndarray] = []
208
+ for z in range(Z):
209
+ if progress_cb is not None:
210
+ progress_cb(t, T, f"Channel 1 maps: frame {t + 1}/{T}, z {z + 1}/{Z}...")
211
+ m, p, f = run_nucleus_maps_frame(stack[t], z=z, params=params)
212
+ mask_slices.append(m)
213
+ prob_slices.append(p)
214
+ flow_slices.append(f)
215
+ masks = offset_slice_labels(mask_slices)
216
+ prob = np.stack(prob_slices, axis=0).astype(np.float32)
217
+ flow = np.stack(flow_slices, axis=0)
218
+ if progress_cb is not None:
219
+ progress_cb(t + 1, T, f"Channel 1 maps: frame {t + 1}/{T}...")
220
+ yield t, masks, prob, flow
221
+
222
+
223
+ def run_cell_masks_frame(
224
+ frame: np.ndarray,
225
+ z: int,
226
+ params: CellParams,
227
+ ) -> np.ndarray:
228
+ """Native cell masks for a single 2D z-slice. Returns labels ``(Y, X)``."""
229
+ diameter = _diameter_kwarg(params.diameter)
230
+ slice_2d = _apply_gamma(frame[z], params.gamma)
231
+ return _eval_masks(slice_2d, diameter=diameter, min_size=params.min_size)
232
+
233
+
234
+ def run_nucleus_masks_stack(
235
+ stack: np.ndarray,
236
+ params: NucleusParams,
237
+ *,
238
+ progress_cb: Callable[[int, int, str], None] | None = None,
239
+ cancel_cb: Callable[[], bool] | None = None,
240
+ ) -> np.ndarray:
241
+ """Process a ``(T, Z, Y, X)`` stack into a ``(T, Z, Y, X)`` label stack.
242
+
243
+ ``params.do_3d`` runs a coherent 3D segmentation per frame; otherwise each
244
+ z-slice is segmented in 2D and the slices are stacked with frame-unique
245
+ labels (see :func:`offset_slice_labels`).
246
+ """
247
+ if stack.ndim != 4:
248
+ raise ValueError(f"expected (T, Z, Y, X), got shape {stack.shape}")
249
+ T, Z = stack.shape[:2]
250
+ frames: list[np.ndarray] = []
251
+ for t in range(T):
252
+ _check_cancel(cancel_cb)
253
+ if progress_cb is not None:
254
+ progress_cb(t, T, f"Nucleus masks: frame {t + 1}/{T}...")
255
+ if params.do_3d:
256
+ masks = run_nucleus_masks_frame(stack[t], z=None, params=params)
257
+ else:
258
+ slices = []
259
+ for z in range(Z):
260
+ if progress_cb is not None:
261
+ progress_cb(
262
+ t, T,
263
+ f"Nucleus masks: frame {t + 1}/{T}, z {z + 1}/{Z}...",
264
+ )
265
+ slices.append(run_nucleus_masks_frame(stack[t], z=z, params=params))
266
+ masks = offset_slice_labels(slices)
267
+ frames.append(masks.astype(np.int32))
268
+ if progress_cb is not None:
269
+ progress_cb(t + 1, T, f"Nucleus masks: frame {t + 1}/{T}...")
270
+ return np.stack(frames, axis=0)
271
+
272
+
273
+ def run_cell_masks_stack(
274
+ stack: np.ndarray,
275
+ params: CellParams,
276
+ *,
277
+ progress_cb: Callable[[int, int, str], None] | None = None,
278
+ cancel_cb: Callable[[], bool] | None = None,
279
+ ) -> np.ndarray:
280
+ """Process a ``(T, Z, Y, X)`` stack slice-by-slice into ``(T, Z, Y, X)`` labels."""
281
+ if stack.ndim != 4:
282
+ raise ValueError(f"expected (T, Z, Y, X), got shape {stack.shape}")
283
+ T, Z = stack.shape[:2]
284
+ frames: list[np.ndarray] = []
285
+ for t in range(T):
286
+ _check_cancel(cancel_cb)
287
+ if progress_cb is not None:
288
+ progress_cb(t, T, f"Cell masks: frame {t + 1}/{T}...")
289
+ slices = [run_cell_masks_frame(stack[t], z=z, params=params) for z in range(Z)]
290
+ frames.append(offset_slice_labels(slices))
291
+ if progress_cb is not None:
292
+ progress_cb(t + 1, T, f"Cell masks: frame {t + 1}/{T}...")
293
+ return np.stack(frames, axis=0)
294
+
295
+
296
+ def write_masks(
297
+ masks_tzyx: np.ndarray,
298
+ output_dir: Path,
299
+ channel: Literal["nucleus", "cell"],
300
+ ) -> Path:
301
+ """Write ``{channel}_masks.tif`` (``int32``, axes ``TZYX``); return its path."""
302
+ if channel not in ("nucleus", "cell"):
303
+ raise ValueError(f"channel must be 'nucleus' or 'cell', got {channel!r}")
304
+ if masks_tzyx.ndim != 4:
305
+ raise ValueError(f"masks must be (T, Z, Y, X), got {masks_tzyx.shape}")
306
+ output_dir = Path(output_dir)
307
+ output_dir.mkdir(parents=True, exist_ok=True)
308
+ path = output_dir / f"{channel}_masks.tif"
309
+ imwrite_grayscale(
310
+ path, masks_tzyx.astype(np.int32),
311
+ compression="zlib", metadata={"axes": "TZYX"},
312
+ )
313
+ return path
@@ -0,0 +1,232 @@
1
+ """DB-free greedy retracker for the standalone segment + track corrector.
2
+
3
+ This mirrors the constrained retracker in :mod:`itasc.tracking_ultrack`
4
+ (which ships in a separate ``itasc-tracking`` distribution the standalone
5
+ ``itasc-cellpose`` tool does *not* depend on), reduced to the validation-free
6
+ case the standalone needs: starting from the current frame, every later frame's
7
+ labels are re-linked to the already-retracked neighbour toward the start frame
8
+ by the same additive similarity score (area ratio + centroid-corrected IoU -
9
+ distance), matched greedily best-first. Unmatched target cells receive fresh
10
+ ids. There is no locked/validated/reserved concept here — the standalone has no
11
+ validation store — so the algorithm is purely the geometric re-linking.
12
+
13
+ Everything here is Qt-free and depends only on numpy/scipy/skimage, so it ships
14
+ inside the cellpose distro tree and is unit-testable without a viewer.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Literal
19
+
20
+ import numpy as np
21
+ from scipy.spatial.distance import cdist
22
+ from skimage.measure import regionprops
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Similarity scoring (ported from itasc.tracking_ultrack.scoring so the
27
+ # standalone stays self-contained — same formula, so the UX matches the app).
28
+ # ---------------------------------------------------------------------------
29
+ def _rasterize(coords: np.ndarray, mins: np.ndarray, shape: tuple) -> np.ndarray:
30
+ canvas = np.zeros(shape, dtype=bool)
31
+ idx = np.rint(coords - mins).astype(int)
32
+ valid = np.ones(len(idx), dtype=bool)
33
+ for axis, size in enumerate(shape):
34
+ valid &= (idx[:, axis] >= 0) & (idx[:, axis] < size)
35
+ idx = idx[valid]
36
+ if idx.size:
37
+ canvas[tuple(idx.T)] = True
38
+ return canvas
39
+
40
+
41
+ def centroid_corrected_iou_from_coords(
42
+ src_coords: np.ndarray,
43
+ src_centroid: np.ndarray,
44
+ target_coords: np.ndarray,
45
+ target_centroid: np.ndarray,
46
+ ) -> float:
47
+ """IoU after shifting the target so its centroid matches the source's."""
48
+ if len(src_coords) == 0 or len(target_coords) == 0:
49
+ return 0.0
50
+ shifted = target_coords + (src_centroid - target_centroid)
51
+ all_coords = np.vstack([src_coords, shifted])
52
+ mins = np.floor(all_coords.min(axis=0)).astype(int) - 1
53
+ maxs = np.ceil(all_coords.max(axis=0)).astype(int) + 1
54
+ shape = tuple((maxs - mins + 1).tolist())
55
+ if any(dim <= 0 for dim in shape):
56
+ return 0.0
57
+ mins_f = mins.astype(np.float32)
58
+ src_canvas = _rasterize(src_coords, mins_f, shape)
59
+ tgt_canvas = _rasterize(shifted, mins_f, shape)
60
+ union = np.logical_or(src_canvas, tgt_canvas).sum()
61
+ if union == 0:
62
+ return 0.0
63
+ return float(np.logical_and(src_canvas, tgt_canvas).sum() / union)
64
+
65
+
66
+ def similarity_score(
67
+ *,
68
+ area_ratio: float,
69
+ centroid_corrected_iou: float,
70
+ distance: float,
71
+ area_weight: float,
72
+ iou_weight: float,
73
+ distance_weight: float,
74
+ ) -> float:
75
+ """Additive similarity score (higher = more preferred).
76
+
77
+ Shape terms are positive rewards in ``[0, 1]``; distance is a raw pixel
78
+ penalty, so the score can go negative for far candidates.
79
+ """
80
+ return (
81
+ area_weight * area_ratio
82
+ + iou_weight * centroid_corrected_iou
83
+ - distance_weight * distance
84
+ )
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Frame / stack retracking
89
+ # ---------------------------------------------------------------------------
90
+ def _label_props(
91
+ labels: np.ndarray,
92
+ ) -> dict[int, tuple[np.ndarray, float, np.ndarray]]:
93
+ """Return ``{label_id: (centroid_yx, area, coords)}`` for every non-zero id."""
94
+ props: dict[int, tuple[np.ndarray, float, np.ndarray]] = {}
95
+ for region in regionprops(labels):
96
+ props[int(region.label)] = (
97
+ np.asarray(region.centroid, dtype=np.float32),
98
+ float(region.area),
99
+ region.coords.astype(np.float32),
100
+ )
101
+ return props
102
+
103
+
104
+ def retrack_frame(
105
+ ref_labels: np.ndarray,
106
+ target_labels: np.ndarray,
107
+ *,
108
+ max_dist_px: float = 50.0,
109
+ area_weight: float = 1.0,
110
+ iou_weight: float = 1.0,
111
+ distance_weight: float = 0.05,
112
+ ) -> np.ndarray:
113
+ """Remap every target id to its best-matching reference id by similarity.
114
+
115
+ Each target cell takes its highest-scoring still-free reference cell
116
+ (greedy best-first), among reference cells whose centroid is within
117
+ ``max_dist_px``. Targets with no eligible reference get a fresh id above all
118
+ existing ids, so no two cells ever collide. Returns a relabelled copy of
119
+ ``target_labels`` (input arrays are not mutated).
120
+ """
121
+ result = np.zeros_like(target_labels)
122
+
123
+ tgt_ids = [int(i) for i in np.unique(target_labels) if i != 0]
124
+ if not tgt_ids:
125
+ return result
126
+
127
+ ref_ids = [int(i) for i in np.unique(ref_labels) if i != 0]
128
+ max_existing = max(
129
+ int(ref_labels.max()) if ref_labels.size and ref_labels.max() > 0 else 0,
130
+ int(target_labels.max()) if target_labels.size and target_labels.max() > 0 else 0,
131
+ )
132
+
133
+ def _assign_fresh(remap: dict[int, int]) -> dict[int, int]:
134
+ next_id = max_existing + 1
135
+ for tid in tgt_ids:
136
+ if tid not in remap:
137
+ remap[tid] = next_id
138
+ next_id += 1
139
+ return remap
140
+
141
+ if not ref_ids:
142
+ for tid, new_id in _assign_fresh({}).items():
143
+ result[target_labels == tid] = new_id
144
+ return result
145
+
146
+ tgt_props = _label_props(np.where(np.isin(target_labels, tgt_ids), target_labels, 0))
147
+ ref_props = _label_props(np.where(np.isin(ref_labels, ref_ids), ref_labels, 0))
148
+
149
+ tgt_centroids = np.array([tgt_props[t][0] for t in tgt_ids])
150
+ ref_centroids = np.array([ref_props[r][0] for r in ref_ids])
151
+ dist = cdist(tgt_centroids, ref_centroids)
152
+ gate = dist <= max_dist_px
153
+
154
+ # Score every distance-eligible (target, reference) pair.
155
+ scored: list[tuple[float, float, int, int]] = []
156
+ for ti in range(dist.shape[0]):
157
+ _t_centroid, t_area, t_coords = tgt_props[tgt_ids[ti]]
158
+ for ri in np.nonzero(gate[ti])[0]:
159
+ r_centroid, r_area, r_coords = ref_props[ref_ids[ri]]
160
+ area_ratio = min(t_area, r_area) / max(t_area, r_area)
161
+ iou = centroid_corrected_iou_from_coords(
162
+ r_coords, r_centroid, t_coords, tgt_props[tgt_ids[ti]][0]
163
+ )
164
+ score = similarity_score(
165
+ area_ratio=area_ratio,
166
+ centroid_corrected_iou=iou,
167
+ distance=float(dist[ti, ri]),
168
+ area_weight=area_weight,
169
+ iou_weight=iou_weight,
170
+ distance_weight=distance_weight,
171
+ )
172
+ scored.append((score, float(dist[ti, ri]), ti, int(ri)))
173
+
174
+ # Greedy best-first: highest score wins, nearer centroid breaks ties. Each
175
+ # target and reference is consumed once.
176
+ scored.sort(key=lambda item: (-item[0], item[1]))
177
+
178
+ remap: dict[int, int] = {}
179
+ used_refs: set[int] = set()
180
+ for _score, _d, ti, ri in scored:
181
+ tid = tgt_ids[ti]
182
+ if tid in remap or ri in used_refs:
183
+ continue
184
+ remap[tid] = ref_ids[ri]
185
+ used_refs.add(ri)
186
+
187
+ for tid, new_id in _assign_fresh(remap).items():
188
+ result[target_labels == tid] = new_id
189
+ return result
190
+
191
+
192
+ def retrack_stack(
193
+ stack: np.ndarray,
194
+ *,
195
+ start_frame: int,
196
+ direction: Literal["forward", "backward"],
197
+ max_dist_px: float = 50.0,
198
+ area_weight: float = 1.0,
199
+ iou_weight: float = 1.0,
200
+ distance_weight: float = 0.05,
201
+ ) -> np.ndarray:
202
+ """Retrack a time-first ``(T, Y, X)`` stack outward from ``start_frame``.
203
+
204
+ The start frame is kept as the anchor; each later frame in ``direction`` is
205
+ re-linked (:func:`retrack_frame`) to the already-retracked neighbour toward
206
+ the start frame, so corrected ids propagate. Returns a new stack; the input
207
+ is not mutated.
208
+ """
209
+ stack = np.asarray(stack)
210
+ if stack.ndim != 3 or stack.shape[0] < 2:
211
+ raise ValueError("retrack_stack needs a 3D time-first stack with >= 2 frames.")
212
+
213
+ out = stack.copy()
214
+ if direction == "forward":
215
+ frame_range = range(start_frame + 1, out.shape[0])
216
+ neighbour = lambda t: out[t - 1]
217
+ elif direction == "backward":
218
+ frame_range = range(start_frame - 1, -1, -1)
219
+ neighbour = lambda t: out[t + 1]
220
+ else:
221
+ raise ValueError(f"unknown retrack direction: {direction!r}")
222
+
223
+ for t in frame_range:
224
+ out[t] = retrack_frame(
225
+ neighbour(t),
226
+ out[t],
227
+ max_dist_px=max_dist_px,
228
+ area_weight=area_weight,
229
+ iou_weight=iou_weight,
230
+ distance_weight=distance_weight,
231
+ )
232
+ return out