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.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|