itasc 0.2.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,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
|