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,251 @@
1
+ """Persist / read the track-dynamics artifact and orchestrate the build.
2
+
3
+ One **HDF5** per substrate (``cell_dynamics.h5`` / ``nucleus_dynamics.h5``) — the
4
+ quantity is several heterogeneous tables, so a single flat CSV (as the shape
5
+ family uses) does not fit; this mirrors how contacts stores ``cells/`` /
6
+ ``edges/`` / ``t1_events/``. Groups::
7
+
8
+ instantaneous/table per (frame, cell_id) positions + velocities (the
9
+ object_table for the generic plotting layer)
10
+ tracks/table per-track motility summary
11
+ msd/table ensemble MSD curve (+ D_um2_per_s / alpha / r2 attrs)
12
+ dac/table ensemble directional autocorrelation (+ persistence_time_s attr)
13
+ collective/table per-frame order parameter / correlation length / NN distance
14
+ corr_curve/table pooled velocity-correlation curve
15
+ provenance run metadata as group attrs
16
+
17
+ :func:`build_track_dynamics` runs the headless core and writes the file;
18
+ :func:`read_track_dynamics` loads it back; :func:`read_instantaneous_table`
19
+ serves the tidy-table contract. Backend-only (no Qt / napari).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ import h5py
29
+ import numpy as np
30
+
31
+ from .collective import collective_tables, pooled_corr_length
32
+ from .kinematics import ensemble_dac, instantaneous_table, track_summary_table
33
+ from .msd import MSD_TRACK_COLUMNS, ensemble_msd, fit_msd_power_law, per_track_msd_fit
34
+ from .trajectories import extract_trajectories
35
+ from itasc.contact_analysis._provenance import (
36
+ itasc_version as _itasc_version,
37
+ )
38
+
39
+ #: Build params and their defaults (a position with no override uses these).
40
+ DEFAULT_PARAMS = {
41
+ "min_track_frames": 3, # drop shorter tracks from the per-track summary
42
+ "min_msd_samples": 10, # lags with fewer samples are excluded from the fit
43
+ "msd_track_window_frames": 8, # fixed lag window for the per-track MSD fit
44
+ "corr_bin_um": None, # None → global median nearest-neighbour distance
45
+ "min_corr_cells": 5, # frames with fewer velocity-bearing cells → NaN
46
+ }
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class TrackDynamics:
51
+ """In-memory view of a dynamics ``.h5`` — every table plus the fit scalars."""
52
+
53
+ instantaneous: dict[str, np.ndarray]
54
+ tracks: dict[str, np.ndarray]
55
+ msd: dict[str, np.ndarray]
56
+ dac: dict[str, np.ndarray]
57
+ collective: dict[str, np.ndarray]
58
+ corr_curve: dict[str, np.ndarray]
59
+ msd_D_um2_per_s: float
60
+ msd_alpha: float
61
+ msd_r2: float
62
+ dac_persistence_time_s: float
63
+ corr_length_um: float
64
+
65
+
66
+ def build_track_dynamics(
67
+ label_path: str | Path,
68
+ output_path: str | Path,
69
+ *,
70
+ pixel_size_um: float,
71
+ time_interval_s: float,
72
+ source_path: str | Path | None = None,
73
+ params: dict | None = None,
74
+ quantity_id: str = "",
75
+ progress_cb: Callable[[int, int, str], None] | None = None,
76
+ ) -> Path:
77
+ """Compute every dynamics table from a tracked label TIFF and write the ``.h5``."""
78
+ pixel_size_um = float(pixel_size_um)
79
+ time_interval_s = float(time_interval_s)
80
+ if not pixel_size_um > 0:
81
+ raise ValueError(f"pixel_size_um must be positive, got {pixel_size_um!r}")
82
+ if not time_interval_s > 0:
83
+ raise ValueError(f"time_interval_s must be positive, got {time_interval_s!r}")
84
+ p = {**DEFAULT_PARAMS, **(params or {})}
85
+ label_path = Path(label_path)
86
+ output_path = Path(output_path)
87
+
88
+ def step(done: int, total: int, message: str) -> None:
89
+ if progress_cb is not None:
90
+ progress_cb(done, total, message)
91
+
92
+ trajectories = extract_trajectories(label_path, pixel_size_um=pixel_size_um)
93
+ step(1, 5, "trajectories")
94
+
95
+ instantaneous = instantaneous_table(trajectories, time_interval_s=time_interval_s)
96
+ tracks = track_summary_table(
97
+ trajectories,
98
+ time_interval_s=time_interval_s,
99
+ min_track_frames=int(p["min_track_frames"]),
100
+ )
101
+ track_msd = per_track_msd_fit(
102
+ trajectories,
103
+ time_interval_s=time_interval_s,
104
+ window_frames=int(p["msd_track_window_frames"]),
105
+ )
106
+ tracks = _merge_track_msd(tracks, track_msd)
107
+ step(2, 5, "kinematics")
108
+
109
+ msd = ensemble_msd(trajectories, time_interval_s=time_interval_s)
110
+ msd_fit = fit_msd_power_law(
111
+ msd["lag_s"], msd["msd_um2"],
112
+ min_samples_mask=msd["n_samples"] >= int(p["min_msd_samples"]),
113
+ )
114
+ step(3, 5, "msd")
115
+
116
+ dac, dac_persistence = ensemble_dac(trajectories, time_interval_s=time_interval_s)
117
+ step(4, 5, "persistence")
118
+
119
+ collective, corr_curve = collective_tables(
120
+ instantaneous,
121
+ corr_bin_um=p["corr_bin_um"],
122
+ min_cells=int(p["min_corr_cells"]),
123
+ )
124
+ corr_length = pooled_corr_length(corr_curve)
125
+ step(5, 5, "collective")
126
+
127
+ _write_h5(
128
+ output_path,
129
+ tables={
130
+ "instantaneous": instantaneous,
131
+ "tracks": tracks,
132
+ "msd": msd,
133
+ "dac": dac,
134
+ "collective": collective,
135
+ "corr_curve": corr_curve,
136
+ },
137
+ msd_fit=msd_fit,
138
+ dac_persistence_time_s=dac_persistence,
139
+ corr_length_um=corr_length,
140
+ provenance={
141
+ "quantity_id": quantity_id,
142
+ "source_position_path": str(source_path) if source_path else "",
143
+ "label_path": str(label_path),
144
+ "pixel_size_um": pixel_size_um,
145
+ "time_interval_s": time_interval_s,
146
+ "params": {k: ("" if v is None else v) for k, v in p.items()},
147
+ "created_at": datetime.now(timezone.utc).isoformat(),
148
+ "itasc_version": _itasc_version(),
149
+ },
150
+ )
151
+ return output_path
152
+
153
+
154
+ def read_track_dynamics(path: str | Path) -> TrackDynamics:
155
+ """Load a dynamics ``.h5`` into a :class:`TrackDynamics`."""
156
+ path = Path(path)
157
+ with h5py.File(path, "r") as h5:
158
+ tables = {name: _read_table(h5[f"{name}/table"]) for name in (
159
+ "instantaneous", "tracks", "msd", "dac", "collective", "corr_curve"
160
+ )}
161
+ msd_attrs = h5["msd/table"].attrs
162
+ dac_attrs = h5["dac/table"].attrs
163
+ corr_attrs = h5["corr_curve/table"].attrs
164
+ return TrackDynamics(
165
+ instantaneous=tables["instantaneous"],
166
+ tracks=tables["tracks"],
167
+ msd=tables["msd"],
168
+ dac=tables["dac"],
169
+ collective=tables["collective"],
170
+ corr_curve=tables["corr_curve"],
171
+ msd_D_um2_per_s=float(msd_attrs["D_um2_per_s"]),
172
+ msd_alpha=float(msd_attrs["alpha"]),
173
+ msd_r2=float(msd_attrs["r2"]),
174
+ dac_persistence_time_s=float(dac_attrs["persistence_time_s"]),
175
+ # Absent in .h5 files built before per-tissue ξ → NaN (backward compat).
176
+ corr_length_um=float(corr_attrs.get("corr_length_um", float("nan"))),
177
+ )
178
+
179
+
180
+ def read_instantaneous_table(path: str | Path) -> dict[str, np.ndarray]:
181
+ """The ``(frame, cell_id, …)`` instantaneous table — the tidy-table contract."""
182
+ path = Path(path)
183
+ with h5py.File(path, "r") as h5:
184
+ return _read_table(h5["instantaneous/table"])
185
+
186
+
187
+ # --------------------------------------------------------------------- h5 I/O
188
+ def _write_h5(
189
+ output_path, *, tables, msd_fit, dac_persistence_time_s, corr_length_um, provenance
190
+ ) -> None:
191
+ output_path.parent.mkdir(parents=True, exist_ok=True)
192
+ with h5py.File(output_path, "w") as h5:
193
+ for name, table in tables.items():
194
+ group = h5.create_group(f"{name}/table")
195
+ for column, values in table.items():
196
+ group.create_dataset(column, data=np.asarray(values))
197
+ # h5py iterates datasets alphabetically; persist the authored column
198
+ # order so _read_table can restore it rather than reshuffling.
199
+ group.attrs["column_order"] = list(table.keys())
200
+ msd_table = h5["msd/table"]
201
+ msd_table.attrs["D_um2_per_s"] = float(msd_fit.D_um2_per_s)
202
+ msd_table.attrs["alpha"] = float(msd_fit.alpha)
203
+ msd_table.attrs["r2"] = float(msd_fit.r2)
204
+ h5["dac/table"].attrs["persistence_time_s"] = float(dac_persistence_time_s)
205
+ # Symmetric with the msd fit attrs and the dac persistence attr.
206
+ h5["corr_curve/table"].attrs["corr_length_um"] = float(corr_length_um)
207
+ prov = h5.create_group("provenance")
208
+ _write_provenance_attrs(prov, provenance)
209
+
210
+
211
+ def _merge_track_msd(
212
+ tracks: dict[str, np.ndarray], track_msd: dict[int, object]
213
+ ) -> dict[str, np.ndarray]:
214
+ """Add the per-track MSD-fit columns to the per-track table, aligned to
215
+ ``tracks["cell_id"]`` (NaN for any track absent from the fit map).
216
+
217
+ Lives here rather than in kinematics or msd: it bridges the two tables, and
218
+ neither of those modules should import the other.
219
+ """
220
+ cell_ids = tracks["cell_id"]
221
+ columns = {name: np.full(cell_ids.size, np.nan, dtype=float) for name in MSD_TRACK_COLUMNS}
222
+ for i, cid in enumerate(cell_ids.tolist()):
223
+ fit = track_msd.get(int(cid))
224
+ if fit is None:
225
+ continue
226
+ columns["msd_D_um2_per_s"][i] = fit.D_um2_per_s
227
+ columns["msd_alpha"][i] = fit.alpha
228
+ columns["msd_r2"][i] = fit.r2
229
+ return {**tracks, **columns}
230
+
231
+
232
+ def _write_provenance_attrs(group: h5py.Group, provenance: dict) -> None:
233
+ for key, value in provenance.items():
234
+ if isinstance(value, dict):
235
+ for sub_key, sub_value in value.items():
236
+ group.attrs[f"{key}.{sub_key}"] = sub_value
237
+ else:
238
+ group.attrs[key] = value
239
+
240
+
241
+ def _read_table(group: h5py.Group) -> dict[str, np.ndarray]:
242
+ """Read a table group, preserving the authored column order.
243
+
244
+ h5py iterates datasets alphabetically, so fall back to that only for legacy
245
+ files written without the ``column_order`` attribute.
246
+ """
247
+ order = group.attrs.get("column_order")
248
+ if order is not None:
249
+ names = [n.decode() if isinstance(n, bytes) else str(n) for n in order]
250
+ return {name: group[name][:] for name in names if name in group}
251
+ return {name: dataset[:] for name, dataset in group.items()}
@@ -0,0 +1,100 @@
1
+ """Tracked label stack → per-track centroid trajectories (µm), gaps preserved.
2
+
3
+ The shared front end of every dynamics calculation. Runs
4
+ :func:`skimage.measure.regionprops` over each frame of a tracked label stack
5
+ (``label == track_id``, constant across frames — the same per-frame loop the
6
+ shape core uses) and groups centroids into one trajectory per track id. Frames
7
+ where a track's label is absent are simply missing from its series, so a
8
+ :class:`Trajectory` carries an explicit ``frames`` array and downstream code can
9
+ detect gaps (``diff(frames) > 1``).
10
+
11
+ Centroids are returned in **µm** (``(x, y)`` = ``(col, row) · pixel_size_um``);
12
+ ``x`` is the column axis and ``y`` the row axis, matching the shape core's
13
+ ``centroid_x_um`` / ``centroid_y_um``. Backend-only (no Qt / napari).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ from skimage.measure import regionprops
23
+
24
+ from ..shape.core import read_label_stack
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Trajectory:
29
+ """One track's centroid path in µm, in frame order.
30
+
31
+ ``frames`` is strictly increasing (one entry per frame the track is present);
32
+ ``xy`` is the matching ``(N, 2)`` array of ``(x_um, y_um)`` centroids. A jump
33
+ of more than 1 between consecutive ``frames`` is a gap.
34
+ """
35
+
36
+ track_id: int
37
+ frames: np.ndarray # (N,) int64, strictly increasing
38
+ xy: np.ndarray # (N, 2) float, columns (x_um, y_um)
39
+
40
+ @property
41
+ def n_frames(self) -> int:
42
+ return int(self.frames.size)
43
+
44
+ @property
45
+ def n_gaps(self) -> int:
46
+ if self.frames.size < 2:
47
+ return 0
48
+ return int(np.count_nonzero(np.diff(self.frames) > 1))
49
+
50
+
51
+ def extract_trajectories(
52
+ label_path: str | Path,
53
+ *,
54
+ pixel_size_um: float,
55
+ progress_cb: Callable[[int, int, str], None] | None = None,
56
+ ) -> list[Trajectory]:
57
+ """Read a tracked label TIFF and return one :class:`Trajectory` per track id.
58
+
59
+ Centroids come from ``regionprops(frame).centroid`` (row, col) scaled by
60
+ *pixel_size_um* into ``(x_um, y_um)``. Trajectories are sorted by track id;
61
+ each is internally sorted by frame.
62
+ """
63
+ pixel_size_um = float(pixel_size_um)
64
+ if not pixel_size_um > 0:
65
+ raise ValueError(f"pixel_size_um must be positive, got {pixel_size_um!r}")
66
+ label_stack = read_label_stack(Path(label_path))
67
+ return trajectories_from_stack(
68
+ label_stack, pixel_size_um=pixel_size_um, progress_cb=progress_cb
69
+ )
70
+
71
+
72
+ def trajectories_from_stack(
73
+ label_stack: np.ndarray,
74
+ *,
75
+ pixel_size_um: float,
76
+ progress_cb: Callable[[int, int, str], None] | None = None,
77
+ ) -> list[Trajectory]:
78
+ """:func:`extract_trajectories` over an in-memory ``T×Y×X`` label stack."""
79
+ total = int(label_stack.shape[0])
80
+ # track_id -> (list[frame], list[(x_um, y_um)])
81
+ by_track: dict[int, tuple[list[int], list[tuple[float, float]]]] = {}
82
+ for frame_idx, frame in enumerate(label_stack):
83
+ for prop in regionprops(frame):
84
+ row, col = prop.centroid
85
+ frames, coords = by_track.setdefault(int(prop.label), ([], []))
86
+ frames.append(frame_idx)
87
+ coords.append((col * pixel_size_um, row * pixel_size_um))
88
+ if progress_cb is not None:
89
+ progress_cb(frame_idx + 1, total, "extract centroids")
90
+
91
+ trajectories: list[Trajectory] = []
92
+ for track_id in sorted(by_track):
93
+ frames, coords = by_track[track_id]
94
+ frame_arr = np.asarray(frames, dtype=np.int64)
95
+ xy = np.asarray(coords, dtype=float)
96
+ order = np.argsort(frame_arr, kind="stable")
97
+ trajectories.append(
98
+ Trajectory(track_id=track_id, frames=frame_arr[order], xy=xy[order])
99
+ )
100
+ return trajectories
@@ -0,0 +1,85 @@
1
+ """Resolve a position's frame interval (seconds per frame).
2
+
3
+ The time twin of :mod:`.pixel_size`. Track dynamics needs the real elapsed time
4
+ between frames to turn pixel displacements into µm/s, MSD into µm²/s, and lags
5
+ into seconds. Backend-only (no Qt / napari) so the standalone wheel and headless
6
+ batch runs can use it. Two sources are consulted, in order:
7
+
8
+ 1. The position's ``itasc_config.json`` — ``metadata.time_interval_s`` — the
9
+ key the main pipeline widget already writes (``main_widget.py``).
10
+ 2. The label TIFF's ImageJ ``finterval`` tag (seconds between frames), the
11
+ convention ImageJ/Fiji writes for time-lapse stacks.
12
+
13
+ Returns ``None`` when neither yields a positive value; callers treat that as
14
+ "interval unknown" (the dynamics build is then blocked until one is set).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+
21
+ import tifffile
22
+
23
+ from itasc.core.paths import position_config_path
24
+
25
+ __all__ = [
26
+ "resolve_time_interval_s",
27
+ "time_interval_from_config",
28
+ "time_interval_from_tiff",
29
+ ]
30
+
31
+
32
+ def resolve_time_interval_s(
33
+ position_dir: Path | str | None,
34
+ label_path: Path | str | None,
35
+ ) -> float | None:
36
+ """Seconds/frame for a position, from its config first, then the TIFF tag.
37
+
38
+ Returns ``None`` when no positive interval can be found.
39
+ """
40
+ interval = time_interval_from_config(position_dir)
41
+ if interval is not None:
42
+ return interval
43
+ return time_interval_from_tiff(label_path)
44
+
45
+
46
+ def time_interval_from_config(position_dir: Path | str | None) -> float | None:
47
+ """``metadata.time_interval_s`` from ``itasc_config.json`` in *position_dir*."""
48
+ if position_dir is None:
49
+ return None
50
+ config_path = position_config_path(position_dir)
51
+ if not config_path.is_file():
52
+ return None
53
+ try:
54
+ with open(config_path) as f:
55
+ config = json.load(f)
56
+ metadata = config.get("metadata", {})
57
+ return _positive_float(metadata.get("time_interval_s"))
58
+ except (OSError, ValueError, json.JSONDecodeError):
59
+ return None
60
+
61
+
62
+ def time_interval_from_tiff(label_path: Path | str | None) -> float | None:
63
+ """Seconds/frame from a TIFF's ImageJ ``finterval`` metadata."""
64
+ if label_path is None:
65
+ return None
66
+ path = Path(label_path)
67
+ if not path.is_file():
68
+ return None
69
+ try:
70
+ with tifffile.TiffFile(str(path)) as tf:
71
+ metadata = tf.imagej_metadata or {}
72
+ return _positive_float(metadata.get("finterval"))
73
+ except (OSError, ValueError, KeyError, tifffile.TiffFileError):
74
+ # An unreadable/empty/corrupt TIFF means "frame interval unknown", not a
75
+ # crash. tifffile raises TiffFileError (not a KeyError/OSError subclass)
76
+ # for a malformed file, so it must be named explicitly.
77
+ return None
78
+
79
+
80
+ def _positive_float(value: object) -> float | None:
81
+ try:
82
+ result = float(value) # type: ignore[arg-type]
83
+ except (TypeError, ValueError):
84
+ return None
85
+ return result if result > 0 else None
@@ -0,0 +1,10 @@
1
+ name: itasc-aggregate
2
+ display_name: ITASC Aggregate Quantification
3
+ contributions:
4
+ commands:
5
+ - id: itasc-aggregate.widget
6
+ title: "ITASC Aggregate"
7
+ python_name: itasc.napari.main_widget:make_aggregate_app_widget
8
+ widgets:
9
+ - command: itasc-aggregate.widget
10
+ display_name: "ITASC Aggregate"