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,43 @@
1
+ """Track-dynamics compute core: motion read off tracked label stacks.
2
+
3
+ The headless backend for the dynamics quantifiers. A tracked label stack becomes
4
+ per-track centroid trajectories (:mod:`.trajectories`), then instantaneous
5
+ velocities + per-track motility summaries (:mod:`.kinematics`), an ensemble
6
+ mean-square-displacement curve with a power-law fit (:mod:`.msd`), and
7
+ tissue-scale collective metrics — alignment, velocity correlation, length scale
8
+ (:mod:`.collective`). :func:`build_track_dynamics` runs all of it and persists a
9
+ multi-table ``.h5``; :func:`read_track_dynamics` loads it back. No Qt / napari
10
+ import, so scripts and the standalone wheel can use it.
11
+ """
12
+
13
+ from .collective import COLLECTIVE_COLUMNS, CORR_CURVE_COLUMNS, pooled_corr_length
14
+ from .kinematics import DAC_COLUMNS, INSTANTANEOUS_COLUMNS, TRACK_COLUMNS
15
+ from .msd import MSD_COLUMNS, MSD_TRACK_COLUMNS, per_track_msd_fit
16
+ from .store import (
17
+ DEFAULT_PARAMS,
18
+ TrackDynamics,
19
+ build_track_dynamics,
20
+ read_instantaneous_table,
21
+ read_track_dynamics,
22
+ )
23
+ from .trajectories import Trajectory, extract_trajectories, trajectories_from_stack
24
+
25
+ __all__ = [
26
+ "COLLECTIVE_COLUMNS",
27
+ "CORR_CURVE_COLUMNS",
28
+ "DAC_COLUMNS",
29
+ "DEFAULT_PARAMS",
30
+ "INSTANTANEOUS_COLUMNS",
31
+ "MSD_COLUMNS",
32
+ "MSD_TRACK_COLUMNS",
33
+ "TRACK_COLUMNS",
34
+ "TrackDynamics",
35
+ "Trajectory",
36
+ "build_track_dynamics",
37
+ "extract_trajectories",
38
+ "per_track_msd_fit",
39
+ "pooled_corr_length",
40
+ "read_instantaneous_table",
41
+ "read_track_dynamics",
42
+ "trajectories_from_stack",
43
+ ]
@@ -0,0 +1,206 @@
1
+ """Tissue-scale collective motion: alignment, velocity correlation, length scale.
2
+
3
+ Consumes the per-frame instantaneous table (positions + velocities, the single
4
+ source of truth) and, for each frame, measures how coherently neighbours move:
5
+
6
+ * **order parameter** ``φ = |⟨v_i/|v_i|⟩|`` ∈ [0,1] — 0 random, 1 fully aligned
7
+ (uses raw velocities).
8
+ * **velocity correlation** of fluctuations ``δv_i = v_i − ⟨v⟩`` (the per-frame
9
+ drift removed): ``C(r) = ⟨δv_i·δv_j⟩_{|r_i−r_j|≈r} / ⟨δv_i·δv_i⟩``, normalised
10
+ so ``C → 1`` as ``r → 0``.
11
+ * **correlation length** ``ξ`` = the separation where ``C(r)`` decays to ``1/e``
12
+ (linear interpolation between bin centres); NaN when it never does.
13
+ * **nearest-neighbour distance** (median, per frame) — a natural length scale and
14
+ the default correlation bin width.
15
+
16
+ Pairs are binned by separation with width *corr_bin_um* (defaults to the global
17
+ median NN distance). The per-frame ``C(r)`` curves are also pooled across frames
18
+ into a single dataset-level curve. Pure NumPy; no I/O.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+
24
+ #: Per-frame collective columns.
25
+ COLLECTIVE_COLUMNS = ("frame", "n_cells", "order_param", "corr_length_um", "nn_distance_um")
26
+ #: Pooled correlation-curve columns.
27
+ CORR_CURVE_COLUMNS = ("separation_um", "corr", "n_pairs")
28
+
29
+ _INV_E = float(np.exp(-1.0))
30
+
31
+
32
+ def collective_tables(
33
+ instantaneous: dict[str, np.ndarray],
34
+ *,
35
+ corr_bin_um: float | None = None,
36
+ min_cells: int = 5,
37
+ ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
38
+ """Return ``(collective_table, corr_curve_table)`` from the instantaneous table.
39
+
40
+ *min_cells* is the minimum velocity-bearing cells a frame needs for its
41
+ correlation/alignment metrics (fewer → NaN, but the frame's NN distance is
42
+ still recorded). *corr_bin_um* defaults to the global median NN distance.
43
+ """
44
+ frames_all = instantaneous["frame"]
45
+ x = instantaneous["x_um"]
46
+ y = instantaneous["y_um"]
47
+ vx = instantaneous["vx_um_per_s"]
48
+ vy = instantaneous["vy_um_per_s"]
49
+
50
+ per_frame = _per_frame_views(frames_all, x, y, vx, vy)
51
+ bin_width = _resolve_bin_width(corr_bin_um, per_frame)
52
+
53
+ frames: list[int] = []
54
+ n_cells: list[int] = []
55
+ order: list[float] = []
56
+ corr_len: list[float] = []
57
+ nn_dist: list[float] = []
58
+
59
+ # Pooled-curve accumulators, keyed by integer bin index.
60
+ pooled_dot: dict[int, float] = {}
61
+ pooled_count: dict[int, int] = {}
62
+ pooled_var_sum = 0.0
63
+ pooled_var_count = 0
64
+
65
+ for frame, (pos_all, pos_v, vel) in per_frame.items():
66
+ frames.append(frame)
67
+ n_cells.append(pos_v.shape[0])
68
+ nn_dist.append(_median_nn_distance(pos_all))
69
+ if pos_v.shape[0] < int(min_cells):
70
+ order.append(np.nan)
71
+ corr_len.append(np.nan)
72
+ continue
73
+ order.append(_order_parameter(vel))
74
+ dV = vel - vel.mean(axis=0)
75
+ var = float(np.mean(np.einsum("ij,ij->i", dV, dV)))
76
+ centers, C, counts, dot_by_bin = _velocity_correlation(pos_v, dV, bin_width, var)
77
+ corr_len.append(_one_over_e_length(centers, C))
78
+ # Pool raw dot sums + the variance normaliser (a single global C(0)).
79
+ for b, d_sum in dot_by_bin.items():
80
+ pooled_dot[b] = pooled_dot.get(b, 0.0) + d_sum
81
+ pooled_count[b] = pooled_count.get(b, 0) + counts[b]
82
+ pooled_var_sum += var * pos_v.shape[0]
83
+ pooled_var_count += pos_v.shape[0]
84
+
85
+ collective = {
86
+ "frame": np.asarray(frames, dtype=np.int64),
87
+ "n_cells": np.asarray(n_cells, dtype=np.int64),
88
+ "order_param": np.asarray(order, dtype=float),
89
+ "corr_length_um": np.asarray(corr_len, dtype=float),
90
+ "nn_distance_um": np.asarray(nn_dist, dtype=float),
91
+ }
92
+ curve = _pooled_curve(pooled_dot, pooled_count, pooled_var_sum, pooled_var_count, bin_width)
93
+ return collective, curve
94
+
95
+
96
+ # --------------------------------------------------------------------- helpers
97
+ def _per_frame_views(frames_all, x, y, vx, vy):
98
+ """``frame -> (positions_all, positions_with_velocity, velocities)`` arrays."""
99
+ out: dict[int, tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
100
+ for frame in np.unique(frames_all):
101
+ m = frames_all == frame
102
+ pos_all = np.column_stack((x[m], y[m]))
103
+ has_v = np.isfinite(vx[m]) & np.isfinite(vy[m])
104
+ pos_v = pos_all[has_v]
105
+ vel = np.column_stack((vx[m][has_v], vy[m][has_v]))
106
+ out[int(frame)] = (pos_all, pos_v, vel)
107
+ return out
108
+
109
+
110
+ def _resolve_bin_width(corr_bin_um, per_frame) -> float:
111
+ if corr_bin_um is not None and float(corr_bin_um) > 0:
112
+ return float(corr_bin_um)
113
+ nn = [_median_nn_distance(pos_all) for pos_all, _, _ in per_frame.values()]
114
+ nn = [v for v in nn if np.isfinite(v) and v > 0]
115
+ return float(np.median(nn)) if nn else 1.0
116
+
117
+
118
+ def _order_parameter(vel: np.ndarray) -> float:
119
+ speed = np.hypot(vel[:, 0], vel[:, 1])
120
+ moving = speed > 0
121
+ if not moving.any():
122
+ return float("nan")
123
+ units = vel[moving] / speed[moving, None]
124
+ return float(np.hypot(*units.mean(axis=0)))
125
+
126
+
127
+ def _velocity_correlation(pos: np.ndarray, dV: np.ndarray, bin_width: float, var: float):
128
+ """Per-frame ``C(r)``: bin centres, normalised correlation, pair counts, dot sums."""
129
+ n = pos.shape[0]
130
+ iu, ju = np.triu_indices(n, k=1)
131
+ diff = pos[iu] - pos[ju]
132
+ r = np.hypot(diff[:, 0], diff[:, 1])
133
+ dots = np.einsum("ij,ij->i", dV[iu], dV[ju])
134
+ bins = np.floor(r / bin_width).astype(np.int64)
135
+
136
+ dot_by_bin: dict[int, float] = {}
137
+ count_by_bin: dict[int, int] = {}
138
+ for b, d in zip(bins.tolist(), dots.tolist()):
139
+ dot_by_bin[b] = dot_by_bin.get(b, 0.0) + d
140
+ count_by_bin[b] = count_by_bin.get(b, 0) + 1
141
+
142
+ order = sorted(dot_by_bin)
143
+ centers = np.asarray([(b + 0.5) * bin_width for b in order], dtype=float)
144
+ denom = var if var > 0 else np.nan
145
+ C = np.asarray([dot_by_bin[b] / count_by_bin[b] / denom for b in order], dtype=float)
146
+ counts = {b: count_by_bin[b] for b in order}
147
+ return centers, C, counts, dot_by_bin
148
+
149
+
150
+ def pooled_corr_length(corr_curve: dict[str, np.ndarray]) -> float:
151
+ """Single per-tissue correlation length ``ξ`` from the pooled ``C(r)`` curve.
152
+
153
+ Applies the same ``1/e`` crossing rule as the per-frame ``corr_length_um``
154
+ column to the dataset-level pooled curve; returns ``NaN`` when the curve is
155
+ empty or never decays to ``1/e``.
156
+ """
157
+ centers = np.asarray(corr_curve.get("separation_um", []), dtype=float)
158
+ C = np.asarray(corr_curve.get("corr", []), dtype=float)
159
+ if centers.size == 0:
160
+ return float("nan")
161
+ return _one_over_e_length(centers, C)
162
+
163
+
164
+ def _one_over_e_length(centers: np.ndarray, C: np.ndarray) -> float:
165
+ """Separation where *C* first decays to ``1/e``, by linear interpolation."""
166
+ if centers.size == 0:
167
+ return float("nan")
168
+ for i in range(centers.size):
169
+ if C[i] <= _INV_E:
170
+ if i == 0:
171
+ # Already below 1/e at the first populated bin: the curve
172
+ # crossed at a separation smaller than we can resolve. Report
173
+ # "cannot resolve" (NaN) rather than claiming ξ = the first bin
174
+ # center, which would overstate the resolution.
175
+ return float("nan")
176
+ c0, c1 = C[i - 1], C[i]
177
+ if c0 == c1:
178
+ return float(centers[i])
179
+ t = (c0 - _INV_E) / (c0 - c1)
180
+ return float(centers[i - 1] + t * (centers[i] - centers[i - 1]))
181
+ return float("nan") # never decays to 1/e within the field
182
+
183
+
184
+ def _pooled_curve(dot_by_bin, count_by_bin, var_sum, var_count, bin_width):
185
+ if not count_by_bin or var_count == 0:
186
+ return {name: np.asarray([], dtype=float) for name in CORR_CURVE_COLUMNS}
187
+ var = var_sum / var_count
188
+ order = sorted(count_by_bin)
189
+ sep = np.asarray([(b + 0.5) * bin_width for b in order], dtype=float)
190
+ corr = np.asarray(
191
+ [dot_by_bin[b] / count_by_bin[b] / var if var > 0 else np.nan for b in order],
192
+ dtype=float,
193
+ )
194
+ n_pairs = np.asarray([count_by_bin[b] for b in order], dtype=np.int64)
195
+ return {"separation_um": sep, "corr": corr, "n_pairs": n_pairs}
196
+
197
+
198
+ def _median_nn_distance(pos: np.ndarray) -> float:
199
+ n = pos.shape[0]
200
+ if n < 2:
201
+ return float("nan")
202
+ # Pairwise distances; small per-frame cell counts make this cheap and exact.
203
+ diff = pos[:, None, :] - pos[None, :, :]
204
+ dist = np.hypot(diff[..., 0], diff[..., 1])
205
+ np.fill_diagonal(dist, np.inf)
206
+ return float(np.median(dist.min(axis=1)))
@@ -0,0 +1,278 @@
1
+ """Per-frame velocities, per-track motility summaries, and persistence times.
2
+
3
+ Pure NumPy over :class:`~.trajectories.Trajectory` lists; no I/O, no Qt. Three
4
+ public pieces:
5
+
6
+ * :func:`instantaneous_table` — one row per ``(frame, cell_id)``: position,
7
+ velocity, speed, and net displacement from the track's start. Forward
8
+ differences between consecutive **present** frames, divided by the *real*
9
+ elapsed time ``Δf · dt`` so gaps don't inflate speed. A track's last present
10
+ frame has no forward step → NaN velocity/speed.
11
+ * :func:`track_summary_table` — one row per track: path length, net displacement,
12
+ curvilinear / net speed, directionality (confinement) ratio, and the
13
+ directional-autocorrelation persistence time.
14
+ * :func:`directional_autocorrelation` / :func:`fit_persistence_time` — the
15
+ shared DAC machinery, reused for the ensemble curve in :mod:`.msd`-adjacent
16
+ code (the dynamics store) and for each track's own persistence time.
17
+
18
+ ``step directions`` use only **real** single-frame steps (``Δf == 1``); a step
19
+ spanning a gap is dropped from the direction statistics (its heading over the
20
+ gap is unreliable), though it still contributes to path length / speed.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+
26
+ from .trajectories import Trajectory
27
+
28
+ #: Instantaneous per-(frame, cell) value columns (keys ``frame``/``cell_id`` are
29
+ #: prepended by the store). Order is the on-disk order.
30
+ INSTANTANEOUS_COLUMNS = (
31
+ "x_um",
32
+ "y_um",
33
+ "vx_um_per_s",
34
+ "vy_um_per_s",
35
+ "speed_um_per_s",
36
+ "net_disp_um",
37
+ )
38
+
39
+ #: Ensemble directional-autocorrelation curve columns.
40
+ DAC_COLUMNS = ("lag_s", "dac", "n_samples", "sem")
41
+
42
+ #: Per-track summary value columns (key ``cell_id`` prepended by the store).
43
+ TRACK_COLUMNS = (
44
+ "n_frames",
45
+ "n_gaps",
46
+ "frame_start",
47
+ "frame_end",
48
+ "duration_s",
49
+ "path_length_um",
50
+ "net_displacement_um",
51
+ "curvilinear_speed_um_per_s",
52
+ "net_speed_um_per_s",
53
+ "directionality_ratio",
54
+ "persistence_time_s",
55
+ )
56
+
57
+
58
+ def instantaneous_table(
59
+ trajectories: list[Trajectory], *, time_interval_s: float
60
+ ) -> dict[str, np.ndarray]:
61
+ """Column-major ``(frame, cell_id, …INSTANTANEOUS_COLUMNS)`` over all tracks."""
62
+ dt = float(time_interval_s)
63
+ frame: list[int] = []
64
+ cell_id: list[int] = []
65
+ cols: dict[str, list[float]] = {name: [] for name in INSTANTANEOUS_COLUMNS}
66
+ for traj in trajectories:
67
+ xy = traj.xy
68
+ frames = traj.frames
69
+ n = traj.n_frames
70
+ r0 = xy[0]
71
+ for k in range(n):
72
+ frame.append(int(frames[k]))
73
+ cell_id.append(traj.track_id)
74
+ cols["x_um"].append(float(xy[k, 0]))
75
+ cols["y_um"].append(float(xy[k, 1]))
76
+ cols["net_disp_um"].append(float(np.hypot(*(xy[k] - r0))))
77
+ if k < n - 1:
78
+ dt_real = float(frames[k + 1] - frames[k]) * dt
79
+ step = xy[k + 1] - xy[k]
80
+ vx, vy = step / dt_real
81
+ cols["vx_um_per_s"].append(float(vx))
82
+ cols["vy_um_per_s"].append(float(vy))
83
+ cols["speed_um_per_s"].append(float(np.hypot(vx, vy)))
84
+ else:
85
+ cols["vx_um_per_s"].append(np.nan)
86
+ cols["vy_um_per_s"].append(np.nan)
87
+ cols["speed_um_per_s"].append(np.nan)
88
+ out: dict[str, np.ndarray] = {
89
+ "frame": np.asarray(frame, dtype=np.int64),
90
+ "cell_id": np.asarray(cell_id, dtype=np.int64),
91
+ }
92
+ for name in INSTANTANEOUS_COLUMNS:
93
+ out[name] = np.asarray(cols[name], dtype=float)
94
+ return out
95
+
96
+
97
+ def track_summary_table(
98
+ trajectories: list[Trajectory],
99
+ *,
100
+ time_interval_s: float,
101
+ min_track_frames: int = 3,
102
+ ) -> dict[str, np.ndarray]:
103
+ """Column-major per-track summary; tracks shorter than *min_track_frames* drop."""
104
+ dt = float(time_interval_s)
105
+ cell_id: list[int] = []
106
+ rows: dict[str, list[float]] = {name: [] for name in TRACK_COLUMNS}
107
+ for traj in trajectories:
108
+ if traj.n_frames < int(min_track_frames):
109
+ continue
110
+ xy = traj.xy
111
+ frames = traj.frames
112
+ steps = np.diff(xy, axis=0)
113
+ step_len = np.hypot(steps[:, 0], steps[:, 1])
114
+ path_length = float(step_len.sum())
115
+ net_disp = float(np.hypot(*(xy[-1] - xy[0])))
116
+ duration = float(frames[-1] - frames[0]) * dt
117
+ curv_speed = path_length / duration if duration > 0 else np.nan
118
+ net_speed = net_disp / duration if duration > 0 else np.nan
119
+ dir_ratio = net_disp / path_length if path_length > 0 else np.nan
120
+ persistence = _track_persistence_time(traj, time_interval_s=dt)
121
+
122
+ cell_id.append(traj.track_id)
123
+ rows["n_frames"].append(traj.n_frames)
124
+ rows["n_gaps"].append(traj.n_gaps)
125
+ rows["frame_start"].append(int(frames[0]))
126
+ rows["frame_end"].append(int(frames[-1]))
127
+ rows["duration_s"].append(duration)
128
+ rows["path_length_um"].append(path_length)
129
+ rows["net_displacement_um"].append(net_disp)
130
+ rows["curvilinear_speed_um_per_s"].append(curv_speed)
131
+ rows["net_speed_um_per_s"].append(net_speed)
132
+ rows["directionality_ratio"].append(dir_ratio)
133
+ rows["persistence_time_s"].append(persistence)
134
+
135
+ out: dict[str, np.ndarray] = {"cell_id": np.asarray(cell_id, dtype=np.int64)}
136
+ # Integer-valued columns stay integers on disk; the rest are floats (NaN-safe).
137
+ int_cols = {"n_frames", "n_gaps", "frame_start", "frame_end"}
138
+ for name in TRACK_COLUMNS:
139
+ dtype = np.int64 if name in int_cols else float
140
+ out[name] = np.asarray(rows[name], dtype=dtype)
141
+ return out
142
+
143
+
144
+ # --------------------------------------------------------- directional autocorr
145
+ def step_directions_by_frame(traj: Trajectory) -> dict[int, np.ndarray]:
146
+ """``start_frame -> unit step direction`` for each **real** (``Δf == 1``) step.
147
+
148
+ Zero-length steps (a stationary cell) have no defined direction and are
149
+ skipped.
150
+ """
151
+ out: dict[int, np.ndarray] = {}
152
+ frames = traj.frames
153
+ xy = traj.xy
154
+ for k in range(traj.n_frames - 1):
155
+ if int(frames[k + 1] - frames[k]) != 1:
156
+ continue
157
+ step = xy[k + 1] - xy[k]
158
+ norm = float(np.hypot(step[0], step[1]))
159
+ if norm > 0:
160
+ out[int(frames[k])] = step / norm
161
+ return out
162
+
163
+
164
+ def accumulate_dac(
165
+ dirs_by_frame: dict[int, np.ndarray],
166
+ max_lag: int,
167
+ sums: dict[int, float],
168
+ counts: dict[int, int],
169
+ sumsq: dict[int, float] | None = None,
170
+ ) -> None:
171
+ """Add ``û(f)·û(f+n)`` dot products into *sums* / *counts*, keyed by lag *n*.
172
+
173
+ Reused for a single track (fresh accumulators → that track's persistence
174
+ time) and for the ensemble (shared accumulators across all tracks). Pass
175
+ *sumsq* to also accumulate squared dot products (for the ensemble SEM).
176
+ """
177
+ if not dirs_by_frame:
178
+ return
179
+ for f in sorted(dirs_by_frame):
180
+ u = dirs_by_frame[f]
181
+ for n in range(1, max_lag + 1):
182
+ v = dirs_by_frame.get(f + n)
183
+ if v is None: # f+n absent (gap / track end) — no pair at this lag
184
+ continue
185
+ dot = float(u[0] * v[0] + u[1] * v[1])
186
+ sums[n] = sums.get(n, 0.0) + dot
187
+ counts[n] = counts.get(n, 0) + 1
188
+ if sumsq is not None:
189
+ sumsq[n] = sumsq.get(n, 0.0) + dot * dot
190
+
191
+
192
+ def ensemble_dac(
193
+ trajectories: list[Trajectory], *, time_interval_s: float
194
+ ) -> tuple[dict[str, np.ndarray], float]:
195
+ """Pooled DAC curve over all tracks → ``(DAC_COLUMNS table, persistence_s)``.
196
+
197
+ Each track contributes its real-step direction pairs up to its own span; the
198
+ fitted ensemble persistence time uses the same recipe as the per-track one.
199
+ """
200
+ dt = float(time_interval_s)
201
+ sums: dict[int, float] = {}
202
+ counts: dict[int, int] = {}
203
+ sumsq: dict[int, float] = {}
204
+ for traj in trajectories:
205
+ dirs = step_directions_by_frame(traj)
206
+ if len(dirs) < 2:
207
+ continue
208
+ accumulate_dac(dirs, max(dirs) - min(dirs), sums, counts, sumsq)
209
+
210
+ lags, dac, n_samples = directional_autocorrelation(sums, counts)
211
+ lag_s = lags.astype(float) * dt
212
+ sem = np.asarray(
213
+ [_dac_sem(sums[int(n)], sumsq[int(n)], counts[int(n)]) for n in lags],
214
+ dtype=float,
215
+ )
216
+ table = {"lag_s": lag_s, "dac": dac, "n_samples": n_samples, "sem": sem}
217
+ return table, fit_persistence_time(lag_s, dac)
218
+
219
+
220
+ def _dac_sem(s: float, ss: float, count: int) -> float:
221
+ if count < 2:
222
+ return float("nan")
223
+ var = (ss - s * s / count) / (count - 1)
224
+ return float(np.sqrt(max(var, 0.0) / count))
225
+
226
+
227
+ def directional_autocorrelation(
228
+ sums: dict[int, float], counts: dict[int, int]
229
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
230
+ """Turn accumulated sums/counts into ``(lags, C, n_samples)`` arrays.
231
+
232
+ ``lags`` are integer frame lags ``1 … max`` present in *counts*, sorted.
233
+ """
234
+ lags = np.asarray(sorted(counts), dtype=np.int64)
235
+ C = np.asarray([sums[n] / counts[n] for n in lags], dtype=float)
236
+ n_samples = np.asarray([counts[n] for n in lags], dtype=np.int64)
237
+ return lags, C, n_samples
238
+
239
+
240
+ def fit_persistence_time(lags_s: np.ndarray, C: np.ndarray) -> float:
241
+ """Fit ``C = exp(-τ/P)`` over the leading positive run; return ``P`` (s).
242
+
243
+ Linear fit of ``ln C`` vs ``τ`` across the contiguous ``C > 0`` prefix
244
+ (autocorrelation decays from 1, so the leading run is the signal). ``P =
245
+ -1/slope``. NaN when fewer than two positive points or a non-negative slope
246
+ (no decay).
247
+ """
248
+ lags_s = np.asarray(lags_s, dtype=float)
249
+ C = np.asarray(C, dtype=float)
250
+ if lags_s.size == 0:
251
+ return float("nan")
252
+ # Leading contiguous positive run.
253
+ positive = C > 0
254
+ if not positive[0]:
255
+ return float("nan")
256
+ cut = int(np.argmin(positive)) if not positive.all() else positive.size
257
+ if cut < 2:
258
+ return float("nan")
259
+ x = lags_s[:cut]
260
+ y = np.log(C[:cut])
261
+ slope = float(np.polyfit(x, y, 1)[0])
262
+ if slope >= 0:
263
+ return float("nan")
264
+ return -1.0 / slope
265
+
266
+
267
+ def _track_persistence_time(traj: Trajectory, *, time_interval_s: float) -> float:
268
+ dirs = step_directions_by_frame(traj)
269
+ if len(dirs) < 2:
270
+ return float("nan")
271
+ max_lag = max(dirs) - min(dirs)
272
+ sums: dict[int, float] = {}
273
+ counts: dict[int, int] = {}
274
+ accumulate_dac(dirs, max_lag, sums, counts)
275
+ if not counts:
276
+ return float("nan")
277
+ lags, C, _ = directional_autocorrelation(sums, counts)
278
+ return fit_persistence_time(lags.astype(float) * float(time_interval_s), C)
@@ -0,0 +1,165 @@
1
+ """Ensemble time-averaged mean-square displacement and its power-law fit.
2
+
3
+ For each integer frame lag ``n``, the MSD is averaged over **all tracks** and
4
+ **all present overlapping origin pairs** ``(k, k+n)`` whose frames are separated
5
+ by exactly ``n`` (a gap inside a pair breaks it, so only genuine ``n``-frame
6
+ displacements count)::
7
+
8
+ MSD(n) = mean over tracks, origins of |r(f+n) − r(f)|² (µm²)
9
+
10
+ Lags run ``1 … n_max`` with ``n_max = max(1, ⌊¼ · longest track span⌋)`` — the
11
+ usual reliability cap, since large-lag estimates rest on few, strongly
12
+ overlapping samples. The curve is fit in log-log to ``MSD = 2·d·D·τ^α`` (``d =
13
+ 2``): ``α`` is the anomalous exponent (≈1 diffusive, →2 ballistic/persistent, <1
14
+ confined) and ``D`` the (generalised) diffusion coefficient in µm²/s. Pure
15
+ NumPy; no I/O.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+
23
+ from .trajectories import Trajectory
24
+
25
+ #: Spatial dimensionality of the trajectories (2-D centroids everywhere here).
26
+ _DIMS = 2
27
+
28
+ #: MSD curve columns (the store prepends nothing; these are the full set).
29
+ MSD_COLUMNS = ("lag_s", "msd_um2", "n_samples", "sem")
30
+
31
+ #: Per-track MSD-fit value columns, merged into the per-track summary table.
32
+ MSD_TRACK_COLUMNS = ("msd_D_um2_per_s", "msd_alpha", "msd_r2")
33
+ #: Default fixed lag window (frames) for the per-track MSD fit. A fixed window
34
+ #: makes every track's ``D``/``α`` comparable and excludes the noisy long-lag tail.
35
+ DEFAULT_MSD_TRACK_WINDOW = 8
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class MsdFit:
40
+ """Power-law fit of the MSD curve: ``MSD = 2·d·D·τ^α``."""
41
+
42
+ D_um2_per_s: float
43
+ alpha: float
44
+ r2: float
45
+
46
+
47
+ def ensemble_msd(
48
+ trajectories: list[Trajectory],
49
+ *,
50
+ time_interval_s: float,
51
+ n_max: int | None = None,
52
+ ) -> dict[str, np.ndarray]:
53
+ """Column-major ``MSD_COLUMNS`` table (``lag_s, msd_um2, n_samples, sem``)."""
54
+ dt = float(time_interval_s)
55
+ if n_max is None:
56
+ n_max = _default_n_max(trajectories)
57
+ # lag -> running sum of squared displacements and a list for the SEM.
58
+ sq_by_lag: dict[int, list[float]] = {}
59
+ for traj in trajectories:
60
+ _accumulate_track_sq(traj, n_max, sq_by_lag)
61
+
62
+ lags = np.asarray(sorted(sq_by_lag), dtype=np.int64)
63
+ msd = np.empty(lags.size, dtype=float)
64
+ n_samples = np.empty(lags.size, dtype=np.int64)
65
+ sem = np.empty(lags.size, dtype=float)
66
+ for i, n in enumerate(lags):
67
+ vals = np.asarray(sq_by_lag[int(n)], dtype=float)
68
+ msd[i] = float(vals.mean())
69
+ n_samples[i] = vals.size
70
+ sem[i] = float(vals.std(ddof=1) / np.sqrt(vals.size)) if vals.size > 1 else np.nan
71
+ return {
72
+ "lag_s": lags.astype(float) * dt,
73
+ "msd_um2": msd,
74
+ "n_samples": n_samples,
75
+ "sem": sem,
76
+ }
77
+
78
+
79
+ def fit_msd_power_law(
80
+ lag_s: np.ndarray, msd_um2: np.ndarray, *, min_samples_mask: np.ndarray | None = None
81
+ ) -> MsdFit:
82
+ """Log-log fit ``log MSD = log(2dD) + α·log τ`` → :class:`MsdFit`.
83
+
84
+ *min_samples_mask* optionally restricts the fit to well-sampled lags. NaNs in
85
+ ``D``/``α``/``r2`` when fewer than two usable positive points remain.
86
+ """
87
+ lag_s = np.asarray(lag_s, dtype=float)
88
+ msd_um2 = np.asarray(msd_um2, dtype=float)
89
+ valid = (lag_s > 0) & (msd_um2 > 0) & np.isfinite(lag_s) & np.isfinite(msd_um2)
90
+ if min_samples_mask is not None:
91
+ valid &= np.asarray(min_samples_mask, dtype=bool)
92
+ if int(valid.sum()) < 2:
93
+ return MsdFit(float("nan"), float("nan"), float("nan"))
94
+ x = np.log(lag_s[valid])
95
+ y = np.log(msd_um2[valid])
96
+ slope, intercept = np.polyfit(x, y, 1)
97
+ alpha = float(slope)
98
+ D = float(np.exp(intercept) / (2.0 * _DIMS))
99
+ r2 = _r_squared(x, y, slope, intercept)
100
+ return MsdFit(D_um2_per_s=D, alpha=alpha, r2=r2)
101
+
102
+
103
+ def per_track_msd_fit(
104
+ trajectories: list[Trajectory],
105
+ *,
106
+ time_interval_s: float,
107
+ window_frames: int = DEFAULT_MSD_TRACK_WINDOW,
108
+ ) -> dict[int, MsdFit]:
109
+ """Fit each track's own MSD over a shared fixed lag window → ``track_id → MsdFit``.
110
+
111
+ For each track the MSD is averaged over its present ``(k, k+n)`` origin pairs
112
+ for lags ``1 … window_frames`` (the same rule as :func:`ensemble_msd`, capped
113
+ at the window), then fit in log-log. A track that cannot fill at least two
114
+ distinct lags within the window yields ``MsdFit(nan, nan, nan)``, so its
115
+ ``D``/``α``/``r2`` are NaN rather than a spurious single-point slope.
116
+ """
117
+ dt = float(time_interval_s)
118
+ window = int(window_frames)
119
+ fits: dict[int, MsdFit] = {}
120
+ for traj in trajectories:
121
+ sq_by_lag: dict[int, list[float]] = {}
122
+ _accumulate_track_sq(traj, window, sq_by_lag)
123
+ if len(sq_by_lag) < 2:
124
+ fits[traj.track_id] = MsdFit(float("nan"), float("nan"), float("nan"))
125
+ continue
126
+ lags = np.asarray(sorted(sq_by_lag), dtype=np.int64)
127
+ msd = np.asarray([float(np.mean(sq_by_lag[int(n)])) for n in lags], dtype=float)
128
+ fits[traj.track_id] = fit_msd_power_law(lags.astype(float) * dt, msd)
129
+ return fits
130
+
131
+
132
+ def _accumulate_track_sq(
133
+ traj: Trajectory, n_max: int, sq_by_lag: dict[int, list[float]]
134
+ ) -> None:
135
+ frames = traj.frames
136
+ xy = traj.xy
137
+ # frame -> position index, so an origin pair is "present" only when both
138
+ # endpoints exist and are exactly n frames apart.
139
+ pos = {int(f): i for i, f in enumerate(frames)}
140
+ for f, i in pos.items():
141
+ for n in range(1, n_max + 1):
142
+ j = pos.get(f + n)
143
+ if j is None:
144
+ continue
145
+ d = xy[j] - xy[i]
146
+ sq_by_lag.setdefault(n, []).append(float(d[0] * d[0] + d[1] * d[1]))
147
+
148
+
149
+ def _default_n_max(trajectories: list[Trajectory]) -> int:
150
+ # Use each track's effective length (number of observed points), not its
151
+ # frame span. A gappy 2-point track present only in frames {0, 400} has a
152
+ # span of 400 but supports a single lag; keying n_max off the span would
153
+ # extend the ensemble MSD curve into an unreliable, poorly-sampled tail.
154
+ lengths = [t.n_frames - 1 for t in trajectories if t.n_frames >= 2]
155
+ longest = max(lengths) if lengths else 1
156
+ return max(1, longest // 4)
157
+
158
+
159
+ def _r_squared(x: np.ndarray, y: np.ndarray, slope: float, intercept: float) -> float:
160
+ pred = slope * x + intercept
161
+ ss_res = float(np.sum((y - pred) ** 2))
162
+ ss_tot = float(np.sum((y - y.mean()) ** 2))
163
+ if ss_tot == 0:
164
+ return float("nan")
165
+ return 1.0 - ss_res / ss_tot