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,616 @@
1
+ """Shared reusable Qt widgets for the ITASC napari plugin."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from qtpy.QtCore import Qt, QTimer, Signal
7
+ from qtpy.QtWidgets import (
8
+ QFrame,
9
+ QHBoxLayout,
10
+ QLabel,
11
+ QPushButton,
12
+ QSizePolicy,
13
+ QToolButton,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+ from .ui_style import (
19
+ SECTION_MARGIN,
20
+ TIGHT_SPACING,
21
+ TINY_MARGIN,
22
+ icon_button,
23
+ muted_accent,
24
+ muted_label,
25
+ stage_header_action_button,
26
+ stage_header_label,
27
+ status_label,
28
+ )
29
+ from ._widget_helpers import tool_btn
30
+
31
+
32
+ class CollapsibleSection(QWidget):
33
+ """A labelled section with a toggle button that shows/hides its inner widget."""
34
+
35
+ def __init__(
36
+ self,
37
+ title: str,
38
+ inner: QWidget,
39
+ expanded: bool = False,
40
+ parent: QWidget | None = None,
41
+ title_color: str | None = None,
42
+ accent_color: str | None = None,
43
+ ) -> None:
44
+ super().__init__(parent)
45
+ self._inner = inner
46
+ self._base_title = title
47
+ self._default_title_color: str | None = title_color
48
+ # An explicit accent_color marks this as the OUTER stage anchor: stripe
49
+ # is thicker and the header text uses the full accent hue. Inner sections
50
+ # leave accent_color=None and inherit a muted variant via parent walk.
51
+ self._explicit_accent: str | None = accent_color
52
+ self._effective_accent: str | None = accent_color
53
+ self._is_outer_accent: bool = accent_color is not None
54
+
55
+ layout = QVBoxLayout(self)
56
+ layout.setContentsMargins(0, TINY_MARGIN, 0, TINY_MARGIN)
57
+ layout.setSpacing(0)
58
+
59
+ # Header toggle button
60
+ self._toggle = QToolButton()
61
+ self._toggle.setObjectName("collapsible_toggle")
62
+ self._toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
63
+ self._toggle.setCheckable(True)
64
+ self._toggle.setChecked(expanded)
65
+ self._toggle.setText(self._qt_display_text(title))
66
+ self._toggle.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
67
+ self._toggle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
68
+ self._toggle.toggled.connect(self._on_toggled)
69
+
70
+ self._status: str | None = None
71
+
72
+ header_row = QHBoxLayout()
73
+ header_row.setContentsMargins(0, 0, 0, 0)
74
+ header_row.setSpacing(4)
75
+ header_row.addWidget(self._toggle, 1)
76
+ layout.addLayout(header_row)
77
+
78
+ self._content_frame = QFrame()
79
+ self._content_frame.setObjectName("collapsible_content")
80
+ self._content_frame.setFrameShape(QFrame.NoFrame)
81
+ frame_layout = QVBoxLayout(self._content_frame)
82
+ frame_layout.setContentsMargins(
83
+ SECTION_MARGIN, SECTION_MARGIN, SECTION_MARGIN, SECTION_MARGIN
84
+ )
85
+ frame_layout.setSpacing(TINY_MARGIN)
86
+ frame_layout.addWidget(inner)
87
+
88
+ self._content_frame.setVisible(expanded)
89
+ layout.addWidget(self._content_frame)
90
+
91
+ # Always Preferred policy — height is driven by scroll area's minimumHeight
92
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
93
+
94
+ self._apply_accent_styles()
95
+ # If no explicit accent, defer a parent walk to inherit one if present.
96
+ if self._explicit_accent is None:
97
+ QTimer.singleShot(0, self._maybe_inherit_accent)
98
+
99
+ if expanded:
100
+ QTimer.singleShot(0, self._notify_layout_change)
101
+
102
+ def _apply_accent_styles(self) -> None:
103
+ """(Re)apply header + content-frame stylesheets from current accent state."""
104
+ accent = self._effective_accent
105
+ if accent is None:
106
+ title_color = self._default_title_color
107
+ font_size_pt = 10
108
+ frame_qss = (
109
+ "QFrame#collapsible_content { border: 1px solid #666666; "
110
+ "border-radius: 4px; margin: 0px 2px 2px 2px; }"
111
+ )
112
+ else:
113
+ if self._is_outer_accent:
114
+ title_color = accent
115
+ font_size_pt = 11
116
+ else:
117
+ title_color = self._muted_accent(accent)
118
+ font_size_pt = 9
119
+ # Only the coloured left stripe — no gray frame around the content,
120
+ # since the stripe alone is enough to delimit the section.
121
+ frame_qss = (
122
+ "QFrame#collapsible_content { "
123
+ "border: none; "
124
+ f"border-left: 2px solid {title_color}; "
125
+ "border-radius: 0px; "
126
+ "margin: 0px 2px 2px 2px; "
127
+ "}"
128
+ )
129
+ color_rule = f"color: {title_color}; " if title_color else ""
130
+ self._toggle.setStyleSheet(
131
+ "QToolButton#collapsible_toggle { "
132
+ f"font-weight: bold; font-size: {font_size_pt}pt; border: none; "
133
+ f"padding: 2px; {color_rule}"
134
+ "}"
135
+ )
136
+ self._content_frame.setStyleSheet(frame_qss)
137
+
138
+ def _maybe_inherit_accent(self) -> None:
139
+ """Walk up the parent chain and pick up the nearest ancestor's accent."""
140
+ if self._explicit_accent is not None:
141
+ return
142
+ ancestor_color = self._find_ancestor_accent_color()
143
+ if ancestor_color is None or ancestor_color == self._effective_accent:
144
+ return
145
+ self._effective_accent = ancestor_color
146
+ self._is_outer_accent = False
147
+ self._apply_accent_styles()
148
+
149
+ def set_accent_color(self, accent_color: str | None) -> None:
150
+ """Set this section's explicit accent and refresh inherited child accents."""
151
+ self._explicit_accent = accent_color
152
+ self._effective_accent = accent_color
153
+ self._is_outer_accent = accent_color is not None
154
+ self._apply_accent_styles()
155
+ self._refresh_descendant_inherited_accents()
156
+
157
+ def _refresh_descendant_inherited_accents(self) -> None:
158
+ for child in self.findChildren(CollapsibleSection):
159
+ if child._explicit_accent is not None:
160
+ continue
161
+ ancestor_color = child._find_ancestor_accent_color()
162
+ child._effective_accent = ancestor_color
163
+ child._is_outer_accent = False
164
+ child._apply_accent_styles()
165
+
166
+ def _find_ancestor_accent_color(self) -> str | None:
167
+ parent = self.parent()
168
+ while parent is not None:
169
+ if isinstance(parent, CollapsibleSection):
170
+ if parent._effective_accent is not None:
171
+ return parent._effective_accent
172
+ parent = parent.parent()
173
+ return None
174
+
175
+ @staticmethod
176
+ def _muted_accent(hex_str: str) -> str:
177
+ """Desaturate and flatten lightness so inner headers read as 'same hue,
178
+ quieter' rather than as a separate color."""
179
+ return muted_accent(hex_str)
180
+
181
+ def set_header_visible(self, visible: bool) -> None:
182
+ """Show or hide the built-in toggle header row."""
183
+ self._toggle.setVisible(visible)
184
+
185
+ def set_title(self, title: str) -> None:
186
+ """Update the header text."""
187
+ self._base_title = title
188
+ self._toggle.setText(self._qt_display_text(title))
189
+
190
+ def set_status(self, status: str | None) -> None:
191
+ """Store section status for callers without rendering a header indicator."""
192
+ self._status = status
193
+
194
+ @property
195
+ def status(self) -> str | None:
196
+ return self._status
197
+
198
+ @property
199
+ def title(self) -> str:
200
+ return self._base_title
201
+
202
+ @property
203
+ def is_expanded(self) -> bool:
204
+ return self._toggle.isChecked()
205
+
206
+ def expand(self) -> None:
207
+ self._toggle.setChecked(True)
208
+
209
+ def collapse(self) -> None:
210
+ self._toggle.setChecked(False)
211
+
212
+ def _on_toggled(self, checked: bool) -> None:
213
+ self._toggle.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
214
+ self._content_frame.setVisible(checked)
215
+ QTimer.singleShot(0, self._notify_layout_change)
216
+
217
+ @staticmethod
218
+ def _qt_display_text(title: str) -> str:
219
+ """Escape mnemonic markers so literal ampersands render correctly."""
220
+ return title.replace("&", "&&")
221
+
222
+ def _notify_layout_change(self) -> None:
223
+ """Propagate geometry changes up the nested collapsible chain."""
224
+ self.updateGeometry()
225
+ parent = self.parent()
226
+ while parent is not None:
227
+ if isinstance(parent, CollapsibleSection) and parent.is_expanded:
228
+ parent.updateGeometry()
229
+ QTimer.singleShot(0, parent._notify_layout_change)
230
+ return
231
+ parent.updateGeometry()
232
+ parent = parent.parent()
233
+
234
+
235
+ def make_pipeline_files_header(
236
+ section: CollapsibleSection,
237
+ *,
238
+ stage_key: str,
239
+ parent: QWidget | None = None,
240
+ ) -> tuple[QWidget, QLabel, QToolButton]:
241
+ """Create a compact external header for a pipeline files section."""
242
+ section.set_header_visible(False)
243
+
244
+ header = QWidget(parent)
245
+ layout = QHBoxLayout(header)
246
+ layout.setContentsMargins(0, 0, 0, 0)
247
+ layout.setSpacing(4)
248
+
249
+ label = QLabel("Pipeline Files")
250
+ stage_header_label(label, stage_key)
251
+
252
+ button = tool_btn("🔍", "Show pipeline files.", checkable=True)
253
+ stage_header_action_button(button, stage_key)
254
+ button.setChecked(section.is_expanded)
255
+
256
+ def _set_expanded(checked: bool) -> None:
257
+ if checked:
258
+ section.expand()
259
+ else:
260
+ section.collapse()
261
+ button.setToolTip(
262
+ "Hide pipeline files." if checked else "Show pipeline files."
263
+ )
264
+
265
+ button.toggled.connect(_set_expanded)
266
+ section._toggle.toggled.connect(button.setChecked)
267
+
268
+ layout.addWidget(label)
269
+ layout.addWidget(button)
270
+ layout.addStretch(1)
271
+ return header, label, button
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Pipeline file status rows
276
+ # ---------------------------------------------------------------------------
277
+
278
+ class _PipelineFileRow(QWidget):
279
+ """One pipeline file status row: icon | rel-path | info | [load btn]"""
280
+
281
+ def __init__(
282
+ self,
283
+ rel_path: str,
284
+ display_name: str,
285
+ loadable: str | None = None,
286
+ viewer=None,
287
+ legacy_rel_path: str | None = None,
288
+ ):
289
+ super().__init__()
290
+ self._rel_path = rel_path
291
+ self._legacy_rel_path = legacy_rel_path
292
+ self._loadable = loadable or self._infer_load_kind(rel_path)
293
+ self._full_path: Path | None = None
294
+ self._viewer = viewer
295
+
296
+ lay = QHBoxLayout(self)
297
+ lay.setContentsMargins(
298
+ SECTION_MARGIN, TINY_MARGIN, SECTION_MARGIN, TINY_MARGIN
299
+ )
300
+ lay.setSpacing(TIGHT_SPACING)
301
+
302
+ self._icon_lbl = QLabel("○")
303
+ self._icon_lbl.setFixedWidth(14)
304
+ self._icon_lbl.setAlignment(Qt.AlignCenter)
305
+ muted_label(self._icon_lbl, size_pt=9)
306
+ lay.addWidget(self._icon_lbl)
307
+
308
+ self._display_name = display_name
309
+ self._name_lbl = QLabel(rel_path)
310
+ self._name_lbl.setFixedWidth(200)
311
+ status_label(self._name_lbl)
312
+ self._name_lbl.setToolTip(display_name)
313
+ lay.addWidget(self._name_lbl)
314
+
315
+ self._info_lbl = QLabel("—")
316
+ self._info_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
317
+ status_label(self._info_lbl)
318
+ lay.addWidget(self._info_lbl)
319
+
320
+ self._load_btn = QPushButton("↑")
321
+ icon_button(self._load_btn, width=18, height=18)
322
+ self._load_btn.clicked.connect(self._on_load_clicked)
323
+ self._load_btn.setEnabled(False)
324
+ self._load_btn.setToolTip(self._load_tooltip())
325
+ # Hide the button entirely when no viewer is wired in or file is not napari-loadable.
326
+ self._load_btn.setVisible(viewer is not None and self._loadable is not None)
327
+ lay.addWidget(self._load_btn)
328
+
329
+ def set_present(self, info_text: str) -> None:
330
+ self._icon_lbl.setText("✓")
331
+ self._icon_lbl.setStyleSheet("font-size: 9pt; font-weight: bold; color: #4CAF50;")
332
+ self._info_lbl.setText(info_text)
333
+ status_label(self._info_lbl)
334
+ self._update_load_button()
335
+
336
+ def set_missing(self) -> None:
337
+ self._icon_lbl.setText("✗")
338
+ muted_label(self._icon_lbl, size_pt=9)
339
+ self._info_lbl.setText("missing")
340
+ muted_label(self._info_lbl)
341
+ self._full_path = None
342
+ self._load_btn.setEnabled(False)
343
+ self._load_btn.setToolTip(self._load_tooltip(missing=True))
344
+
345
+ def set_no_project(self) -> None:
346
+ self._icon_lbl.setText("○")
347
+ muted_label(self._icon_lbl, size_pt=9)
348
+ self._info_lbl.setText("—")
349
+ muted_label(self._info_lbl)
350
+ self._full_path = None
351
+ self._load_btn.setEnabled(False)
352
+ self._load_btn.setToolTip(self._load_tooltip(no_project=True))
353
+
354
+ def apply_override(self, path: Path | None, pos_dir: Path) -> None:
355
+ """Point this row at a live-resolved ``path`` instead of ``pos_dir / rel_path``.
356
+
357
+ Used for rows whose real location is configurable and need not match the
358
+ canonical declared rel-path (e.g. a cellpose input the user repointed away
359
+ from ``0_input/*.tif``). The displayed path label is updated to the
360
+ resolved location so status and label track the file the stage will read.
361
+ """
362
+ if path is None:
363
+ self._reset_display_path()
364
+ self.set_missing()
365
+ return
366
+ self._name_lbl.setText(self._display_for(path, pos_dir))
367
+ self._name_lbl.setToolTip(str(path))
368
+ if path.exists():
369
+ self._full_path = path
370
+ self.set_present(_file_info(path))
371
+ else:
372
+ self.set_missing()
373
+
374
+ def _reset_display_path(self) -> None:
375
+ self._name_lbl.setText(self._rel_path)
376
+ self._name_lbl.setToolTip(self._display_name)
377
+
378
+ @staticmethod
379
+ def _display_for(path: Path, pos_dir: Path) -> str:
380
+ """Path relative to the position dir when nested under it, else the name."""
381
+ try:
382
+ return path.relative_to(pos_dir).as_posix()
383
+ except ValueError:
384
+ return path.name
385
+
386
+ def _update_load_button(self) -> None:
387
+ if self._full_path is None or not self._full_path.exists():
388
+ self._load_btn.setEnabled(False)
389
+ self._load_btn.setToolTip(self._load_tooltip(missing=True))
390
+ return
391
+
392
+ if self._loadable in {"tracked", "labels", "tiff"}:
393
+ self._load_btn.setEnabled(True)
394
+ self._load_btn.setToolTip(self._load_tooltip())
395
+ return
396
+
397
+ self._load_btn.setEnabled(False)
398
+ self._load_btn.setToolTip("No direct napari load action for this file.")
399
+
400
+ def _on_load_clicked(self) -> None:
401
+ if self._full_path is None or not self._full_path.exists():
402
+ return
403
+
404
+ self._load_file_into_viewer()
405
+
406
+ def _load_file_into_viewer(self) -> None:
407
+ viewer = self._viewer if self._viewer is not None else self._find_viewer()
408
+ if viewer is None:
409
+ return
410
+
411
+ import tifffile
412
+
413
+ data = tifffile.imread(str(self._full_path))
414
+ layer_name = self._layer_name()
415
+ use_labels = self._loadable in {"tracked", "labels"}
416
+
417
+ if use_labels:
418
+ if layer_name in viewer.layers:
419
+ try:
420
+ viewer.layers[layer_name].data = data
421
+ return
422
+ except Exception:
423
+ viewer.layers.remove(viewer.layers[layer_name])
424
+ viewer.add_labels(data, name=layer_name)
425
+ else:
426
+ colormap = self._pick_colormap()
427
+ if layer_name in viewer.layers:
428
+ try:
429
+ viewer.layers[layer_name].data = data
430
+ return
431
+ except Exception:
432
+ viewer.layers.remove(viewer.layers[layer_name])
433
+ viewer.add_image(data, name=layer_name, colormap=colormap)
434
+
435
+ def _pick_colormap(self) -> str:
436
+ """Colormap for non-label intensity TIFFs.
437
+
438
+ Mirrors the dedicated cellpose/divergence viewers so a file looks the
439
+ same however it's loaded: contour/divergence maps → ``magma``, cellpose
440
+ probability → ``viridis``, flow/dp → ``inferno``, and raw input or
441
+ foreground intensity → plain grayscale.
442
+ """
443
+ name = Path(self._rel_path).name
444
+ if "contour" in name:
445
+ return "magma"
446
+ if "_prob" in name:
447
+ return "viridis"
448
+ if "_dp" in name or "flow" in name:
449
+ return "inferno"
450
+ return "gray"
451
+
452
+ def _find_viewer(self):
453
+ widget = self.parentWidget()
454
+ while widget is not None:
455
+ viewer = getattr(widget, "viewer", None)
456
+ if viewer is not None and hasattr(viewer, "add_image") and hasattr(viewer, "add_labels"):
457
+ return viewer
458
+ widget = widget.parentWidget()
459
+ return None
460
+
461
+ def _layer_name(self) -> str:
462
+ return Path(self._rel_path).with_suffix("").as_posix().replace("/", "_")
463
+
464
+ def _load_tooltip(self, *, missing: bool = False, no_project: bool = False) -> str:
465
+ if no_project:
466
+ return "No project open."
467
+ if missing:
468
+ return "File is missing."
469
+ if self._loadable in {"tracked", "labels"}:
470
+ return "Load labels into napari."
471
+ if self._loadable == "tiff":
472
+ return "Load into napari viewer."
473
+ return "No direct napari load action for this file."
474
+
475
+ @staticmethod
476
+ def _infer_load_kind(rel_path: str) -> str | None:
477
+ name = Path(rel_path).name
478
+ if name == "tracked_labels.tif":
479
+ return "tracked"
480
+ if name == "foreground_masks.tif":
481
+ return "labels"
482
+ # atoms.tif is an int32 atom-ID image (stage ① output); load as labels.
483
+ if name == "atoms.tif":
484
+ return "labels"
485
+ if name.endswith("_labels.tif") or ("labels" in name and name.endswith((".tif", ".tiff"))):
486
+ return "labels"
487
+ if name.endswith((".tif", ".tiff")):
488
+ return "tiff"
489
+ return None
490
+
491
+
492
+ def _file_info(path: Path) -> str:
493
+ """Return a concise shape/dtype string for a pipeline output file."""
494
+ if path.is_dir():
495
+ return "Directory"
496
+ suffix = path.suffix.lower()
497
+ if suffix in (".tif", ".tiff"):
498
+ try:
499
+ import tifffile
500
+ with tifffile.TiffFile(path) as tif:
501
+ # The series carries the full N-D shape (T, Z, Y, X); a single
502
+ # page is only the 2D Y×X plane, which would drop z and t.
503
+ series = tif.series[0] if tif.series else None
504
+ shape = tuple(series.shape) if series is not None else tuple(tif.pages[0].shape)
505
+ return "×".join(str(d) for d in shape)
506
+ except Exception:
507
+ pass
508
+ return "TIFF"
509
+ if suffix in (".h5", ".hdf5"):
510
+ return "HDF5"
511
+ return f"{path.stat().st_size // 1024} KB"
512
+
513
+
514
+ class PipelineFilesWidget(QWidget):
515
+ """Compact file-status display for pipeline-stage widgets."""
516
+
517
+ #: Emitted after every ``refresh()`` so a host can recompute aggregated
518
+ #: status (section dots, catalog rail) whenever a stage's on-disk output
519
+ #: changed. Every stage widget already refreshes its tracker after a run,
520
+ #: commit, or finalize, so this is the single upstream "disk changed" hook.
521
+ refreshed = Signal()
522
+
523
+ def __init__(
524
+ self,
525
+ groups: list[tuple[str, list[tuple[str, str]]]],
526
+ parent: QWidget | None = None,
527
+ viewer=None,
528
+ ) -> None:
529
+ super().__init__(parent)
530
+ lay = QVBoxLayout(self)
531
+ lay.setContentsMargins(0, 0, 0, 0)
532
+ lay.setSpacing(0)
533
+ self._rows: list[_PipelineFileRow] = []
534
+ self._rows_by_group: dict[str, list[_PipelineFileRow]] = {}
535
+
536
+ for group_label, entries in groups:
537
+ if group_label:
538
+ hdr = QLabel(group_label)
539
+ hdr.setStyleSheet(
540
+ "font-size: 7pt; font-weight: bold; padding: 1px 4px;"
541
+ " background: palette(alternateBase); color: palette(mid);"
542
+ )
543
+ lay.addWidget(hdr)
544
+ group_rows: list[_PipelineFileRow] = []
545
+ for entry in entries:
546
+ rel_path, display_name = entry[0], entry[1]
547
+ legacy = entry[2] if len(entry) > 2 else None
548
+ row = _PipelineFileRow(rel_path, display_name, loadable=None, viewer=viewer, legacy_rel_path=legacy)
549
+ self._rows.append(row)
550
+ group_rows.append(row)
551
+ lay.addWidget(row)
552
+ if group_label:
553
+ self._rows_by_group[group_label] = group_rows
554
+
555
+ def refresh(
556
+ self,
557
+ pos_dir: Path | None,
558
+ overrides: dict[str, Path | None] | None = None,
559
+ ) -> None:
560
+ """Update all rows to reflect current on-disk state.
561
+
562
+ ``overrides`` maps a declared rel-path to the live location that row
563
+ should actually track, for rows whose real path is configurable (a
564
+ cellpose input the user repointed away from the canonical
565
+ ``0_input/*.tif``). A ``None`` value marks that row missing.
566
+ """
567
+ overrides = overrides or {}
568
+ if pos_dir is None:
569
+ for row in self._rows:
570
+ row.set_no_project()
571
+ self.refreshed.emit()
572
+ return
573
+ for row in self._rows:
574
+ if row._rel_path in overrides:
575
+ row.apply_override(overrides[row._rel_path], pos_dir)
576
+ continue
577
+ full_path = pos_dir / row._rel_path
578
+ if not full_path.exists() and row._legacy_rel_path is not None:
579
+ legacy_path = pos_dir / row._legacy_rel_path
580
+ if legacy_path.exists():
581
+ full_path = legacy_path
582
+ if full_path.exists():
583
+ row._full_path = full_path
584
+ row.set_present(_file_info(full_path))
585
+ else:
586
+ row.set_missing()
587
+ self.refreshed.emit()
588
+
589
+ def presence_count_by_group(self) -> dict[str, tuple[int, int]]:
590
+ """Per-group (n_present, n_total) snapshot of current file state."""
591
+ return {
592
+ label: (
593
+ sum(1 for r in rows if r._full_path is not None),
594
+ len(rows),
595
+ )
596
+ for label, rows in self._rows_by_group.items()
597
+ }
598
+
599
+
600
+ def pipeline_status_from_files(
601
+ tracker: PipelineFilesWidget, *, done_group: str
602
+ ) -> str:
603
+ """Derive a "not_started" / "in_progress" / "done" status from on-disk files.
604
+
605
+ `done` when ``done_group`` is fully present. `in_progress` when any file in
606
+ an "Intermediates" group is present, or when ``done_group`` is partially
607
+ populated. Otherwise `not_started`.
608
+ """
609
+ counts = tracker.presence_count_by_group()
610
+ done_present, done_total = counts.get(done_group, (0, 0))
611
+ if done_total > 0 and done_present == done_total:
612
+ return "done"
613
+ intermediates_present = counts.get("Intermediates", (0, 0))[0]
614
+ if intermediates_present > 0 or done_present > 0:
615
+ return "in_progress"
616
+ return "not_started"
itasc/napari.yaml ADDED
@@ -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"