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,430 @@
1
+ """Persistent validation metadata for the nucleus workflow.
2
+
3
+ All functions take ``nucleus_dir`` — the directory that holds the nucleus
4
+ annotation store (``validated_frames.json``, ``validated_cells.json``,
5
+ ``corrections.json``). In the full orchestrator this is ``<pos>/2_nucleus``; the
6
+ standalone tracking piece points it at a flat working directory. The annotation
7
+ files live directly in that directory.
8
+
9
+ Frame-level validation (validated_frames.json):
10
+ A "fully-validated" frame is one where every current (non-zero) cell ID has
11
+ been individually validated. The file acts as a *cache* so UI counters can
12
+ count fully-validated frames without scanning the whole stack.
13
+
14
+ Schema: JSON array of ints, e.g. [0, 3, 7].
15
+
16
+ Track-level validation (validated_cells.json):
17
+ Tracks which frames have been validated for each cell (track) ID.
18
+
19
+ Schema: JSON object keyed by cell ID string, value is a list of frame ints,
20
+ e.g. {"47": [10, 11, 12], "82": [3, 4, 5]}.
21
+ Cell IDs with no validated frames are omitted entirely (sparse).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+ from collections.abc import Iterable
28
+
29
+ import numpy as np
30
+
31
+ from itasc.tracking_ultrack.corrections import Correction
32
+
33
+ # Stat-keyed parse caches: hot overlay loops call the readers below repeatedly,
34
+ # and re-parsing unchanged JSON each time is the cost the review flagged. Key on
35
+ # the file's (mtime_ns, size); a writer touching the file changes the signature
36
+ # and invalidates the entry. Callers always receive a fresh copy, so the cached
37
+ # value is never mutated through aliasing.
38
+ _corrections_cache: dict[Path, tuple[tuple[int, int], list[Correction]]] = {}
39
+ _legacy_tracks_cache: dict[Path, tuple[tuple[int, int], dict[int, set[int]]]] = {}
40
+
41
+
42
+ def _stat_signature(path: Path) -> tuple[int, int] | None:
43
+ try:
44
+ st = path.stat()
45
+ except OSError:
46
+ return None
47
+ return (st.st_mtime_ns, st.st_size)
48
+
49
+
50
+ def _path(nucleus_dir: Path) -> Path:
51
+ return Path(nucleus_dir) / "validated_frames.json"
52
+
53
+
54
+ def read_validated_frames(nucleus_dir: Path) -> set[int]:
55
+ """Return the set of validated frame indices, or an empty set if none."""
56
+ p = _path(nucleus_dir)
57
+ if not p.exists():
58
+ return set()
59
+ try:
60
+ data = json.loads(p.read_text())
61
+ return set(int(t) for t in data)
62
+ except Exception:
63
+ return set()
64
+
65
+
66
+ def write_validated_frames(nucleus_dir: Path, frames: set[int]) -> None:
67
+ """Persist the full set of validated frames."""
68
+ p = _path(nucleus_dir)
69
+ p.parent.mkdir(parents=True, exist_ok=True)
70
+ p.write_text(json.dumps(sorted(frames)))
71
+
72
+
73
+ def validate_frame(nucleus_dir: Path, t: int) -> None:
74
+ """Mark frame t as validated."""
75
+ frames = read_validated_frames(nucleus_dir)
76
+ frames.add(t)
77
+ write_validated_frames(nucleus_dir, frames)
78
+
79
+
80
+ def invalidate_frame(nucleus_dir: Path, t: int) -> None:
81
+ """Remove the validated mark from frame t."""
82
+ frames = read_validated_frames(nucleus_dir)
83
+ frames.discard(t)
84
+ write_validated_frames(nucleus_dir, frames)
85
+
86
+
87
+ def is_validated(nucleus_dir: Path, t: int) -> bool:
88
+ """Return True if frame t is in the validated set."""
89
+ return t in read_validated_frames(nucleus_dir)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Track-level validation (nucleus workflow)
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _cells_path(nucleus_dir: Path) -> Path:
97
+ return Path(nucleus_dir) / "validated_cells.json"
98
+
99
+
100
+ def read_validated_tracks(nucleus_dir: Path) -> dict[int, set[int]]:
101
+ """Return {cell_id: {frames}} for all validated tracks.
102
+
103
+ Empty dict if the file is missing or corrupt.
104
+ JSON keys are cell ID strings; values are lists of frame ints.
105
+ """
106
+ corrections = read_corrections(nucleus_dir)
107
+ if corrections:
108
+ data: dict[int, set[int]] = {}
109
+ for correction in corrections:
110
+ if correction.kind == "validated":
111
+ data.setdefault(int(correction.cell_id), set()).add(int(correction.t))
112
+ legacy = _read_legacy_validated_tracks(nucleus_dir)
113
+ for cell_id, frames in legacy.items():
114
+ data.setdefault(cell_id, set()).update(frames)
115
+ return data
116
+ return _read_legacy_validated_tracks(nucleus_dir)
117
+
118
+
119
+ def _copy_tracks(data: dict[int, set[int]]) -> dict[int, set[int]]:
120
+ """Deep-ish copy so a cached map is never mutated through the returned value."""
121
+ return {cell_id: set(frames) for cell_id, frames in data.items()}
122
+
123
+
124
+ def _read_legacy_validated_tracks(nucleus_dir: Path) -> dict[int, set[int]]:
125
+ p = _cells_path(nucleus_dir)
126
+ sig = _stat_signature(p)
127
+ if sig is None:
128
+ _legacy_tracks_cache.pop(p, None)
129
+ return {}
130
+ hit = _legacy_tracks_cache.get(p)
131
+ if hit is not None and hit[0] == sig:
132
+ return _copy_tracks(hit[1])
133
+ try:
134
+ raw: dict = json.loads(p.read_text())
135
+ parsed = {int(k): set(int(f) for f in vs) for k, vs in raw.items() if vs}
136
+ except Exception:
137
+ return {}
138
+ _legacy_tracks_cache[p] = (sig, parsed)
139
+ return _copy_tracks(parsed)
140
+
141
+
142
+ def _write_validated_tracks(nucleus_dir: Path, data: dict[int, set[int]]) -> None:
143
+ """Persist the full {cell_id: {frames}} map. Entries with empty sets are dropped."""
144
+ p = _cells_path(nucleus_dir)
145
+ p.parent.mkdir(parents=True, exist_ok=True)
146
+ serialisable = {
147
+ str(cell_id): sorted(frames)
148
+ for cell_id, frames in data.items()
149
+ if frames
150
+ }
151
+ p.write_text(json.dumps(serialisable))
152
+ # Invalidate the parse cache explicitly: two same-size writes within the
153
+ # filesystem's mtime granularity (coarse on Windows/FAT/NFS) leave the
154
+ # (mtime_ns, size) signature unchanged, so a stale entry would survive.
155
+ _legacy_tracks_cache.pop(p, None)
156
+
157
+
158
+ def read_validated_cells_at_frame(nucleus_dir: Path, t: int) -> set[int]:
159
+ """Return all cell IDs that have frame *t* in their validated set.
160
+
161
+ Derived from the track-keyed store; suitable for overlay rendering.
162
+ """
163
+ return {cell_id for cell_id, frames in read_validated_tracks(nucleus_dir).items() if t in frames}
164
+
165
+
166
+ def is_track_validated(nucleus_dir: Path, cell_id: int) -> bool:
167
+ """Return True if *cell_id* has any entry in the validated-tracks store."""
168
+ return cell_id in read_validated_tracks(nucleus_dir)
169
+
170
+
171
+ def validate_track(nucleus_dir: Path, cell_id: int, frames: Iterable[int]) -> None:
172
+ """Add the given frames to *cell_id*'s validated set (idempotent, accumulates).
173
+
174
+ Creates an entry for *cell_id* if none exists yet. Any existing per-frame
175
+ anchor corrections for the same cell are dropped — whole-track validation
176
+ supersedes them (the post-solve paste-back stamps the validated geometry,
177
+ so anchor pins on the same cell are redundant).
178
+ """
179
+ frames_set = set(frames)
180
+ if not frames_set:
181
+ return
182
+ data = read_validated_tracks(nucleus_dir)
183
+ existing = data.get(cell_id, set())
184
+ data[cell_id] = existing | frames_set
185
+ _write_validated_tracks(nucleus_dir, data)
186
+
187
+ corrections = read_corrections(nucleus_dir)
188
+ if corrections:
189
+ filtered = [
190
+ c
191
+ for c in corrections
192
+ if not (int(c.cell_id) == int(cell_id) and c.kind == "anchor")
193
+ ]
194
+ if len(filtered) != len(corrections):
195
+ write_corrections(nucleus_dir, filtered)
196
+
197
+
198
+
199
+ def invalidate_track(nucleus_dir: Path, cell_id: int) -> None:
200
+ """Remove the entire entry for *cell_id* from the validated-tracks store.
201
+
202
+ No-op if *cell_id* is not present.
203
+ """
204
+ corrections = read_corrections(nucleus_dir)
205
+ if corrections:
206
+ write_corrections(
207
+ nucleus_dir,
208
+ [
209
+ c
210
+ for c in corrections
211
+ if not (int(c.cell_id) == int(cell_id) and c.kind == "validated")
212
+ ],
213
+ )
214
+ data = read_validated_tracks(nucleus_dir)
215
+ if cell_id in data:
216
+ del data[cell_id]
217
+ _write_validated_tracks(nucleus_dir, data)
218
+
219
+
220
+ def remap_validated_tracks(nucleus_dir: Path, old_to_new: dict[int, int]) -> None:
221
+ """Remap cell IDs in the validated-tracks store using *old_to_new* mapping.
222
+
223
+ IDs not present in the mapping are dropped.
224
+ """
225
+ corrections = read_corrections(nucleus_dir)
226
+ # Read the legacy store *directly* (not via ``read_validated_tracks``, which
227
+ # merges in the corrections we are about to rewrite) so each store is remapped
228
+ # exactly once. Reading the merged view here would remap the corrections-derived
229
+ # IDs a second time and inject phantom validations whenever the mapping is not
230
+ # the identity — i.e. for any real contiguous compaction with gaps.
231
+ legacy = _read_legacy_validated_tracks(nucleus_dir)
232
+ if corrections:
233
+ write_corrections(
234
+ nucleus_dir,
235
+ [
236
+ Correction(
237
+ cell_id=int(old_to_new[int(c.cell_id)]),
238
+ t=int(c.t),
239
+ kind=c.kind,
240
+ y=float(c.y),
241
+ x=float(c.x),
242
+ )
243
+ for c in corrections
244
+ if int(c.cell_id) in old_to_new
245
+ ],
246
+ )
247
+ remapped = {
248
+ old_to_new[cell_id]: frames
249
+ for cell_id, frames in legacy.items()
250
+ if cell_id in old_to_new
251
+ }
252
+ _write_validated_tracks(nucleus_dir, remapped)
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Unified correction list (nucleus workflow)
257
+ # ---------------------------------------------------------------------------
258
+
259
+ def _corrections_path(nucleus_dir: Path) -> Path:
260
+ return Path(nucleus_dir) / "corrections.json"
261
+
262
+
263
+ def read_corrections(nucleus_dir: Path) -> list[Correction]:
264
+ """Return persisted per-frame corrections, or an empty list if unavailable."""
265
+ p = _corrections_path(nucleus_dir)
266
+ sig = _stat_signature(p)
267
+ if sig is None:
268
+ _corrections_cache.pop(p, None)
269
+ return []
270
+ hit = _corrections_cache.get(p)
271
+ if hit is not None and hit[0] == sig:
272
+ return list(hit[1]) # fresh list of immutable Corrections
273
+ try:
274
+ raw = json.loads(p.read_text())
275
+ corrections = [
276
+ Correction(
277
+ cell_id=int(item["cell_id"]),
278
+ t=int(item["t"]),
279
+ kind=item["kind"],
280
+ y=float(item["y"]),
281
+ x=float(item["x"]),
282
+ )
283
+ for item in raw
284
+ ]
285
+ except Exception:
286
+ return []
287
+ result = sorted(corrections, key=lambda c: (c.t, c.cell_id, c.kind))
288
+ _corrections_cache[p] = (sig, result)
289
+ return list(result)
290
+
291
+
292
+ def write_corrections(nucleus_dir: Path, corrections: Iterable[Correction]) -> None:
293
+ """Persist the full flat correction list to ``corrections.json``."""
294
+ p = _corrections_path(nucleus_dir)
295
+ p.parent.mkdir(parents=True, exist_ok=True)
296
+ serialisable = [
297
+ {
298
+ "cell_id": int(c.cell_id),
299
+ "t": int(c.t),
300
+ "kind": c.kind,
301
+ "y": float(c.y),
302
+ "x": float(c.x),
303
+ }
304
+ for c in sorted(corrections, key=lambda item: (item.t, item.cell_id, item.kind))
305
+ ]
306
+ p.write_text(json.dumps(serialisable))
307
+ # See _write_validated_tracks: same-size writes can share a (mtime_ns, size)
308
+ # signature on coarse-mtime filesystems, so invalidate the cache explicitly.
309
+ _corrections_cache.pop(p, None)
310
+
311
+
312
+ def add_correction(nucleus_dir: Path, correction: Correction) -> None:
313
+ """Add or replace a correction for the same cell, frame, and kind.
314
+
315
+ Writing a ``validated`` correction for a cell additionally drops every
316
+ ``anchor`` correction for that cell — validation is whole-track and
317
+ supersedes any anchor pinning on the same cell.
318
+ """
319
+ add_corrections(nucleus_dir, [correction])
320
+
321
+
322
+ def add_corrections(nucleus_dir: Path, corrections: Iterable[Correction]) -> None:
323
+ """Add or replace several corrections with a single read+write.
324
+
325
+ Each correction replaces any existing one for the same cell, frame, and
326
+ kind (later entries in *corrections* win over earlier ones). As with
327
+ :func:`add_correction`, a ``validated`` correction for a cell additionally
328
+ drops every ``anchor`` correction for that cell.
329
+
330
+ Unlike calling :func:`add_correction` in a loop, this reads and writes
331
+ ``corrections.json`` exactly once, so validating a long track stays cheap
332
+ instead of re-parsing and re-serialising the whole file per frame.
333
+ """
334
+ new = list(corrections)
335
+ if not new:
336
+ return
337
+
338
+ # Keys (cell_id, t, kind) being written; cell_ids whose anchors validation drops.
339
+ replaced_keys = {(int(c.cell_id), int(c.t), c.kind) for c in new}
340
+ validated_cells = {int(c.cell_id) for c in new if c.kind == "validated"}
341
+
342
+ def _keep(c: Correction) -> bool:
343
+ if (int(c.cell_id), int(c.t), c.kind) in replaced_keys:
344
+ return False
345
+ if c.kind == "anchor" and int(c.cell_id) in validated_cells:
346
+ return False
347
+ return True
348
+
349
+ existing = [c for c in read_corrections(nucleus_dir) if _keep(c)]
350
+
351
+ # Dedupe within the batch itself (last wins for a repeated key).
352
+ deduped: dict[tuple[int, int, str], Correction] = {}
353
+ for c in new:
354
+ deduped[(int(c.cell_id), int(c.t), c.kind)] = c
355
+
356
+ write_corrections(nucleus_dir, existing + list(deduped.values()))
357
+
358
+
359
+ def _centroid_of(mask: np.ndarray) -> tuple[float, float] | None:
360
+ if mask.ndim == 3:
361
+ mask = mask.any(axis=0)
362
+ ys, xs = np.nonzero(mask)
363
+ if ys.size == 0:
364
+ return None
365
+ return float(ys.mean()), float(xs.mean())
366
+
367
+
368
+ def add_anchor(
369
+ nucleus_dir: Path,
370
+ cell_id: int,
371
+ t: int,
372
+ y: float,
373
+ x: float,
374
+ tracked_labels: np.ndarray,
375
+ ) -> int:
376
+ """Add an anchor for *cell_id* at frame *t* and back-fill intermediate frames.
377
+
378
+ If another anchor for the same cell already exists at some frame ``A``,
379
+ find the nearest such ``A`` (smallest ``|A - t|``). If ``cell_id`` is
380
+ present in ``tracked_labels`` at *every* frame in ``[min(A, t), max(A, t)]``,
381
+ add an anchor correction for each intermediate frame using the cell's
382
+ centroid in ``tracked_labels`` at that frame.
383
+
384
+ Returns the number of intermediate anchors added (0 if no neighbor exists
385
+ or the track is not consecutive).
386
+
387
+ The strictness rule guarantees we never anchor onto a frame where the cell
388
+ isn't actually present in the corrected layer.
389
+ """
390
+ cell_id = int(cell_id)
391
+ t = int(t)
392
+
393
+ existing = read_corrections(nucleus_dir)
394
+ other_anchor_frames = sorted(
395
+ int(c.t)
396
+ for c in existing
397
+ if int(c.cell_id) == cell_id and c.kind == "anchor" and int(c.t) != t
398
+ )
399
+
400
+ add_correction(
401
+ nucleus_dir,
402
+ Correction(cell_id=cell_id, t=t, kind="anchor", y=float(y), x=float(x)),
403
+ )
404
+
405
+ if not other_anchor_frames:
406
+ return 0
407
+
408
+ neighbor = min(other_anchor_frames, key=lambda f: (abs(f - t), f))
409
+ lo, hi = (neighbor, t) if neighbor < t else (t, neighbor)
410
+ interior = list(range(lo + 1, hi))
411
+ if not interior:
412
+ return 0
413
+
414
+ n_frames = int(tracked_labels.shape[0])
415
+ centroids: dict[int, tuple[float, float]] = {}
416
+ for f in interior:
417
+ if f < 0 or f >= n_frames:
418
+ return 0
419
+ mask = np.asarray(tracked_labels[f] == cell_id)
420
+ c = _centroid_of(mask)
421
+ if c is None:
422
+ return 0
423
+ centroids[f] = c
424
+
425
+ for f, (cy, cx) in centroids.items():
426
+ add_correction(
427
+ nucleus_dir,
428
+ Correction(cell_id=cell_id, t=f, kind="anchor", y=cy, x=cx),
429
+ )
430
+ return len(centroids)
@@ -0,0 +1,45 @@
1
+ name: itasc
2
+ display_name: ITASC
3
+ contributions:
4
+ commands:
5
+ - id: itasc.main_widget
6
+ title: "ITASC"
7
+ python_name: itasc.napari:ITASCWidget
8
+ # The full install folds the extracted tools' widgets into this single
9
+ # manifest. npe2 requires a manifest's name to match the providing
10
+ # distribution, so the standalone itasc-cellpose / itasc-tracking /
11
+ # itasc-aggregate manifests cannot be re-registered from the itasc
12
+ # distribution; their contributions are mirrored here instead. The integrated
13
+ # ITASC app's own per-stage widgets (Cellpose map-building, Cell
14
+ # Segmentation) live inside the ITASC widget above and are not exposed as
15
+ # separate menu entries.
16
+ #
17
+ # The itasc-cellpose distribution's *standalone* surface — native-mask
18
+ # segment + laptrack track + embedded basic correction. It is not part of the
19
+ # integrated pipeline (the pipeline uses the Cellpose widget above); it is
20
+ # mirrored here only so the full install can also reach the standalone tool.
21
+ - id: itasc.cellpose_segment_track_widget
22
+ title: "Cellpose Segment + Track"
23
+ python_name: itasc.napari.cellpose_segment_track_widget:make_cellpose_segment_track_widget
24
+ - id: itasc.nucleus_tracking_widget
25
+ title: "Ultrack Segment + Track"
26
+ python_name: itasc.napari.nucleus_workflow_widget:make_nucleus_tracking_widget
27
+ # The ITASC Aggregate app: the full catalog UI restricted to the
28
+ # Contact Analysis stage + Aggregate capstone (three-dot rail: cell labels,
29
+ # nucleus labels, contact analysis). Both the full install and the standalone
30
+ # itasc-aggregate wheel resolve this same factory; the standalone simply
31
+ # lacks the upstream segmentation/tracking distributions, so it always comes
32
+ # up contact-only. In the full install this is the slim aggregate-focused
33
+ # entry alongside the complete ITASC widget above.
34
+ - id: itasc.contact_analysis_widget
35
+ title: "ITASC Aggregate"
36
+ python_name: itasc.napari.main_widget:make_aggregate_app_widget
37
+ widgets:
38
+ - command: itasc.main_widget
39
+ display_name: "ITASC"
40
+ - command: itasc.cellpose_segment_track_widget
41
+ display_name: "Cellpose Segment + Track"
42
+ - command: itasc.nucleus_tracking_widget
43
+ display_name: "Ultrack Segment + Track"
44
+ - command: itasc.contact_analysis_widget
45
+ display_name: "ITASC Aggregate"
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.4
2
+ Name: itasc
3
+ Version: 0.2.0.dev0
4
+ Summary: Fast, interactive cell tracking
5
+ Project-URL: Homepage, https://github.com/ArturRuppel/ITASC
6
+ Project-URL: Repository, https://github.com/ArturRuppel/ITASC
7
+ Project-URL: Issues, https://github.com/ArturRuppel/ITASC/issues
8
+ Author-email: Artur Ruppel <artur@ruppel.pro>
9
+ License: AGPL-3.0
10
+ License-File: LICENSE
11
+ Keywords: cell-tracking,microscopy,napari,segmentation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: napari
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
24
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: h5py
27
+ Requires-Dist: matplotlib>=3.9.4
28
+ Requires-Dist: napari[all]>=0.4.18
29
+ Requires-Dist: numba
30
+ Requires-Dist: numpy
31
+ Requires-Dist: pandas
32
+ Requires-Dist: pyarrow
33
+ Requires-Dist: pydantic
34
+ Requires-Dist: pymaxflow>=1.3.2
35
+ Requires-Dist: pyqtgraph>=0.13
36
+ Requires-Dist: qtpy>=2.3.0
37
+ Requires-Dist: scikit-image
38
+ Requires-Dist: scipy
39
+ Requires-Dist: seaborn>=0.13
40
+ Requires-Dist: sqlalchemy
41
+ Requires-Dist: tifffile
42
+ Provides-Extra: all
43
+ Requires-Dist: cellpose>=4.0; extra == 'all'
44
+ Requires-Dist: torch; extra == 'all'
45
+ Requires-Dist: torchvision; extra == 'all'
46
+ Requires-Dist: ultrack; extra == 'all'
47
+ Provides-Extra: cellpose
48
+ Requires-Dist: cellpose>=4.0; extra == 'cellpose'
49
+ Requires-Dist: torch; extra == 'cellpose'
50
+ Requires-Dist: torchvision; extra == 'cellpose'
51
+ Provides-Extra: dev
52
+ Requires-Dist: pytest>=8; extra == 'dev'
53
+ Requires-Dist: ruff>=0.11; extra == 'dev'
54
+ Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'dev'
55
+ Provides-Extra: docs
56
+ Requires-Dist: furo>=2024.1; extra == 'docs'
57
+ Requires-Dist: myst-parser>=2; extra == 'docs'
58
+ Requires-Dist: sphinx-autobuild>=2024.2; extra == 'docs'
59
+ Requires-Dist: sphinx-autodoc-typehints>=1.25; extra == 'docs'
60
+ Requires-Dist: sphinx-design>=0.5; extra == 'docs'
61
+ Requires-Dist: sphinx>=7; extra == 'docs'
62
+ Requires-Dist: sphinxcontrib-mermaid>=0.9; extra == 'docs'
63
+ Provides-Extra: test
64
+ Requires-Dist: pytest>=8; extra == 'test'
65
+ Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'test'
66
+ Provides-Extra: tracking
67
+ Requires-Dist: ultrack; extra == 'tracking'
68
+ Description-Content-Type: text/markdown
69
+
70
+ # ITASC
71
+
72
+ Segment, track, correct, and quantify cells in time-lapse microscopy, inside napari.
73
+
74
+ ITASC (Interactive Tracking And Segmentation of Cells) takes raw time-lapse
75
+ stacks to tracked, quantified cells. It segments each frame, links cells across
76
+ time, lets you correct the result by hand where the automatics miss, and
77
+ measures what the tracked cells do. It is a [napari](https://napari.org) plugin
78
+ built for dense, motile monolayers, where segmentation and tracking are the hard
79
+ part.
80
+
81
+ <!-- hero-start -->
82
+ <p align="center">
83
+ <img src="docs/_static/napari_timelapse_last.png"
84
+ alt="ITASC tracking a monolayer in napari" width="100%">
85
+ </p>
86
+ <!-- hero-end -->
87
+
88
+ ## What it does
89
+
90
+ An ITASC run moves through four stages, each usable on its own:
91
+
92
+ - **Segment** each frame with [Cellpose-SAM](https://github.com/MouseLand/cellpose).
93
+ For sparse, well-separated cells its masks are the result; for a dense
94
+ monolayer its probability and flow output becomes divergence images that
95
+ separate crowded, variable-shape cells and form the input the tracker runs on.
96
+ - **Track** across time: [Ultrack](https://github.com/royerlab/ultrack) for
97
+ dense monolayers, [LapTrack](https://github.com/yfukai/laptrack) for sparse,
98
+ well-separated cells.
99
+ - **Correct** tracks and labels interactively, with editing tools adapted from
100
+ [EpiCure](https://github.com/Image-Analysis-Hub/Epicure), where the automatic
101
+ result is wrong.
102
+ - **Quantify** what the tracked cells do, in two outputs:
103
+ - cell-cell contacts and edges, identified and tracked through T1 transitions,
104
+ written to a self-describing HDF5 (`.h5`) file.
105
+ - aggregate analysis of tracked nuclei, cell bodies, and cell-cell contacts
106
+ (shape and dynamics over time), exported to `.csv` tables.
107
+
108
+ ## How it is organized
109
+
110
+ ITASC comes in two forms.
111
+
112
+ The **full app** is the integrated pipeline. A project is a directory with one
113
+ subfolder per position, and the four stages hand off through the `.tif` and HDF5
114
+ files on disk, so you can enter wherever your data already sits or pick up a
115
+ project someone else started.
116
+
117
+ The **solo distributions** are separate, smaller napari tools, each built for one
118
+ job with its own interface: `itasc-cellpose` segments and tracks channels
119
+ straight into viewer layers, `itasc-tracking` turns foreground and contour maps
120
+ into corrected Ultrack tracks, and `itasc-aggregate` quantifies contacts and T1
121
+ events from finished labels. They are not the full app with stages switched off:
122
+ each ships only the code its job needs and a leaner interface to match.
123
+
124
+ Pick the one that matches the data you have. Each row links to that tool's guide,
125
+ which covers how to install it and how to use it.
126
+
127
+ | If you have… | Reach for | It gives you |
128
+ | --- | --- | --- |
129
+ | **Dense, motile cells of varying shape** (a confluent monolayer), from raw stacks to quantified contacts | [itasc\[all\]](docs/manual/full-app.md) | The unified `ITASC` workflow widget, every stage end to end. |
130
+ | **Sparse, well-separated cells** with a cell and/or nucleus marker, to segment and track one or both channels | [itasc-cellpose](docs/manual/cellpose.md) | A local Cellpose-SAM runner for segmentation, then `laptrack` linking across time, plus manual correction of tracks and masks (adapted from [EpiCure](https://github.com/Image-Analysis-Hub/Epicure)). One channel or two. |
131
+ | **Foreground and contour maps already**, to skip the cellpose step | [itasc-tracking](docs/manual/tracking.md) | Ultrack candidate database, solving, browsing, and interactive segmentation and tracking correction. |
132
+ | **Tracked cell labels already**, and you want the aggregate quantification | [itasc-aggregate](docs/manual/aggregate.md) | Cell-cell edges, border edges, and T1 events to HDF5, aggregate quantification to `.csv`. |
133
+ | **Code to build on** | [itasc-core](docs/manual/core.md) | TIFF/path/label-IO helpers, the lineage model, and napari UI primitives. |
134
+
135
+ ## Built on
136
+
137
+ ITASC reuses the published methods of four tools. If you use the stage that
138
+ depends on one, please cite it:
139
+
140
+ - **Cellpose-SAM** (segmentation): Pachitariu M, Rariden M, Stringer C.
141
+ *Cellpose-SAM: superhuman generalization for cellular segmentation.* bioRxiv
142
+ (2025). [doi:10.1101/2025.04.28.651001](https://doi.org/10.1101/2025.04.28.651001)
143
+ · [MouseLand/cellpose](https://github.com/MouseLand/cellpose)
144
+ - **Ultrack** (dense tracking): Bragantini J, et al. *Ultrack: pushing the
145
+ limits of cell tracking across biological scales.* Nature Methods (2025).
146
+ [doi:10.1038/s41592-025-02778-0](https://doi.org/10.1038/s41592-025-02778-0)
147
+ · [royerlab/ultrack](https://github.com/royerlab/ultrack)
148
+ - **LapTrack** (sparse tracking): Fukai YT, Kawaguchi K. *LapTrack: linear
149
+ assignment particle tracking with tunable metrics.* Bioinformatics 39(1),
150
+ btac799 (2023). [doi:10.1093/bioinformatics/btac799](https://doi.org/10.1093/bioinformatics/btac799)
151
+ · [yfukai/laptrack](https://github.com/yfukai/laptrack)
152
+ - **EpiCure** (correction tools): Letort G. *EpiCure: a versatile and handy tool
153
+ for curation of epithelial segmentation.* bioRxiv (2026).
154
+ [doi:10.64898/2026.03.27.714683](https://doi.org/10.64898/2026.03.27.714683)
155
+ · [Image-Analysis-Hub/Epicure](https://github.com/Image-Analysis-Hub/Epicure)
156
+
157
+ ## Documentation
158
+
159
+ - [User guide](docs/index.md): install, the staged workflow, and driving the
160
+ plugin.
161
+ - [API reference](docs/api/index.md): the programmatic API, generated
162
+ from the source.
163
+
164
+ ## Status
165
+
166
+ ITASC is approaching its first public release and JOSS submission. The four
167
+ stages and the file-based project layout are settled and in active research use.
168
+ Installation and the public API are close to final: expect small changes before
169
+ the release and its accompanying manuscript.
170
+
171
+ ## Citing ITASC
172
+
173
+ Cite the software using the metadata in [`CITATION.cff`](CITATION.cff). A DOI
174
+ and manuscript citation will be added with the public release. For
175
+ pre-publication citation questions, contact Artur Ruppel at `artur@ruppel.pro`.
176
+
177
+ ## License
178
+
179
+ AGPL-3.0. See [`LICENSE`](LICENSE).
180
+
181
+ ## AI usage
182
+
183
+ Generative AI tools (OpenAI GPT and Anthropic Claude) assisted with code
184
+ drafting, refactoring, tests, debugging, and documentation. Human authors made
185
+ the scientific, architectural, and design decisions, and are fully responsible
186
+ for all code and other content in the repository.