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,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"
|