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,380 @@
|
|
|
1
|
+
"""Divergence-based foreground & contour maps from Cellpose prob/dp outputs.
|
|
2
|
+
|
|
3
|
+
Replaces the (cellprob x z) mask sweep with a direct computation:
|
|
4
|
+
|
|
5
|
+
foreground = reduce_z(sigmoid(prob))
|
|
6
|
+
contours = reduce_z(clip(div(filter(dp)), 0, inf))
|
|
7
|
+
|
|
8
|
+
See ``notes/divergence_maps_spec.md`` for the rationale.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import tifffile
|
|
19
|
+
from scipy.ndimage import gaussian_filter, median_filter
|
|
20
|
+
|
|
21
|
+
from itasc.core.cancellation import CancelledError
|
|
22
|
+
from itasc.core.tiff import imwrite_grayscale
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_cancel(cancel: Callable[[], bool] | None) -> None:
|
|
26
|
+
if cancel is not None and cancel():
|
|
27
|
+
raise CancelledError("Operation cancelled.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ZReduction = Literal["mean", "max"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class DivergenceMapsReport:
|
|
35
|
+
"""Summary returned by :func:`build_divergence_maps`."""
|
|
36
|
+
|
|
37
|
+
frames: int
|
|
38
|
+
foreground_z_reduction: ZReduction
|
|
39
|
+
contour_z_reduction: ZReduction
|
|
40
|
+
smoothing_sigma: float
|
|
41
|
+
median_radius: int
|
|
42
|
+
contours_path: Path
|
|
43
|
+
foreground_path: Path
|
|
44
|
+
foreground_smoothing_sigma: float = 0.0
|
|
45
|
+
foreground_median_radius: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sigmoid(x: np.ndarray) -> np.ndarray:
|
|
49
|
+
"""Numerically stable sigmoid on float32 logits."""
|
|
50
|
+
x = np.clip(np.asarray(x, dtype=np.float32), -88.0, 88.0)
|
|
51
|
+
return (1.0 / (1.0 + np.exp(-x))).astype(np.float32, copy=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _reduce_z(arr_tzyx: np.ndarray, reduction: ZReduction) -> np.ndarray:
|
|
55
|
+
if reduction == "mean":
|
|
56
|
+
return arr_tzyx.mean(axis=1, dtype=np.float32).astype(np.float32, copy=False)
|
|
57
|
+
if reduction == "max":
|
|
58
|
+
return arr_tzyx.max(axis=1).astype(np.float32, copy=False)
|
|
59
|
+
raise ValueError(f"reduction must be 'mean' or 'max', got {reduction!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _filter_2d(
|
|
63
|
+
arr_yx: np.ndarray, *, smoothing_sigma: float, median_radius: int,
|
|
64
|
+
) -> np.ndarray:
|
|
65
|
+
"""Apply median -> gaussian to a single ``(Y, X)`` map. Order matters (spec)."""
|
|
66
|
+
out = np.asarray(arr_yx, dtype=np.float32)
|
|
67
|
+
if median_radius > 0:
|
|
68
|
+
out = median_filter(out, size=2 * int(median_radius) + 1)
|
|
69
|
+
if smoothing_sigma > 0.0:
|
|
70
|
+
out = gaussian_filter(out, sigma=float(smoothing_sigma))
|
|
71
|
+
return out.astype(np.float32, copy=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def foreground_from_prob(
|
|
75
|
+
prob_tzyx: np.ndarray,
|
|
76
|
+
*,
|
|
77
|
+
reduction: ZReduction,
|
|
78
|
+
smoothing_sigma: float = 0.0,
|
|
79
|
+
median_radius: int = 0,
|
|
80
|
+
) -> np.ndarray:
|
|
81
|
+
"""``sigmoid(prob)`` reduced across z, optionally smoothed.
|
|
82
|
+
|
|
83
|
+
Returns ``(T, Y, X)`` float32 in [0, 1]. When ``smoothing_sigma`` or
|
|
84
|
+
``median_radius`` is set, each reduced frame is median- then gaussian-
|
|
85
|
+
filtered (defaults of ``0`` leave the foreground untouched).
|
|
86
|
+
"""
|
|
87
|
+
p = sigmoid(prob_tzyx)
|
|
88
|
+
fg = _reduce_z(p, reduction)
|
|
89
|
+
if smoothing_sigma > 0.0 or median_radius > 0:
|
|
90
|
+
fg = np.stack(
|
|
91
|
+
[
|
|
92
|
+
_filter_2d(
|
|
93
|
+
fg[t], smoothing_sigma=smoothing_sigma, median_radius=median_radius,
|
|
94
|
+
)
|
|
95
|
+
for t in range(fg.shape[0])
|
|
96
|
+
]
|
|
97
|
+
)
|
|
98
|
+
return fg
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def divergence_2d(flow_yx: np.ndarray) -> np.ndarray:
|
|
102
|
+
"""Divergence of a ``(2, Y, X)`` flow field with channels ``[dy, dx]``."""
|
|
103
|
+
flow_yx = np.asarray(flow_yx, dtype=np.float32)
|
|
104
|
+
if flow_yx.ndim != 3 or flow_yx.shape[0] != 2:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"flow must be (2, Y, X) with channels [dy, dx]; got {flow_yx.shape}"
|
|
107
|
+
)
|
|
108
|
+
# np.gradient needs >= 2 samples along an axis; a singleton Y or X (e.g. a
|
|
109
|
+
# 1-pixel crop) has zero divergence contribution there, so skip it.
|
|
110
|
+
d_dy = (
|
|
111
|
+
np.gradient(flow_yx[0], axis=0)
|
|
112
|
+
if flow_yx.shape[1] >= 2
|
|
113
|
+
else np.zeros_like(flow_yx[0])
|
|
114
|
+
)
|
|
115
|
+
d_dx = (
|
|
116
|
+
np.gradient(flow_yx[1], axis=1)
|
|
117
|
+
if flow_yx.shape[2] >= 2
|
|
118
|
+
else np.zeros_like(flow_yx[1])
|
|
119
|
+
)
|
|
120
|
+
return (d_dy + d_dx).astype(np.float32, copy=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _filter_flow(
|
|
124
|
+
flow_2yx: np.ndarray, *, smoothing_sigma: float, median_radius: int,
|
|
125
|
+
) -> np.ndarray:
|
|
126
|
+
"""Apply median -> gaussian per channel. Order matters (spec)."""
|
|
127
|
+
out = flow_2yx
|
|
128
|
+
if median_radius > 0:
|
|
129
|
+
size = 2 * int(median_radius) + 1
|
|
130
|
+
out = np.stack(
|
|
131
|
+
[median_filter(out[0], size=size), median_filter(out[1], size=size)],
|
|
132
|
+
axis=0,
|
|
133
|
+
)
|
|
134
|
+
if smoothing_sigma > 0.0:
|
|
135
|
+
sigma = float(smoothing_sigma)
|
|
136
|
+
out = np.stack(
|
|
137
|
+
[gaussian_filter(out[0], sigma=sigma), gaussian_filter(out[1], sigma=sigma)],
|
|
138
|
+
axis=0,
|
|
139
|
+
)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def contour_from_dp(
|
|
144
|
+
dp_tzcyx: np.ndarray,
|
|
145
|
+
*,
|
|
146
|
+
smoothing_sigma: float,
|
|
147
|
+
median_radius: int,
|
|
148
|
+
reduction: ZReduction,
|
|
149
|
+
) -> np.ndarray:
|
|
150
|
+
"""Per (t, z): filter -> divergence -> clip(>=0); then reduce across z.
|
|
151
|
+
|
|
152
|
+
``dp_tzcyx``: shape ``(T, Z, 2, Y, X)`` with channels ``[dy, dx]``.
|
|
153
|
+
Returns ``(T, Y, X)`` float32.
|
|
154
|
+
"""
|
|
155
|
+
arr = np.asarray(dp_tzcyx, dtype=np.float32)
|
|
156
|
+
if arr.ndim != 5 or arr.shape[2] != 2:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"dp must be (T, Z, 2, Y, X) with channels [dy, dx]; got {arr.shape}"
|
|
159
|
+
)
|
|
160
|
+
n_t, n_z, _, n_y, n_x = arr.shape
|
|
161
|
+
pos = np.empty((n_t, n_z, n_y, n_x), dtype=np.float32)
|
|
162
|
+
for t in range(n_t):
|
|
163
|
+
pos[t] = _positive_divergence_z_stack(
|
|
164
|
+
arr[t],
|
|
165
|
+
smoothing_sigma=smoothing_sigma,
|
|
166
|
+
median_radius=median_radius,
|
|
167
|
+
)
|
|
168
|
+
return _reduce_z(pos, reduction)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _positive_divergence_z_stack(
|
|
172
|
+
dp_zcyx: np.ndarray,
|
|
173
|
+
*,
|
|
174
|
+
smoothing_sigma: float,
|
|
175
|
+
median_radius: int,
|
|
176
|
+
progress_cb: Callable[[int, int], None] | None = None,
|
|
177
|
+
) -> np.ndarray:
|
|
178
|
+
n_z, _, n_y, n_x = dp_zcyx.shape
|
|
179
|
+
pos = np.empty((n_z, n_y, n_x), dtype=np.float32)
|
|
180
|
+
for z in range(n_z):
|
|
181
|
+
filt = _filter_flow(
|
|
182
|
+
dp_zcyx[z], smoothing_sigma=smoothing_sigma, median_radius=median_radius,
|
|
183
|
+
)
|
|
184
|
+
div = divergence_2d(filt)
|
|
185
|
+
np.clip(div, 0.0, None, out=div)
|
|
186
|
+
pos[z] = div
|
|
187
|
+
if progress_cb is not None:
|
|
188
|
+
progress_cb(z + 1, n_z)
|
|
189
|
+
return pos
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _LazyTiffStack:
|
|
193
|
+
"""Frame-at-a-time reader for a (T,Z,Y,X) / (T,Z,2,Y,X) TIFF stack.
|
|
194
|
+
|
|
195
|
+
Reads only the TIFF header on construction (so callers can validate shapes
|
|
196
|
+
without paying for a full decompress) and decodes one frame's pages per
|
|
197
|
+
:meth:`frame` call. Compressed stacks store one page per 2D plane, so a
|
|
198
|
+
per-frame ``key`` slice touches only that frame's pages — this is what lets
|
|
199
|
+
:func:`build_divergence_maps` start reporting progress immediately instead
|
|
200
|
+
of stalling while both whole stacks are read up front.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, path: str | Path, *, ndim: int, name: str) -> None:
|
|
204
|
+
self._path = Path(path)
|
|
205
|
+
self._name = name
|
|
206
|
+
with tifffile.TiffFile(str(self._path)) as tf:
|
|
207
|
+
series = tf.series[0]
|
|
208
|
+
disk_shape = tuple(int(x) for x in series.shape)
|
|
209
|
+
axes = str(series.axes)
|
|
210
|
+
shape = self._canonical_shape(disk_shape, axes, ndim, name)
|
|
211
|
+
self.shape = shape
|
|
212
|
+
self._frame_shape = shape[1:]
|
|
213
|
+
# Pages per frame = product of the non-(Y,X) per-frame axes (Z, or Z*2).
|
|
214
|
+
self._pages_per_frame = int(np.prod(shape[1:-2], dtype=int))
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _canonical_shape(
|
|
218
|
+
disk_shape: tuple[int, ...], axes: str, ndim: int, name: str,
|
|
219
|
+
) -> tuple[int, ...]:
|
|
220
|
+
"""Recover the logical ``(T,Z,Y,X)`` / ``(T,Z,2,Y,X)`` shape.
|
|
221
|
+
|
|
222
|
+
TIFF drops singleton leading axes on write, so a 2D+t prob ``(T,1,Y,X)``
|
|
223
|
+
lands on disk as ``(T,Y,X)`` — indistinguishable from a single z-stack by
|
|
224
|
+
shape alone. When the writer recorded axis labels (our ``write_outputs``),
|
|
225
|
+
map the surviving letters back to canonical order, inserting size-1 for
|
|
226
|
+
any dropped ``T``/``Z``/``C``. Otherwise fall back to the length heuristic
|
|
227
|
+
for legacy/metadata-less files (which are never singleton-squeezed).
|
|
228
|
+
"""
|
|
229
|
+
canonical = "TZYX" if ndim == 4 else "TZCYX"
|
|
230
|
+
label = "Z×Y×X or T×Z×Y×X" if ndim == 4 else "Z×2×Y×X or T×Z×2×Y×X"
|
|
231
|
+
if len(axes) == len(disk_shape) and set("YX") <= set(axes) <= set(canonical):
|
|
232
|
+
present = dict(zip(axes, disk_shape))
|
|
233
|
+
shape = tuple(present.get(a, 1) for a in canonical)
|
|
234
|
+
else:
|
|
235
|
+
shape = disk_shape
|
|
236
|
+
# A frameless 3D/4D stack is a single timepoint: prepend T=1.
|
|
237
|
+
if len(shape) == ndim - 1:
|
|
238
|
+
shape = (1,) + shape
|
|
239
|
+
if len(shape) != ndim:
|
|
240
|
+
raise ValueError(f"{name} must be {label}.")
|
|
241
|
+
if ndim == 5 and shape[2] != 2:
|
|
242
|
+
if shape[1] == 3:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"{name} has shape {shape}, which is Cellpose 3D flows "
|
|
245
|
+
f"(T×3×Z×Y×X from do_3d=True). The divergence builder needs "
|
|
246
|
+
f"per-z 2-component flows (T×Z×2×Y×X); re-run nucleus Cellpose "
|
|
247
|
+
f"with do_3d=False for the divergence pipeline."
|
|
248
|
+
)
|
|
249
|
+
raise ValueError(f"{name} must be Z×2×Y×X or T×Z×2×Y×X.")
|
|
250
|
+
return shape
|
|
251
|
+
|
|
252
|
+
def frame(self, t: int) -> np.ndarray:
|
|
253
|
+
"""Frame ``t`` as float32, shaped ``(Z,Y,X)`` or ``(Z,2,Y,X)``."""
|
|
254
|
+
start = t * self._pages_per_frame
|
|
255
|
+
arr = tifffile.imread(
|
|
256
|
+
str(self._path), key=slice(start, start + self._pages_per_frame),
|
|
257
|
+
)
|
|
258
|
+
return np.asarray(arr, dtype=np.float32).reshape(self._frame_shape)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def build_divergence_maps(
|
|
262
|
+
prob_path: str | Path,
|
|
263
|
+
dp_path: str | Path,
|
|
264
|
+
contours_out: str | Path,
|
|
265
|
+
foreground_out: str | Path,
|
|
266
|
+
*,
|
|
267
|
+
foreground_z_reduction: ZReduction,
|
|
268
|
+
contour_z_reduction: ZReduction,
|
|
269
|
+
smoothing_sigma: float,
|
|
270
|
+
median_radius: int,
|
|
271
|
+
foreground_smoothing_sigma: float = 0.0,
|
|
272
|
+
foreground_median_radius: int = 0,
|
|
273
|
+
progress_cb: Callable[[int, int, str], None] | None = None,
|
|
274
|
+
cancel: Callable[[], bool] | None = None,
|
|
275
|
+
) -> DivergenceMapsReport:
|
|
276
|
+
"""Compute and write ``contours`` and ``foreground`` from Cellpose prob/dp.
|
|
277
|
+
|
|
278
|
+
Output stacks are ``T x Y x X`` float32. ``progress_cb`` is called for each
|
|
279
|
+
foreground frame, contour z-slice, and output write. ``smoothing_sigma`` /
|
|
280
|
+
``median_radius`` filter the flow before divergence (contours); the
|
|
281
|
+
``foreground_*`` knobs filter the reduced foreground map independently.
|
|
282
|
+
"""
|
|
283
|
+
prob_stack = _LazyTiffStack(prob_path, ndim=4, name="prob")
|
|
284
|
+
dp_stack = _LazyTiffStack(dp_path, ndim=5, name="dp")
|
|
285
|
+
if prob_stack.shape[0] != dp_stack.shape[0]:
|
|
286
|
+
raise ValueError("prob and dp must have the same frame count.")
|
|
287
|
+
if prob_stack.shape[1] != dp_stack.shape[1]:
|
|
288
|
+
raise ValueError("prob and dp must have the same z count.")
|
|
289
|
+
if prob_stack.shape[2:] != dp_stack.shape[3:]:
|
|
290
|
+
raise ValueError("prob and dp must have the same Y×X shape.")
|
|
291
|
+
|
|
292
|
+
n_t = int(prob_stack.shape[0])
|
|
293
|
+
n_z = int(prob_stack.shape[1])
|
|
294
|
+
if n_t == 0:
|
|
295
|
+
raise ValueError("prob/dp stack has no frames (T=0); nothing to compute.")
|
|
296
|
+
total_steps = n_t + (n_t * n_z) + 2
|
|
297
|
+
progress_done = 0
|
|
298
|
+
contour_frames: list[np.ndarray] = []
|
|
299
|
+
foreground_frames: list[np.ndarray] = []
|
|
300
|
+
for t in range(n_t):
|
|
301
|
+
_check_cancel(cancel)
|
|
302
|
+
fg = foreground_from_prob(
|
|
303
|
+
prob_stack.frame(t)[np.newaxis],
|
|
304
|
+
reduction=foreground_z_reduction,
|
|
305
|
+
smoothing_sigma=foreground_smoothing_sigma,
|
|
306
|
+
median_radius=foreground_median_radius,
|
|
307
|
+
)
|
|
308
|
+
foreground_frames.append(fg[0])
|
|
309
|
+
progress_done += 1
|
|
310
|
+
if progress_cb is not None:
|
|
311
|
+
progress_cb(
|
|
312
|
+
progress_done,
|
|
313
|
+
total_steps,
|
|
314
|
+
f"Divergence maps: foreground frame {t + 1}/{n_t}",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _progress_z(z_done: int, z_total: int) -> None:
|
|
318
|
+
nonlocal progress_done
|
|
319
|
+
|
|
320
|
+
_check_cancel(cancel)
|
|
321
|
+
progress_done += 1
|
|
322
|
+
if progress_cb is not None:
|
|
323
|
+
progress_cb(
|
|
324
|
+
progress_done,
|
|
325
|
+
total_steps,
|
|
326
|
+
(
|
|
327
|
+
f"Divergence maps: contours frame {t + 1}/{n_t} "
|
|
328
|
+
f"z {z_done}/{z_total}"
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
pos_div = _positive_divergence_z_stack(
|
|
333
|
+
dp_stack.frame(t),
|
|
334
|
+
smoothing_sigma=smoothing_sigma,
|
|
335
|
+
median_radius=median_radius,
|
|
336
|
+
progress_cb=_progress_z,
|
|
337
|
+
)
|
|
338
|
+
contour_frames.append(
|
|
339
|
+
_reduce_z(pos_div[np.newaxis, ...], contour_z_reduction)[0]
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
_check_cancel(cancel)
|
|
343
|
+
contours_out = Path(contours_out)
|
|
344
|
+
foreground_out = Path(foreground_out)
|
|
345
|
+
contours_out.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
foreground_out.parent.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
imwrite_grayscale(
|
|
348
|
+
contours_out, np.stack(contour_frames).astype(np.float32),
|
|
349
|
+
compression="zlib", metadata={"axes": "TYX"},
|
|
350
|
+
)
|
|
351
|
+
progress_done += 1
|
|
352
|
+
if progress_cb is not None:
|
|
353
|
+
progress_cb(progress_done, total_steps, "Divergence maps: writing contours")
|
|
354
|
+
imwrite_grayscale(
|
|
355
|
+
foreground_out, np.stack(foreground_frames).astype(np.float32),
|
|
356
|
+
compression="zlib", metadata={"axes": "TYX"},
|
|
357
|
+
)
|
|
358
|
+
progress_done += 1
|
|
359
|
+
if progress_cb is not None:
|
|
360
|
+
progress_cb(progress_done, total_steps, "Divergence maps: writing foreground")
|
|
361
|
+
return DivergenceMapsReport(
|
|
362
|
+
frames=n_t,
|
|
363
|
+
foreground_z_reduction=foreground_z_reduction,
|
|
364
|
+
contour_z_reduction=contour_z_reduction,
|
|
365
|
+
smoothing_sigma=float(smoothing_sigma),
|
|
366
|
+
median_radius=int(median_radius),
|
|
367
|
+
contours_path=contours_out,
|
|
368
|
+
foreground_path=foreground_out,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
__all__ = [
|
|
373
|
+
"CancelledError",
|
|
374
|
+
"DivergenceMapsReport",
|
|
375
|
+
"build_divergence_maps",
|
|
376
|
+
"contour_from_dp",
|
|
377
|
+
"divergence_2d",
|
|
378
|
+
"foreground_from_prob",
|
|
379
|
+
"sigmoid",
|
|
380
|
+
]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Flow-following cell segmentation, Qt-free, for the standalone distro.
|
|
2
|
+
|
|
3
|
+
When the standalone tool has **both** a nucleus and a cell channel, it can do
|
|
4
|
+
better than independent native masks: it assigns each cell-foreground pixel to a
|
|
5
|
+
*nucleus*, giving one cell per nucleus with the cell sharing the nucleus' id.
|
|
6
|
+
The cell's *extent* is decided by Cellpose's own flow field rather than by naive
|
|
7
|
+
distance.
|
|
8
|
+
|
|
9
|
+
This is the proven two-phase algorithm (recovered from the pre-publication
|
|
10
|
+
``segmentation/flow_following.py``):
|
|
11
|
+
|
|
12
|
+
1. **Integrate.** Each foreground pixel is Euler-integrated along a blend of the
|
|
13
|
+
Cellpose flow vector and an EDT *gravity* vector pointing toward the nearest
|
|
14
|
+
nucleus (``flow_weight`` mixes the two). A pixel that lands on a nucleus pixel
|
|
15
|
+
during integration inherits that nucleus' label immediately.
|
|
16
|
+
2. **Grow.** Remaining foreground pixels are assigned by growing the labelled
|
|
17
|
+
region outward in shells through the *displaced* (post-integration) positions,
|
|
18
|
+
so labels chain-propagate along the flow topology. Pixels whose displaced
|
|
19
|
+
position has no labelled neighbour within ``max_assign_radius`` stay
|
|
20
|
+
background (orphans are dropped, not force-assigned).
|
|
21
|
+
|
|
22
|
+
The numba kernel is lazily JIT-compiled when numba is importable (it ships with
|
|
23
|
+
the Cellpose stack) and falls back to the identical pure-Python function
|
|
24
|
+
otherwise, so importing this module never requires numba.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from scipy.ndimage import distance_transform_edt
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"FlowFollowingParams",
|
|
35
|
+
"flow_follow_frame",
|
|
36
|
+
"flow_follow_movie",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class FlowFollowingParams:
|
|
42
|
+
"""Parameters for flow-following cell assignment."""
|
|
43
|
+
|
|
44
|
+
fg_threshold: float = 0.5
|
|
45
|
+
"""Cell-foreground cutoff on the sigmoid probability (``sigmoid(prob) >
|
|
46
|
+
fg_threshold``). Exposed knob — pixels below it are not assigned."""
|
|
47
|
+
|
|
48
|
+
flow_weight: float = 0.5
|
|
49
|
+
"""Blend between the Cellpose flow vector (``1``) and the EDT gravity vector
|
|
50
|
+
toward the nearest nucleus (``0``). ``0.5`` mixes both equally."""
|
|
51
|
+
|
|
52
|
+
flow_step_scale: float = 0.2
|
|
53
|
+
"""Euler step size (multiplies the unit blended vector each iteration)."""
|
|
54
|
+
|
|
55
|
+
max_iterations: int = 100
|
|
56
|
+
"""Maximum integration steps per pixel."""
|
|
57
|
+
|
|
58
|
+
shell_width: float = 5.0
|
|
59
|
+
"""Per-iteration capture distance when growing labels in shells (px)."""
|
|
60
|
+
|
|
61
|
+
max_assign_radius: float = 30.0
|
|
62
|
+
"""A displaced foreground pixel with no labelled pixel within this radius
|
|
63
|
+
stays background — orphan foreground is dropped, not force-assigned."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Phase 1: Euler integration (lazy-JIT kernel + pure-Python twin) ──────────
|
|
67
|
+
|
|
68
|
+
def _integrate_positions_py(
|
|
69
|
+
nuclear_labels, # (H, W) int32
|
|
70
|
+
flow, # (2, H, W) float32 — flow[0]=dy, flow[1]=dx
|
|
71
|
+
grav_y, # (H, W) float32
|
|
72
|
+
grav_x, # (H, W) float32
|
|
73
|
+
prob_mask, # (H, W) bool
|
|
74
|
+
n_steps, # int
|
|
75
|
+
flow_step_scale, # float
|
|
76
|
+
flow_weight, # float
|
|
77
|
+
):
|
|
78
|
+
"""Integrate each foreground pixel along ``flow_weight*flow + (1-w)*gravity``.
|
|
79
|
+
|
|
80
|
+
Returns ``(result, final_y, final_x)``: a label map where nucleus pixels keep
|
|
81
|
+
their label and foreground pixels that land directly on a nucleus inherit it,
|
|
82
|
+
plus the displaced position of every pixel after integration.
|
|
83
|
+
"""
|
|
84
|
+
H = nuclear_labels.shape[0]
|
|
85
|
+
W = nuclear_labels.shape[1]
|
|
86
|
+
result = nuclear_labels.copy()
|
|
87
|
+
final_y = np.empty((H, W), dtype=np.float32)
|
|
88
|
+
final_x = np.empty((H, W), dtype=np.float32)
|
|
89
|
+
|
|
90
|
+
for i in range(H):
|
|
91
|
+
for j in range(W):
|
|
92
|
+
final_y[i, j] = np.float32(i)
|
|
93
|
+
final_x[i, j] = np.float32(j)
|
|
94
|
+
|
|
95
|
+
if nuclear_labels[i, j] > 0 or not prob_mask[i, j]:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
py = np.float32(i)
|
|
99
|
+
px = np.float32(j)
|
|
100
|
+
for _ in range(n_steps):
|
|
101
|
+
iy = min(max(int(py), 0), H - 1)
|
|
102
|
+
ix = min(max(int(px), 0), W - 1)
|
|
103
|
+
fy = (
|
|
104
|
+
flow_weight * flow[0, iy, ix]
|
|
105
|
+
+ (np.float32(1.0) - flow_weight) * grav_y[iy, ix]
|
|
106
|
+
)
|
|
107
|
+
fx = (
|
|
108
|
+
flow_weight * flow[1, iy, ix]
|
|
109
|
+
+ (np.float32(1.0) - flow_weight) * grav_x[iy, ix]
|
|
110
|
+
)
|
|
111
|
+
py = py + fy * flow_step_scale
|
|
112
|
+
px = px + fx * flow_step_scale
|
|
113
|
+
py = min(max(py, np.float32(0.0)), np.float32(H - 1))
|
|
114
|
+
px = min(max(px, np.float32(0.0)), np.float32(W - 1))
|
|
115
|
+
if nuclear_labels[int(py), int(px)] > 0:
|
|
116
|
+
result[i, j] = nuclear_labels[int(py), int(px)]
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
final_y[i, j] = py
|
|
120
|
+
final_x[i, j] = px
|
|
121
|
+
|
|
122
|
+
return result, final_y, final_x
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _integrate_positions(*args):
|
|
126
|
+
"""Dispatch to the numba-compiled kernel when available, else pure Python."""
|
|
127
|
+
global _INTEGRATE
|
|
128
|
+
if _INTEGRATE is None:
|
|
129
|
+
try: # numba ships with the cellpose stack; optional otherwise.
|
|
130
|
+
import numba
|
|
131
|
+
|
|
132
|
+
_INTEGRATE = numba.njit(cache=True)(_integrate_positions_py)
|
|
133
|
+
except Exception: # pragma: no cover - exercised only without numba
|
|
134
|
+
_INTEGRATE = _integrate_positions_py
|
|
135
|
+
return _INTEGRATE(*args)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_INTEGRATE = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Phase 2: progressive shell growth (orphan-bounded) ───────────────────────
|
|
142
|
+
|
|
143
|
+
def _progressive_shell_assign(
|
|
144
|
+
labels: np.ndarray,
|
|
145
|
+
final_y: np.ndarray,
|
|
146
|
+
final_x: np.ndarray,
|
|
147
|
+
foreground: np.ndarray,
|
|
148
|
+
*,
|
|
149
|
+
shell_width: float,
|
|
150
|
+
max_assign_radius: float,
|
|
151
|
+
) -> np.ndarray:
|
|
152
|
+
"""Grow labels outward through displaced positions; drop distant orphans.
|
|
153
|
+
|
|
154
|
+
Each iteration computes the EDT from currently-labelled pixels and assigns any
|
|
155
|
+
unassigned foreground pixel whose *displaced* position is within
|
|
156
|
+
``shell_width`` of a labelled pixel. Newly labelled pixels seed the next EDT,
|
|
157
|
+
so labels chain-propagate along the flow topology. The growth stops once no
|
|
158
|
+
displaced position is within ``max_assign_radius`` of a label — remaining
|
|
159
|
+
pixels stay background.
|
|
160
|
+
"""
|
|
161
|
+
H, W = labels.shape
|
|
162
|
+
result = labels.copy()
|
|
163
|
+
unassigned = foreground & (result == 0)
|
|
164
|
+
|
|
165
|
+
fy = np.clip(np.round(final_y).astype(np.intp), 0, H - 1)
|
|
166
|
+
fx = np.clip(np.round(final_x).astype(np.intp), 0, W - 1)
|
|
167
|
+
|
|
168
|
+
while unassigned.any():
|
|
169
|
+
unlabelled = result == 0
|
|
170
|
+
if not unlabelled.any():
|
|
171
|
+
break
|
|
172
|
+
dist, indices = distance_transform_edt(unlabelled, return_indices=True)
|
|
173
|
+
d = dist[fy, fx]
|
|
174
|
+
nearest = result[indices[0][fy, fx], indices[1][fy, fx]]
|
|
175
|
+
can_assign = unassigned & (d <= shell_width) & (nearest > 0)
|
|
176
|
+
if not can_assign.any():
|
|
177
|
+
break
|
|
178
|
+
result[can_assign] = nearest[can_assign]
|
|
179
|
+
unassigned &= ~can_assign
|
|
180
|
+
|
|
181
|
+
# Bounded fallback (orphan drop): assign any remaining pixel only if a label
|
|
182
|
+
# sits within max_assign_radius of its displaced position; else leave it 0.
|
|
183
|
+
if unassigned.any() and (result > 0).any():
|
|
184
|
+
unlabelled = result == 0
|
|
185
|
+
if unlabelled.any():
|
|
186
|
+
dist, indices = distance_transform_edt(unlabelled, return_indices=True)
|
|
187
|
+
d = dist[fy, fx]
|
|
188
|
+
nearest = result[indices[0][fy, fx], indices[1][fy, fx]]
|
|
189
|
+
close = unassigned & (d <= max_assign_radius) & (nearest > 0)
|
|
190
|
+
result[close] = nearest[close]
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── Gravity field ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def _gravity_toward_nuclei(labels_yx: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
198
|
+
"""Unit vectors at every pixel pointing toward the nearest nucleus pixel."""
|
|
199
|
+
nucleus = labels_yx > 0
|
|
200
|
+
_dist, indices = distance_transform_edt(~nucleus, return_indices=True)
|
|
201
|
+
H, W = labels_yx.shape
|
|
202
|
+
yy, xx = np.mgrid[:H, :W]
|
|
203
|
+
dy = (indices[0] - yy).astype(np.float32)
|
|
204
|
+
dx = (indices[1] - xx).astype(np.float32)
|
|
205
|
+
norm = np.hypot(dy, dx).astype(np.float32)
|
|
206
|
+
norm[norm == 0.0] = 1.0
|
|
207
|
+
return (dy / norm).astype(np.float32), (dx / norm).astype(np.float32)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── Public: single frame + movie ─────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def flow_follow_frame(
|
|
213
|
+
foreground_yx: np.ndarray,
|
|
214
|
+
dp_cyx: np.ndarray,
|
|
215
|
+
labels_yx: np.ndarray,
|
|
216
|
+
params: FlowFollowingParams,
|
|
217
|
+
) -> np.ndarray:
|
|
218
|
+
"""Assign one frame's cell foreground to nuclei via flow-following.
|
|
219
|
+
|
|
220
|
+
``foreground_yx`` is a bool mask, ``dp_cyx`` the Cellpose cell flow
|
|
221
|
+
``(2, Y, X)`` (``dy, dx``), ``labels_yx`` the tracked nucleus labels. Returns
|
|
222
|
+
``(Y, X)`` int32 cell labels carrying the nucleus ids (background ``0``).
|
|
223
|
+
"""
|
|
224
|
+
foreground_yx = np.ascontiguousarray(foreground_yx, dtype=np.bool_)
|
|
225
|
+
labels_yx = np.ascontiguousarray(labels_yx, dtype=np.int32)
|
|
226
|
+
if not foreground_yx.any() or labels_yx.max() == 0:
|
|
227
|
+
return np.zeros(foreground_yx.shape, dtype=np.int32)
|
|
228
|
+
|
|
229
|
+
# Normalise flow by mean foreground magnitude so the step scale is comparable
|
|
230
|
+
# across frames/datasets.
|
|
231
|
+
flow = np.ascontiguousarray(dp_cyx, dtype=np.float32).copy()
|
|
232
|
+
mag = np.sqrt(flow[0] ** 2 + flow[1] ** 2)
|
|
233
|
+
mean_mag = float(mag[foreground_yx].mean()) if foreground_yx.any() else 1.0
|
|
234
|
+
if mean_mag > 0:
|
|
235
|
+
flow /= np.float32(mean_mag)
|
|
236
|
+
|
|
237
|
+
grav_y, grav_x = _gravity_toward_nuclei(labels_yx)
|
|
238
|
+
result, final_y, final_x = _integrate_positions(
|
|
239
|
+
labels_yx,
|
|
240
|
+
flow,
|
|
241
|
+
np.ascontiguousarray(grav_y),
|
|
242
|
+
np.ascontiguousarray(grav_x),
|
|
243
|
+
foreground_yx,
|
|
244
|
+
int(params.max_iterations),
|
|
245
|
+
np.float32(params.flow_step_scale),
|
|
246
|
+
np.float32(params.flow_weight),
|
|
247
|
+
)
|
|
248
|
+
return _progressive_shell_assign(
|
|
249
|
+
result, final_y, final_x, foreground_yx,
|
|
250
|
+
shell_width=float(params.shell_width),
|
|
251
|
+
max_assign_radius=float(params.max_assign_radius),
|
|
252
|
+
).astype(np.int32)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def flow_follow_movie(
|
|
256
|
+
foreground_tyx: np.ndarray,
|
|
257
|
+
dp_tcyx: np.ndarray,
|
|
258
|
+
labels_tyx: np.ndarray,
|
|
259
|
+
params: FlowFollowingParams,
|
|
260
|
+
*,
|
|
261
|
+
progress_cb=None,
|
|
262
|
+
) -> np.ndarray:
|
|
263
|
+
"""Run :func:`flow_follow_frame` over a ``(T, Y, X)`` series.
|
|
264
|
+
|
|
265
|
+
``dp_tcyx`` is ``(T, 2, Y, X)``. Cell labels inherit the (already tracked)
|
|
266
|
+
nucleus ids per frame, so tracking the nuclei tracks the cells. Returns
|
|
267
|
+
``(T, Y, X)`` int32.
|
|
268
|
+
"""
|
|
269
|
+
foreground = np.asarray(foreground_tyx, dtype=bool)
|
|
270
|
+
labels = np.asarray(labels_tyx, dtype=np.int32)
|
|
271
|
+
flow = np.asarray(dp_tcyx, dtype=np.float32)
|
|
272
|
+
if foreground.ndim != 3 or labels.shape != foreground.shape:
|
|
273
|
+
raise ValueError("foreground and labels must share shape (T, Y, X)")
|
|
274
|
+
T, Y, X = foreground.shape
|
|
275
|
+
if flow.shape != (T, 2, Y, X):
|
|
276
|
+
raise ValueError(f"dp_tcyx must be (T, 2, Y, X); got {flow.shape}")
|
|
277
|
+
|
|
278
|
+
out = np.zeros((T, Y, X), dtype=np.int32)
|
|
279
|
+
for t in range(T):
|
|
280
|
+
out[t] = flow_follow_frame(foreground[t], flow[t], labels[t], params)
|
|
281
|
+
if progress_cb is not None:
|
|
282
|
+
progress_cb(t + 1, T, f"Flow-following: frame {t + 1}/{T}...")
|
|
283
|
+
return out
|