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