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,637 @@
1
+ """Unary-only cell label segmentation (contour-aware geodesic Voronoi).
2
+
3
+ - ``initialize_icm``: compute geodesic unary costs from the contour/foreground
4
+ cost field and assign each foreground pixel to its nearest nucleus seed
5
+ (per-pixel argmin). Returns a :class:`CellICMState` caching the unary and a
6
+ hard-anchored initial label array.
7
+
8
+ - ``assemble_cost_field``: build the per-frame geodesic cost field the walk
9
+ traverses (shared with the cell widget's preview).
10
+
11
+ - ``commit_labels``: write the label array to a TIFF file.
12
+
13
+ The spatial/temporal Potts pairwise terms and the iterated-conditional-modes
14
+ refinement sweep have been removed — with the pairwise weights zeroed the ICM
15
+ optimum is exactly the argmin of the unary, so the initialisation *is* the
16
+ answer.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import logging
22
+ import multiprocessing as mp
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from time import perf_counter
27
+ from collections.abc import Callable
28
+
29
+ import h5py
30
+ import numpy as np
31
+ from skimage.graph import MCP_Geometric
32
+
33
+ from itasc.core.tiff import imwrite_grayscale
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ __all__ = [
38
+ "CellLabelICMParams",
39
+ "CellICMState",
40
+ "assemble_cost_field",
41
+ "balance_strength_to_weights",
42
+ "initialize_icm",
43
+ "commit_labels",
44
+ ]
45
+
46
+ # ── Constants ────────────────────────────────────────────────────────────────
47
+
48
+ _INF: float = 1e9
49
+
50
+
51
+ # ── Cost-field parameterisation ──────────────────────────────────────────────
52
+
53
+ def balance_strength_to_weights(
54
+ balance: float, feature_strength: float
55
+ ) -> tuple[float, float]:
56
+ """Map the (balance, feature_strength) knobs to raw cost-field weights.
57
+
58
+ The geodesic cost field is ``1 + alpha * contour + gamma * (1 - fg_score)``.
59
+ Because the final labels come from a per-pixel ``argmin`` over geodesic
60
+ distances, multiplying the whole field by any positive constant leaves the
61
+ result unchanged — overall scale is a free gauge. That leaves exactly two
62
+ observable degrees of freedom, exposed here as:
63
+
64
+ - ``balance`` (``r`` in ``[0, 1]``) — the contour↔foreground split
65
+ (``1`` = pure contour, ``0`` = pure foreground).
66
+ - ``feature_strength`` (``s >= 0``) — how strongly either feature bends the
67
+ walk away from a plain distance Voronoi, relative to the fixed base of 1
68
+ (``0`` = pure distance Voronoi).
69
+
70
+ with ``alpha = s * r`` and ``gamma = s * (1 - r)``.
71
+ """
72
+ r = min(1.0, max(0.0, float(balance)))
73
+ s = max(0.0, float(feature_strength))
74
+ return s * r, s * (1.0 - r)
75
+
76
+
77
+ # ── Dataclasses ──────────────────────────────────────────────────────────────
78
+
79
+ @dataclass
80
+ class CellLabelICMParams:
81
+ """Parameters for the unary-only geodesic cell segmentation."""
82
+
83
+ balance: float = 1.0
84
+ """Contour↔foreground split ``r`` in ``[0, 1]``: ``1`` = pure contour,
85
+ ``0`` = pure foreground. See :func:`balance_strength_to_weights`."""
86
+
87
+ feature_strength: float = 4.0
88
+ """Overall feature weight ``s >= 0`` relative to the fixed base cost of 1:
89
+ how strongly contour/foreground bend the walk away from a plain geodesic
90
+ distance Voronoi. ``0`` = pure distance Voronoi."""
91
+
92
+ n_workers: int = 1
93
+ """Parallel worker processes for geodesic unary computation.
94
+ 1 = sequential. Values > 1 use fork-based multiprocessing
95
+ to compute frames in parallel."""
96
+
97
+
98
+ @dataclass
99
+ class CellICMState:
100
+ """Cached energy-landscape data for the unary segmentation.
101
+
102
+ Created by :func:`initialize_icm`. All arrays are stored as their
103
+ solver-ready dtypes (float32 / uint32 / bool).
104
+ """
105
+
106
+ fg_mask: np.ndarray = field(repr=False)
107
+ """(T, Y, X) bool — foreground mask (includes nucleus pixels)."""
108
+
109
+ nuc_tracks: np.ndarray = field(repr=False)
110
+ """(T, Y, X) uint32 — nucleus track IDs (0 = no nucleus)."""
111
+
112
+ label_ids: np.ndarray = field(repr=False)
113
+ """(K,) uint32 — sorted global set of label (track) IDs."""
114
+
115
+ unary_dense: np.ndarray | None = field(default=None, repr=False)
116
+ """Deprecated. Previously held the dense ``(T, Y, X, K)`` unary cost
117
+ volume; initial labels are now computed with a streaming argmin
118
+ (:func:`_argmin_init_from_dict`) so this is left ``None`` to avoid the
119
+ multi-gigabyte allocation."""
120
+
121
+ @property
122
+ def shape(self) -> tuple[int, int, int]:
123
+ return self.fg_mask.shape # type: ignore[return-value]
124
+
125
+ @property
126
+ def n_labels(self) -> int:
127
+ return len(self.label_ids)
128
+
129
+
130
+ # ── Cost field assembly (shared by solver + previews) ────────────────────────
131
+
132
+ def assemble_cost_field(
133
+ contours_t: np.ndarray,
134
+ fg_t: np.ndarray,
135
+ alpha_unary: float,
136
+ fg_scores_t: np.ndarray | None = None,
137
+ gamma_unary: float = 0.0,
138
+ ) -> np.ndarray:
139
+ """Per-pixel geodesic cost over a single frame's foreground mask.
140
+
141
+ ``cost = 1 + alpha_unary * contour + gamma_unary * (1 - fg_score)`` inside
142
+ the mask, ``inf`` elsewhere. This is the exact field the geodesic walk
143
+ traverses; sharing it between :func:`_compute_frame_geodesic` and the cell
144
+ widget's live preview guarantees the preview shows the same array the
145
+ solver uses rather than a re-derivation.
146
+ """
147
+ Y, X = fg_t.shape
148
+ cost_field = np.full((Y, X), np.inf, dtype=np.float32)
149
+ c = 1.0 + alpha_unary * contours_t[fg_t]
150
+ if gamma_unary != 0.0 and fg_scores_t is not None:
151
+ c = c + gamma_unary * (1.0 - np.clip(fg_scores_t[fg_t], 0.0, 1.0))
152
+ cost_field[fg_t] = c
153
+ return cost_field
154
+
155
+
156
+ # ── Internal: geodesic unaries ───────────────────────────────────────────────
157
+
158
+ def _compute_frame_geodesic(
159
+ contours_t: np.ndarray,
160
+ fg_t: np.ndarray,
161
+ nuc_t: np.ndarray,
162
+ label_ids: np.ndarray,
163
+ alpha_unary: float,
164
+ fg_scores_t: np.ndarray | None = None,
165
+ gamma_unary: float = 0.0,
166
+ ) -> dict[int, np.ndarray]:
167
+ """Compute normalised geodesic unaries for all alive labels in one frame.
168
+
169
+ Returns ``{k: (Y, X) float32}`` — normalised geodesic distance per label.
170
+ Dead / background entries are ``_INF``.
171
+
172
+ The MCP object is created once and reused for all labels in the frame
173
+ (the cost field depends only on the contour map, not the label).
174
+ """
175
+ # Build cost field — shared across all labels (and with the widget preview)
176
+ cost_field = assemble_cost_field(
177
+ contours_t, fg_t, alpha_unary, fg_scores_t, gamma_unary
178
+ )
179
+
180
+ # Locate every nucleus pixel in a single pass and group by label, rather
181
+ # than rescanning the whole frame once per label (which was O(K * Y * X)).
182
+ nz_y, nz_x = np.nonzero(nuc_t)
183
+ if nz_y.size == 0:
184
+ return {}
185
+ nz_lab = nuc_t[nz_y, nz_x]
186
+ order = np.argsort(nz_lab, kind="stable") # stable → row-major within label
187
+ nz_lab = nz_lab[order]
188
+ nz_y = nz_y[order]
189
+ nz_x = nz_x[order]
190
+ uniq, starts_idx = np.unique(nz_lab, return_index=True)
191
+ bounds = np.append(starts_idx, nz_lab.size)
192
+ alive = [int(k) for k in uniq]
193
+
194
+ # Single MCP object reused for all labels in this frame
195
+ mcp = MCP_Geometric(cost_field, fully_connected=True)
196
+
197
+ raw: dict[int, np.ndarray] = {}
198
+ for i, k in enumerate(alive):
199
+ s, e = int(starts_idx[i]), int(bounds[i + 1])
200
+ ys = nz_y[s:e]
201
+ xs = nz_x[s:e]
202
+ # Seed the geodesic front from the nucleus centroid only, not the
203
+ # whole body, so pixels are drawn toward the centre rather than
204
+ # captured by the nearest point of the full nucleus footprint.
205
+ cy = ys.mean()
206
+ cx = xs.mean()
207
+ nearest = int(np.argmin((ys - cy) ** 2 + (xs - cx) ** 2))
208
+ cum, _ = mcp.find_costs([(int(ys[nearest]), int(xs[nearest]))])
209
+ d = cum.astype(np.float32)
210
+ d[~fg_t] = np.inf
211
+ raw[k] = d
212
+
213
+ # Per-frame median normalisation
214
+ all_finite = np.concatenate([d[np.isfinite(d)] for d in raw.values()])
215
+ med = float(np.median(all_finite)) if all_finite.size > 0 else 1.0
216
+ if med <= 0.0:
217
+ med = 1.0
218
+
219
+ result: dict[int, np.ndarray] = {}
220
+ for k, d in raw.items():
221
+ nd = d / med
222
+ nd[~np.isfinite(nd)] = _INF
223
+ result[k] = nd
224
+
225
+ # Hard nucleus anchors within this frame: each label is forbidden (_INF)
226
+ # on every *other* label's nucleus pixels. O(K) full-frame writes instead
227
+ # of the previous O(K^2) label-pair loop.
228
+ nuc_pos = nuc_t > 0
229
+ for k in alive:
230
+ result[k][nuc_pos & (nuc_t != k)] = _INF
231
+
232
+ return result
233
+
234
+
235
+ # ── Parallel worker globals (fork-inherited, no pickling) ────────────────────
236
+
237
+ _MP_CONTOURS: np.ndarray | None = None
238
+ _MP_FG_MASK: np.ndarray | None = None
239
+ _MP_NUC_TRACKS: np.ndarray | None = None
240
+ _MP_LABEL_IDS: np.ndarray | None = None
241
+ _MP_ALPHA_UNARY: float = 0.0
242
+ _MP_GAMMA_UNARY: float = 0.0
243
+ _MP_FG_SCORES: np.ndarray | None = None
244
+
245
+
246
+ def _geodesic_frame_worker(t: int) -> tuple[int, dict[int, np.ndarray]]:
247
+ """Multiprocessing worker: compute geodesic unaries for frame *t*.
248
+
249
+ Reads from module-level globals set before ``Pool`` creation —
250
+ inherited via fork-COW on Linux, zero pickling overhead.
251
+ """
252
+ result = _compute_frame_geodesic(
253
+ _MP_CONTOURS[t],
254
+ _MP_FG_MASK[t],
255
+ _MP_NUC_TRACKS[t],
256
+ _MP_LABEL_IDS,
257
+ _MP_ALPHA_UNARY,
258
+ fg_scores_t=_MP_FG_SCORES[t] if _MP_FG_SCORES is not None else None,
259
+ gamma_unary=_MP_GAMMA_UNARY,
260
+ )
261
+ return t, result
262
+
263
+
264
+ def _compute_geodesic_unaries(
265
+ nuc_tracks: np.ndarray,
266
+ fg_mask: np.ndarray,
267
+ contours: np.ndarray,
268
+ label_ids: np.ndarray,
269
+ alpha_unary: float,
270
+ *,
271
+ foreground_scores: np.ndarray | None = None,
272
+ gamma_unary: float = 0.0,
273
+ n_workers: int = 1,
274
+ progress_cb: Callable[[str], None] | None = None,
275
+ ) -> dict[tuple[int, int], np.ndarray]:
276
+ """Compute normalised geodesic unary costs for each alive (frame, label).
277
+
278
+ When ``n_workers > 1``, frames are computed in parallel using
279
+ fork-based multiprocessing (Linux). Each worker inherits the input
280
+ arrays via copy-on-write — only the frame index is sent through the
281
+ pipe per task.
282
+ """
283
+ T = fg_mask.shape[0]
284
+ _report = progress_cb or (lambda msg: None)
285
+
286
+ if n_workers > 1 and "fork" in mp.get_all_start_methods():
287
+ # The parallel path relies on fork COW to share input arrays with
288
+ # workers (see _compute_geodesic_unaries_parallel). Where fork is
289
+ # unavailable — Windows only offers spawn, which re-imports rather than
290
+ # inheriting the module globals — fall through to the sequential loop.
291
+ return _compute_geodesic_unaries_parallel(
292
+ nuc_tracks, fg_mask, contours, label_ids, alpha_unary,
293
+ foreground_scores=foreground_scores,
294
+ gamma_unary=gamma_unary,
295
+ n_workers=n_workers,
296
+ progress_cb=progress_cb,
297
+ )
298
+
299
+ # ── Sequential path ──────────────────────────────────────────────
300
+ unary: dict[tuple[int, int], np.ndarray] = {}
301
+ for t in range(T):
302
+ frame_result = _compute_frame_geodesic(
303
+ contours[t], fg_mask[t], nuc_tracks[t], label_ids,
304
+ alpha_unary,
305
+ fg_scores_t=(
306
+ foreground_scores[t] if foreground_scores is not None else None
307
+ ),
308
+ gamma_unary=gamma_unary,
309
+ )
310
+ for k, d in frame_result.items():
311
+ unary[(t, k)] = d
312
+ if progress_cb and ((t + 1) % 10 == 0 or t + 1 == T):
313
+ alive = len(frame_result)
314
+ _report(f"Geodesic unaries: frame {t + 1}/{T}, {alive} alive")
315
+
316
+ return unary
317
+
318
+
319
+ def _compute_geodesic_unaries_parallel(
320
+ nuc_tracks: np.ndarray,
321
+ fg_mask: np.ndarray,
322
+ contours: np.ndarray,
323
+ label_ids: np.ndarray,
324
+ alpha_unary: float,
325
+ *,
326
+ foreground_scores: np.ndarray | None = None,
327
+ gamma_unary: float = 0.0,
328
+ n_workers: int = 4,
329
+ progress_cb: Callable[[str], None] | None = None,
330
+ ) -> dict[tuple[int, int], np.ndarray]:
331
+ """Parallel geodesic unary computation across frames.
332
+
333
+ Uses fork-based multiprocessing: input arrays are set as module-level
334
+ globals and inherited by worker processes via COW. Only the frame
335
+ index (a single int) is sent per task; results (sparse dicts of
336
+ float32 arrays) are returned through the pipe.
337
+ """
338
+ global _MP_CONTOURS, _MP_FG_MASK, _MP_NUC_TRACKS, _MP_LABEL_IDS
339
+ global _MP_ALPHA_UNARY, _MP_GAMMA_UNARY, _MP_FG_SCORES
340
+
341
+ T = fg_mask.shape[0]
342
+ _report = progress_cb or (lambda msg: None)
343
+ n_workers = min(n_workers, T, os.cpu_count() or 1)
344
+
345
+ # Set globals before fork — workers inherit via COW
346
+ _MP_CONTOURS = contours
347
+ _MP_FG_MASK = fg_mask
348
+ _MP_NUC_TRACKS = nuc_tracks
349
+ _MP_LABEL_IDS = label_ids
350
+ _MP_ALPHA_UNARY = alpha_unary
351
+ _MP_GAMMA_UNARY = gamma_unary
352
+ _MP_FG_SCORES = foreground_scores
353
+
354
+ _report(f"Computing geodesic unaries ({n_workers} workers, {T} frames)...")
355
+
356
+ unary: dict[tuple[int, int], np.ndarray] = {}
357
+ done = 0
358
+
359
+ try:
360
+ ctx = mp.get_context("fork")
361
+ with ctx.Pool(n_workers) as pool:
362
+ for t, frame_result in pool.imap_unordered(
363
+ _geodesic_frame_worker, range(T)
364
+ ):
365
+ for k, d in frame_result.items():
366
+ unary[(t, k)] = d
367
+ done += 1
368
+ if progress_cb and (done % 10 == 0 or done == T):
369
+ _report(
370
+ f"Geodesic unaries: {done}/{T} frames "
371
+ f"({len(frame_result)} labels in frame {t})"
372
+ )
373
+ finally:
374
+ # Clear globals — don't keep references to large arrays
375
+ _MP_CONTOURS = None
376
+ _MP_FG_MASK = None
377
+ _MP_NUC_TRACKS = None
378
+ _MP_LABEL_IDS = None
379
+ _MP_FG_SCORES = None
380
+
381
+ return unary
382
+
383
+
384
+ def _apply_nucleus_anchors(
385
+ unary: dict[tuple[int, int], np.ndarray],
386
+ nuc_tracks: np.ndarray,
387
+ label_ids: np.ndarray,
388
+ ) -> dict[tuple[int, int], np.ndarray]:
389
+ """Re-apply hard nucleus anchors: cost=0 for own label, INF for others."""
390
+ T = nuc_tracks.shape[0]
391
+ label_list = [int(k) for k in label_ids]
392
+ cached_by_t: dict[int, list[int]] = {}
393
+ for (t, j) in unary:
394
+ cached_by_t.setdefault(int(t), []).append(int(j))
395
+
396
+ for t in range(T):
397
+ alive = [k for k in label_list if int((nuc_tracks[t] == k).sum()) > 0]
398
+ cached = cached_by_t.get(t, [])
399
+ for k in alive:
400
+ k_pix = nuc_tracks[t] == k
401
+ if (t, k) in unary:
402
+ unary[(t, k)][k_pix] = 0.0
403
+ for j in cached:
404
+ if j != k:
405
+ unary[(t, j)][k_pix] = _INF
406
+ return unary
407
+
408
+
409
+ def _argmin_init_from_dict(
410
+ unary: dict[tuple[int, int], np.ndarray],
411
+ fg_mask: np.ndarray,
412
+ label_ids: np.ndarray,
413
+ ) -> np.ndarray:
414
+ """Per-pixel nearest-seed assignment over the sparse unary dict.
415
+
416
+ Equivalent to ``argmin`` over the label axis of the dense
417
+ ``(T, Y, X, K)`` cost volume, but computed as a running minimum so the
418
+ full volume is never materialised (it can be tens of GB for large
419
+ stacks with many labels). Missing ``(t, k)`` entries are treated as
420
+ ``_INF``, matching the dense fill value.
421
+ """
422
+ T, Y, X = fg_mask.shape
423
+ best_cost = np.full((T, Y, X), _INF, dtype=np.float32)
424
+ best_ki = np.zeros((T, Y, X), dtype=np.intp)
425
+ for ki, k in enumerate(label_ids):
426
+ for t in range(T):
427
+ u = unary.get((t, int(k)))
428
+ if u is None:
429
+ continue
430
+ # Strict ``<`` keeps the lowest ki on ties, matching np.argmin.
431
+ better = u < best_cost[t]
432
+ np.copyto(best_cost[t], u, where=better)
433
+ best_ki[t][better] = ki
434
+ # A foreground pixel no seed reached keeps best_cost == _INF (and best_ki 0);
435
+ # it must stay background rather than collapse onto label_ids[0].
436
+ reached = best_cost < _INF
437
+ return np.where(fg_mask & reached, label_ids[best_ki], 0).astype(np.uint32)
438
+
439
+
440
+ # ── Internal: HDF5 unary cache ───────────────────────────────────────────────
441
+
442
+ def _unary_cache_key(
443
+ shape: tuple[int, int, int],
444
+ alpha_unary: float,
445
+ gamma_unary: float,
446
+ *content: np.ndarray | None,
447
+ ) -> str:
448
+ """Content-addressed key for a set of geodesic unaries.
449
+
450
+ The unaries are a function of the *inputs* (nucleus tracks, foreground mask,
451
+ contours, foreground scores), not just the shape and α/γ weights. Folding a
452
+ digest of the input arrays into the key means changing an upstream knob that
453
+ alters the cost field — but not α/γ — invalidates the cache instead of
454
+ silently returning a stale segmentation.
455
+ """
456
+ h = hashlib.sha1()
457
+ h.update(f"{shape[0]}x{shape[1]}x{shape[2]}_a{alpha_unary:g}_g{gamma_unary:g}".encode())
458
+ for arr in content:
459
+ if arr is None:
460
+ h.update(b"\x00none")
461
+ continue
462
+ arr = np.ascontiguousarray(arr)
463
+ h.update(f"{arr.dtype}{arr.shape}".encode())
464
+ h.update(arr.tobytes())
465
+ return f"unary_{h.hexdigest()[:12]}"
466
+
467
+
468
+ def _unary_cache_path(cache_dir: Path, key: str) -> Path:
469
+ return cache_dir / f"{key}.h5"
470
+
471
+
472
+ def _read_unary_cache(
473
+ cache_dir: Path,
474
+ key: str,
475
+ ) -> dict[tuple[int, int], np.ndarray] | None:
476
+ path = _unary_cache_path(cache_dir, key)
477
+ if not path.exists():
478
+ return None
479
+ try:
480
+ unary: dict[tuple[int, int], np.ndarray] = {}
481
+ with h5py.File(path, "r") as f:
482
+ grp = f["unaries"]
483
+ for name in grp:
484
+ t_s, k_s = name.split("_", 1)
485
+ unary[(int(t_s), int(k_s))] = grp[name][...].astype(
486
+ np.float32, copy=False
487
+ )
488
+ return unary
489
+ except Exception:
490
+ # The file exists but could not be read — corrupt/partial cache, not a
491
+ # cold miss. Warn (don't raise) so it's visibly distinct, then recompute.
492
+ logger.warning(
493
+ "Discarding unreadable unary cache %s; recomputing.", path, exc_info=True
494
+ )
495
+ return None
496
+
497
+
498
+ def _write_unary_cache(
499
+ cache_dir: Path,
500
+ key: str,
501
+ unary: dict[tuple[int, int], np.ndarray],
502
+ ) -> None:
503
+ cache_dir.mkdir(parents=True, exist_ok=True)
504
+ path = _unary_cache_path(cache_dir, key)
505
+ tmp = path.with_suffix(path.suffix + ".tmp")
506
+ try:
507
+ with h5py.File(tmp, "w") as f:
508
+ grp = f.create_group("unaries")
509
+ grp.attrs["cache_key"] = key
510
+ for (t, k), arr in unary.items():
511
+ grp.create_dataset(
512
+ f"{int(t)}_{int(k)}",
513
+ data=np.asarray(arr, dtype=np.float32),
514
+ compression="lzf",
515
+ )
516
+ tmp.replace(path)
517
+ except Exception:
518
+ if tmp.exists():
519
+ tmp.unlink()
520
+ raise
521
+
522
+
523
+ # ══════════════════════════════════════════════════════════════════════════════
524
+ # Public API
525
+ # ══════════════════════════════════════════════════════════════════════════════
526
+
527
+ def initialize_icm(
528
+ nuc_tracks: np.ndarray,
529
+ fg_mask: np.ndarray,
530
+ contours: np.ndarray,
531
+ params: CellLabelICMParams,
532
+ *,
533
+ foreground_scores: np.ndarray | None = None,
534
+ cache_dir: Path | None = None,
535
+ progress_cb: Callable[[str], None] | None = None,
536
+ ) -> tuple[CellICMState, np.ndarray]:
537
+ """Compute geodesic unaries and build labels (per-pixel argmin).
538
+
539
+ Parameters
540
+ ----------
541
+ nuc_tracks : (T, Y, X) uint32
542
+ fg_mask : (T, Y, X) bool
543
+ contours : (T, Y, X) float32
544
+ params : CellLabelICMParams
545
+ foreground_scores : (T, Y, X) float32, optional
546
+ cache_dir : Path, optional
547
+ HDF5 unary cache directory.
548
+ progress_cb : callable, optional
549
+
550
+ Returns
551
+ -------
552
+ state : CellICMState
553
+ init_labels : (T, Y, X) uint32
554
+ """
555
+ _report = progress_cb or (lambda msg: None)
556
+
557
+ fg_mask = fg_mask | (nuc_tracks > 0)
558
+
559
+ label_ids = np.array(
560
+ sorted(int(k) for k in np.unique(nuc_tracks) if k > 0),
561
+ dtype=np.uint32,
562
+ )
563
+ T, Y, X = fg_mask.shape
564
+ _report(
565
+ f"Label set: {len(label_ids)} track IDs, "
566
+ f"shape {T}×{Y}×{X}, "
567
+ f"fg_voxels={int(np.count_nonzero(fg_mask))}"
568
+ )
569
+
570
+ # ── Geodesic unaries (expensive — cache + parallel) ───────────────
571
+ alpha_unary, gamma_unary = balance_strength_to_weights(
572
+ params.balance, params.feature_strength
573
+ )
574
+ cache_key = _unary_cache_key(
575
+ (T, Y, X), alpha_unary, gamma_unary,
576
+ nuc_tracks, fg_mask, contours, foreground_scores,
577
+ )
578
+ unary_dict: dict[tuple[int, int], np.ndarray] | None = None
579
+
580
+ if cache_dir is not None:
581
+ _report(f"Checking unary cache: {cache_key}")
582
+ unary_dict = _read_unary_cache(cache_dir, cache_key)
583
+ if unary_dict is not None:
584
+ _report(f"Cache hit: {len(unary_dict)} entries loaded.")
585
+
586
+ if unary_dict is None:
587
+ t0 = perf_counter()
588
+ unary_dict = _compute_geodesic_unaries(
589
+ nuc_tracks, fg_mask, contours, label_ids, alpha_unary,
590
+ foreground_scores=foreground_scores,
591
+ gamma_unary=gamma_unary,
592
+ n_workers=params.n_workers,
593
+ progress_cb=progress_cb,
594
+ )
595
+ elapsed = perf_counter() - t0
596
+ _report(f"Geodesic unaries: {len(unary_dict)} entries in {elapsed:.1f}s")
597
+ if cache_dir is not None:
598
+ _report("Writing unary cache...")
599
+ _write_unary_cache(cache_dir, cache_key, unary_dict)
600
+
601
+ _apply_nucleus_anchors(unary_dict, nuc_tracks, label_ids)
602
+
603
+ # ── Initial labels (streaming argmin; no dense (T, Y, X, K) volume) ──
604
+ _report("Initialising labels from unary argmin...")
605
+ init_labels = _argmin_init_from_dict(unary_dict, fg_mask, label_ids)
606
+ del unary_dict
607
+
608
+ nuc_mask = nuc_tracks > 0
609
+ init_labels[nuc_mask] = nuc_tracks[nuc_mask].astype(np.uint32)
610
+
611
+ state = CellICMState(
612
+ fg_mask=fg_mask.astype(bool, copy=False),
613
+ nuc_tracks=nuc_tracks.astype(np.uint32, copy=False),
614
+ label_ids=label_ids,
615
+ )
616
+
617
+ _report("Initialisation complete.")
618
+ return state, init_labels
619
+
620
+
621
+ def commit_labels(labels: np.ndarray, output_path: Path | str) -> None:
622
+ """Write label array to TIFF.
623
+
624
+ Labels are stored as ``uint16`` when they fit (compact, backward
625
+ compatible) and promoted to ``uint32`` otherwise. Casting a track id
626
+ above 65535 down to ``uint16`` would silently wrap and merge distinct
627
+ cells, so the dtype is chosen from the actual maximum label.
628
+ """
629
+ output_path = Path(output_path)
630
+ output_path.parent.mkdir(parents=True, exist_ok=True)
631
+ max_label = int(labels.max()) if labels.size else 0
632
+ out_dtype = np.uint16 if max_label <= np.iinfo(np.uint16).max else np.uint32
633
+ imwrite_grayscale(
634
+ output_path,
635
+ labels.astype(out_dtype, copy=False),
636
+ compression="zlib",
637
+ )