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/shape.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Layout-free input canonicalisation for the standalone tool.
|
|
2
|
+
|
|
3
|
+
The standalone Cellpose tool supports neither stitching-as-a-mode nor true 3D
|
|
4
|
+
segmentation, so it never needs the 4-way ``2D/2D+t/3D/3D+t`` layout: it segments
|
|
5
|
+
every plane individually. The only place axis *identity* matters is tracking,
|
|
6
|
+
which links the **z** axis by spatial overlap (stitch) and the **t** axis by
|
|
7
|
+
motion (laptrack). By convention the shorter of the two leading axes is ``z``
|
|
8
|
+
(few z-slices, many timepoints is the common case) and the longer is ``t``.
|
|
9
|
+
|
|
10
|
+
:func:`to_canonical_tzyx` applies that convention so the rest of the pipeline can
|
|
11
|
+
keep working in canonical ``(T, Z, Y, X)`` without the user declaring a layout.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
__all__ = ["to_canonical_tzyx", "describe_axes"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_canonical_tzyx(arr: np.ndarray) -> np.ndarray:
|
|
21
|
+
"""Coerce any 2-D..4-D input to canonical ``(T, Z, Y, X)`` without a layout.
|
|
22
|
+
|
|
23
|
+
- 2-D ``(Y, X)`` → ``(1, 1, Y, X)``.
|
|
24
|
+
- 3-D → one leading axis, read as **time** (the common 2D+t case); ``Z`` is a
|
|
25
|
+
singleton: ``(T, 1, Y, X)``.
|
|
26
|
+
- 4-D → two leading axes; the **shorter** is ``Z`` and the longer is ``T``, so
|
|
27
|
+
the result is reordered to ``(T, Z, Y, X)`` (a no-op when already so).
|
|
28
|
+
"""
|
|
29
|
+
arr = np.asarray(arr)
|
|
30
|
+
if arr.ndim == 2:
|
|
31
|
+
return arr[np.newaxis, np.newaxis]
|
|
32
|
+
if arr.ndim == 3:
|
|
33
|
+
return arr[:, np.newaxis]
|
|
34
|
+
if arr.ndim == 4:
|
|
35
|
+
if arr.shape[0] < arr.shape[1]:
|
|
36
|
+
# leading axis 0 is the shorter one → it is Z; swap to (T, Z, Y, X).
|
|
37
|
+
arr = np.swapaxes(arr, 0, 1)
|
|
38
|
+
return arr
|
|
39
|
+
raise ValueError(f"expected a 2-D..4-D array, got shape {arr.shape}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def describe_axes(shape: tuple[int, ...]) -> str:
|
|
43
|
+
"""Human-readable summary of how an input shape is interpreted.
|
|
44
|
+
|
|
45
|
+
Used for widget status so the user can see the inferred ``T``/``Z`` without a
|
|
46
|
+
picker, e.g. ``"6×512×512 → T=6, Z=1"`` or ``"40×5×512×512 → T=40, Z=5"``.
|
|
47
|
+
"""
|
|
48
|
+
canon = to_canonical_tzyx(np.empty(shape, dtype=np.uint8))
|
|
49
|
+
T, Z = int(canon.shape[0]), int(canon.shape[1])
|
|
50
|
+
dims = "×".join(str(s) for s in shape)
|
|
51
|
+
return f"{dims} → T={T}, Z={Z}"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Link per-frame native masks across time with laptrack, Qt-free.
|
|
2
|
+
|
|
3
|
+
The standalone ``itasc-cellpose`` tool segments each frame independently
|
|
4
|
+
(:mod:`itasc.cellpose.native_masks`), producing labels that are *not*
|
|
5
|
+
consistent across time. This module closes that gap: it computes per-frame
|
|
6
|
+
centroids, runs a linear-assignment tracker (``laptrack``) to link objects
|
|
7
|
+
between consecutive frames, and relabels the stack so a tracked object keeps one
|
|
8
|
+
id over its whole lifetime.
|
|
9
|
+
|
|
10
|
+
``laptrack`` (and ``pandas``) are imported lazily inside :func:`track_masks`, so
|
|
11
|
+
importing this module — and the centroid / relabel helpers, which only need
|
|
12
|
+
numpy + scikit-image — does not require the optional ``[laptrack]`` extra.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"build_track_dataframe",
|
|
20
|
+
"relabel_by_tracks",
|
|
21
|
+
"stitch_z",
|
|
22
|
+
"track_masks",
|
|
23
|
+
"track_axiswise",
|
|
24
|
+
"COORDINATE_COLS",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
#: Centroid coordinate columns used for linking; ``z`` is constant for 2D input.
|
|
28
|
+
COORDINATE_COLS = ["z", "y", "x"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_track_dataframe(masks_tzyx: np.ndarray):
|
|
32
|
+
"""Build a per-object centroid table from a ``(T, Z, Y, X)`` label stack.
|
|
33
|
+
|
|
34
|
+
Returns a :class:`pandas.DataFrame` with columns ``frame, label, z, y, x``
|
|
35
|
+
(one row per labelled object per frame). ``z`` is always present and is
|
|
36
|
+
constant ``0`` for single-slice (2D) input, so linking is uniform.
|
|
37
|
+
"""
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from skimage.measure import regionprops_table
|
|
40
|
+
|
|
41
|
+
if masks_tzyx.ndim != 4:
|
|
42
|
+
raise ValueError(f"expected (T, Z, Y, X), got shape {masks_tzyx.shape}")
|
|
43
|
+
frames, labels, zs, ys, xs = [], [], [], [], []
|
|
44
|
+
for t in range(masks_tzyx.shape[0]):
|
|
45
|
+
volume = np.asarray(masks_tzyx[t])
|
|
46
|
+
if not volume.any():
|
|
47
|
+
continue
|
|
48
|
+
table = regionprops_table(volume, properties=("label", "centroid"))
|
|
49
|
+
n = len(table["label"])
|
|
50
|
+
# centroid-0/1/2 -> z/y/x (3D volume always yields three centroid cols).
|
|
51
|
+
frames.append(np.full(n, t, dtype=np.int64))
|
|
52
|
+
labels.append(table["label"])
|
|
53
|
+
zs.append(table.get("centroid-0", np.zeros(n)))
|
|
54
|
+
ys.append(table.get("centroid-1", np.zeros(n)))
|
|
55
|
+
xs.append(table.get("centroid-2", np.zeros(n)))
|
|
56
|
+
if not frames:
|
|
57
|
+
return pd.DataFrame(columns=["frame", "label", "z", "y", "x"])
|
|
58
|
+
return pd.DataFrame(
|
|
59
|
+
{
|
|
60
|
+
"frame": np.concatenate(frames),
|
|
61
|
+
"label": np.concatenate(labels).astype(int),
|
|
62
|
+
"z": np.concatenate(zs).astype(float),
|
|
63
|
+
"y": np.concatenate(ys).astype(float),
|
|
64
|
+
"x": np.concatenate(xs).astype(float),
|
|
65
|
+
}
|
|
66
|
+
)[["frame", "label", "z", "y", "x"]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def relabel_by_tracks(masks_tzyx: np.ndarray, track_of: dict) -> np.ndarray:
|
|
70
|
+
"""Relabel ``(T, Z, Y, X)`` masks by a ``(frame, orig_label) -> track_id`` map.
|
|
71
|
+
|
|
72
|
+
Output labels are ``track_id + 1`` (so tracks start at ``1`` and background
|
|
73
|
+
stays ``0``). Original labels absent from ``track_of`` are dropped to ``0``.
|
|
74
|
+
"""
|
|
75
|
+
if masks_tzyx.ndim != 4:
|
|
76
|
+
raise ValueError(f"expected (T, Z, Y, X), got shape {masks_tzyx.shape}")
|
|
77
|
+
out = np.zeros_like(masks_tzyx, dtype=np.int32)
|
|
78
|
+
for t in range(masks_tzyx.shape[0]):
|
|
79
|
+
frame = np.asarray(masks_tzyx[t])
|
|
80
|
+
max_label = int(frame.max())
|
|
81
|
+
if max_label == 0:
|
|
82
|
+
continue
|
|
83
|
+
lut = np.zeros(max_label + 1, dtype=np.int32)
|
|
84
|
+
for orig_label in range(1, max_label + 1):
|
|
85
|
+
track_id = track_of.get((t, orig_label))
|
|
86
|
+
if track_id is not None:
|
|
87
|
+
lut[orig_label] = int(track_id) + 1
|
|
88
|
+
out[t] = lut[frame]
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _stitch_volume(vol_zyx: np.ndarray, iou_threshold: float) -> np.ndarray:
|
|
93
|
+
"""Merge per-plane labels of one ``(Z, Y, X)`` volume into z-coherent objects.
|
|
94
|
+
|
|
95
|
+
Each z-plane is independently labelled (frame-unique ids). Two labels in
|
|
96
|
+
adjacent z-planes whose footprints overlap by ``IoU > iou_threshold`` are the
|
|
97
|
+
same 3-D object; they are unioned and the volume relabelled with compact ids.
|
|
98
|
+
"""
|
|
99
|
+
vol = np.asarray(vol_zyx)
|
|
100
|
+
Z = vol.shape[0]
|
|
101
|
+
max_label = int(vol.max())
|
|
102
|
+
if max_label == 0:
|
|
103
|
+
return np.zeros_like(vol, dtype=np.int32)
|
|
104
|
+
|
|
105
|
+
counts = np.bincount(vol.reshape(-1), minlength=max_label + 1)
|
|
106
|
+
parent = np.arange(max_label + 1, dtype=np.int64)
|
|
107
|
+
|
|
108
|
+
def find(x: int) -> int:
|
|
109
|
+
while parent[x] != x:
|
|
110
|
+
parent[x] = parent[parent[x]]
|
|
111
|
+
x = int(parent[x])
|
|
112
|
+
return x
|
|
113
|
+
|
|
114
|
+
def union(a: int, b: int) -> None:
|
|
115
|
+
ra, rb = find(a), find(b)
|
|
116
|
+
if ra != rb:
|
|
117
|
+
parent[rb] = ra
|
|
118
|
+
|
|
119
|
+
stride = max_label + 1
|
|
120
|
+
for z in range(Z - 1):
|
|
121
|
+
a = vol[z]
|
|
122
|
+
b = vol[z + 1]
|
|
123
|
+
both = (a > 0) & (b > 0)
|
|
124
|
+
if not both.any():
|
|
125
|
+
continue
|
|
126
|
+
av = a[both].astype(np.int64)
|
|
127
|
+
bv = b[both].astype(np.int64)
|
|
128
|
+
keys, inter = np.unique(av * stride + bv, return_counts=True)
|
|
129
|
+
for key, n in zip(keys, inter):
|
|
130
|
+
la = int(key // stride)
|
|
131
|
+
lb = int(key % stride)
|
|
132
|
+
union_area = counts[la] + counts[lb] - n
|
|
133
|
+
if union_area > 0 and (n / union_area) > iou_threshold:
|
|
134
|
+
union(la, lb)
|
|
135
|
+
|
|
136
|
+
# Compact the surviving roots to 1..K (background 0 stays 0).
|
|
137
|
+
lut = np.zeros(max_label + 1, dtype=np.int32)
|
|
138
|
+
new_id: dict[int, int] = {}
|
|
139
|
+
for label in range(1, max_label + 1):
|
|
140
|
+
if counts[label] == 0:
|
|
141
|
+
continue
|
|
142
|
+
root = find(label)
|
|
143
|
+
if root not in new_id:
|
|
144
|
+
new_id[root] = len(new_id) + 1
|
|
145
|
+
lut[label] = new_id[root]
|
|
146
|
+
return lut[vol]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def stitch_z(masks_tzyx: np.ndarray, *, iou_threshold: float = 0.25) -> np.ndarray:
|
|
150
|
+
"""Stitch per-plane labels into z-coherent 3-D objects, per timepoint.
|
|
151
|
+
|
|
152
|
+
Input ``(T, Z, Y, X)`` masks are labelled independently in every z-plane; this
|
|
153
|
+
links labels across adjacent z by IoU so an object spanning several planes
|
|
154
|
+
shares one id within its frame (background ``0``). For ``Z == 1`` it is a
|
|
155
|
+
no-op (returns an int32 copy). Cross-time uniqueness is left to the tracker.
|
|
156
|
+
"""
|
|
157
|
+
masks = np.asarray(masks_tzyx)
|
|
158
|
+
if masks.ndim != 4:
|
|
159
|
+
raise ValueError(f"expected (T, Z, Y, X), got shape {masks.shape}")
|
|
160
|
+
if masks.shape[1] == 1:
|
|
161
|
+
return masks.astype(np.int32, copy=True)
|
|
162
|
+
out = np.zeros_like(masks, dtype=np.int32)
|
|
163
|
+
for t in range(masks.shape[0]):
|
|
164
|
+
out[t] = _stitch_volume(masks[t], iou_threshold)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _track_ids_from_tree(tree) -> dict:
|
|
169
|
+
"""Map each ``(frame, index)`` tree node to a track id via connected components.
|
|
170
|
+
|
|
171
|
+
Only valid when splitting/merging are disabled (as they always are in
|
|
172
|
+
:func:`_run_laptrack`): every node then has at most one predecessor and one
|
|
173
|
+
successor, so the raw tree's connected components are exactly the tracks —
|
|
174
|
+
matching what ``laptrack``'s own ``tree_id``/``track_id`` computation would
|
|
175
|
+
give here, without going through its per-node assignment (see
|
|
176
|
+
:func:`_run_laptrack` docstring). Guards against silently wrong ids if that
|
|
177
|
+
assumption is ever broken by raising on unexpected branching.
|
|
178
|
+
"""
|
|
179
|
+
import networkx as nx
|
|
180
|
+
|
|
181
|
+
if any(tree.out_degree(n) > 1 or tree.in_degree(n) > 1 for n in tree.nodes):
|
|
182
|
+
raise AssertionError(
|
|
183
|
+
"track tree has branching (split/merge); _run_laptrack assumes "
|
|
184
|
+
"splitting/merging stay disabled"
|
|
185
|
+
)
|
|
186
|
+
track_id_by_node: dict = {}
|
|
187
|
+
for track_id, nodes in enumerate(nx.connected_components(nx.Graph(tree))):
|
|
188
|
+
for node in nodes:
|
|
189
|
+
track_id_by_node[node] = track_id
|
|
190
|
+
return track_id_by_node
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _run_laptrack(df, *, max_distance: float, max_frame_gap: int):
|
|
194
|
+
"""Run laptrack on a centroid dataframe; return it with a ``track_id`` column.
|
|
195
|
+
|
|
196
|
+
Calls ``LapTrack.predict()`` (the public method returning the raw tracking
|
|
197
|
+
graph) rather than ``predict_dataframe()``. The latter's own conversion step
|
|
198
|
+
(``laptrack.data_conversion.tree_to_dataframe``) assigns ``tree_id`` and
|
|
199
|
+
``track_id`` to every node **one at a time** via ``DataFrame.loc[...] =``
|
|
200
|
+
(the library's own source flags this with "XXX there may exist faster
|
|
201
|
+
impl."); on a dense stack (profiled: 150 frames x ~576 cells/frame) those
|
|
202
|
+
per-node pandas writes accounted for >80% of total tracking time, dwarfing
|
|
203
|
+
laptrack's actual linking cost. :func:`_track_ids_from_tree` gets the same
|
|
204
|
+
result via a dict lookup instead.
|
|
205
|
+
|
|
206
|
+
Isolated so :func:`track_masks` orchestration can be tested without the
|
|
207
|
+
optional dependency installed (tests monkeypatch this).
|
|
208
|
+
"""
|
|
209
|
+
from laptrack import LapTrack
|
|
210
|
+
from laptrack.data_conversion import dataframe_to_coords_frame_index
|
|
211
|
+
|
|
212
|
+
cutoff = float(max_distance) ** 2 # sqeuclidean metric
|
|
213
|
+
lt = LapTrack(
|
|
214
|
+
track_dist_metric="sqeuclidean",
|
|
215
|
+
track_cost_cutoff=cutoff,
|
|
216
|
+
gap_closing_dist_metric="sqeuclidean",
|
|
217
|
+
gap_closing_cost_cutoff=cutoff if max_frame_gap > 0 else False,
|
|
218
|
+
gap_closing_max_frame_count=int(max_frame_gap),
|
|
219
|
+
splitting_cost_cutoff=False,
|
|
220
|
+
merging_cost_cutoff=False,
|
|
221
|
+
)
|
|
222
|
+
coords, frame_index = dataframe_to_coords_frame_index(
|
|
223
|
+
df, COORDINATE_COLS, frame_col="frame"
|
|
224
|
+
)
|
|
225
|
+
tree = lt.predict(coords)
|
|
226
|
+
track_id_by_node = _track_ids_from_tree(tree)
|
|
227
|
+
track_df = df.reset_index(drop=True).copy()
|
|
228
|
+
track_df["track_id"] = [track_id_by_node[node] for node in frame_index]
|
|
229
|
+
return track_df
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def track_masks(
|
|
233
|
+
masks_tzyx: np.ndarray,
|
|
234
|
+
*,
|
|
235
|
+
max_distance: float = 15.0,
|
|
236
|
+
max_frame_gap: int = 0,
|
|
237
|
+
) -> np.ndarray:
|
|
238
|
+
"""Link per-frame masks across time and return a track-consistent stack.
|
|
239
|
+
|
|
240
|
+
``max_distance`` is the maximum centroid displacement (in pixels, over
|
|
241
|
+
``z/y/x``) allowed for a link; ``max_frame_gap`` > 0 enables gap-closing over
|
|
242
|
+
that many missed frames. Output is ``(T, Z, Y, X)`` ``int32`` with labels
|
|
243
|
+
stable across time (background ``0``).
|
|
244
|
+
"""
|
|
245
|
+
if masks_tzyx.ndim != 4:
|
|
246
|
+
raise ValueError(f"expected (T, Z, Y, X), got shape {masks_tzyx.shape}")
|
|
247
|
+
df = build_track_dataframe(masks_tzyx)
|
|
248
|
+
if df.empty:
|
|
249
|
+
return np.zeros_like(masks_tzyx, dtype=np.int32)
|
|
250
|
+
tracked_df = _run_laptrack(df, max_distance=max_distance, max_frame_gap=max_frame_gap)
|
|
251
|
+
track_of = {
|
|
252
|
+
(int(r.frame), int(r.label)): int(r.track_id)
|
|
253
|
+
for r in tracked_df.itertuples(index=False)
|
|
254
|
+
}
|
|
255
|
+
return relabel_by_tracks(masks_tzyx, track_of)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def track_axiswise(
|
|
259
|
+
masks_tzyx: np.ndarray,
|
|
260
|
+
*,
|
|
261
|
+
max_distance: float = 15.0,
|
|
262
|
+
max_frame_gap: int = 0,
|
|
263
|
+
stitch_iou: float = 0.25,
|
|
264
|
+
) -> np.ndarray:
|
|
265
|
+
"""Axis-by-axis linking: **stitch z (overlap) then track t (motion)**.
|
|
266
|
+
|
|
267
|
+
Per-plane native masks are first stitched through z by IoU
|
|
268
|
+
(:func:`stitch_z`) so an object becomes one 3-D label per frame, then linked
|
|
269
|
+
across time by centroid (:func:`track_masks`). For single-slice (``Z == 1``)
|
|
270
|
+
input the stitch is a no-op and this reduces to plain time tracking.
|
|
271
|
+
|
|
272
|
+
Stitching z (where adjacent planes *overlap*) and tracking t (where objects
|
|
273
|
+
*move*) use the right metric for each axis; the shorter leading axis is taken
|
|
274
|
+
as z upstream (see :func:`itasc.cellpose.shape.to_canonical_tzyx`).
|
|
275
|
+
"""
|
|
276
|
+
stitched = stitch_z(masks_tzyx, iou_threshold=stitch_iou)
|
|
277
|
+
return track_masks(
|
|
278
|
+
stitched, max_distance=max_distance, max_frame_gap=max_frame_gap
|
|
279
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Contact Analysis: pool per-position sources into aggregate tidy tables.
|
|
2
|
+
|
|
3
|
+
This package hosts the quantifier seam. The stable, napari-free surface that the
|
|
4
|
+
``itasc-aggregate`` CLI calls and notebooks import is the
|
|
5
|
+
:mod:`~itasc.contact_analysis.pipeline` — ``build_catalog`` →
|
|
6
|
+
``build_quantities`` → ``aggregate`` — re-exported here alongside the catalogue
|
|
7
|
+
load/save helpers and the quantifier registry. Everything it produces is
|
|
8
|
+
**label-agnostic**: tidy CSVs with no subpopulation classification baked in.
|
|
9
|
+
Reduction to replicate-level summaries and plotting are downstream concerns
|
|
10
|
+
owned by the data repo, not this package.
|
|
11
|
+
|
|
12
|
+
The contacts quantifier's public API (cell-cell edges, T1 events) is also
|
|
13
|
+
re-exported for stability; see :mod:`itasc.contact_analysis.contacts`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .catalog import load_catalog, save_catalog
|
|
17
|
+
from .config import RunConfig, load_config
|
|
18
|
+
from .pipeline import (
|
|
19
|
+
aggregate,
|
|
20
|
+
author_config,
|
|
21
|
+
build_catalog,
|
|
22
|
+
build_quantities,
|
|
23
|
+
run,
|
|
24
|
+
select_quantifiers,
|
|
25
|
+
)
|
|
26
|
+
from .quantifier import available_quantifiers
|
|
27
|
+
|
|
28
|
+
from .contacts.batch import (
|
|
29
|
+
ContactBatchJob,
|
|
30
|
+
ContactBatchResult,
|
|
31
|
+
discover_contact_batch_jobs,
|
|
32
|
+
run_contact_batch,
|
|
33
|
+
)
|
|
34
|
+
from .contacts.build import (
|
|
35
|
+
build_contacts,
|
|
36
|
+
build_position_contacts,
|
|
37
|
+
ensure_contacts,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Pipeline: the stable CLI / notebook surface.
|
|
42
|
+
"build_catalog",
|
|
43
|
+
"build_quantities",
|
|
44
|
+
"select_quantifiers",
|
|
45
|
+
"aggregate",
|
|
46
|
+
"author_config",
|
|
47
|
+
"run",
|
|
48
|
+
"load_catalog",
|
|
49
|
+
"save_catalog",
|
|
50
|
+
"RunConfig",
|
|
51
|
+
"load_config",
|
|
52
|
+
"available_quantifiers",
|
|
53
|
+
# Contacts quantifier public API.
|
|
54
|
+
"build_contacts",
|
|
55
|
+
"build_position_contacts",
|
|
56
|
+
"ensure_contacts",
|
|
57
|
+
"ContactBatchJob",
|
|
58
|
+
"ContactBatchResult",
|
|
59
|
+
"discover_contact_batch_jobs",
|
|
60
|
+
"run_contact_batch",
|
|
61
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Small shared helpers for the aggregate-quantification builders.
|
|
2
|
+
|
|
3
|
+
These were copy-pasted byte-for-byte across the shape, contacts, and dynamics
|
|
4
|
+
builders; consolidated here so there is one definition of each.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def report_progress(
|
|
12
|
+
progress_cb: Callable[[int, int, str], None] | None,
|
|
13
|
+
done: int,
|
|
14
|
+
total: int,
|
|
15
|
+
message: str,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Forward a progress tick to *progress_cb* when one was supplied."""
|
|
18
|
+
if progress_cb is not None:
|
|
19
|
+
progress_cb(done, total, message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def itasc_version() -> str:
|
|
23
|
+
"""The installed itasc version, or ``"unknown"`` if it can't be resolved."""
|
|
24
|
+
try:
|
|
25
|
+
from importlib.metadata import version
|
|
26
|
+
|
|
27
|
+
return version("itasc")
|
|
28
|
+
except Exception:
|
|
29
|
+
return "unknown"
|