itasc 0.2.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,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"