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,1184 @@
1
+ """Main widget for the ITASC napari plugin."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import napari
8
+ from napari.qt.threading import thread_worker
9
+ from napari.utils.notifications import show_error, show_info, show_warning
10
+ from qtpy.QtCore import QSize, Qt, Signal
11
+ from qtpy.QtWidgets import (
12
+ QFileDialog,
13
+ QFrame,
14
+ QHBoxLayout,
15
+ QLabel,
16
+ QMenu,
17
+ QProgressBar,
18
+ QPushButton,
19
+ QScrollArea,
20
+ QSizePolicy,
21
+ QToolButton,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+
26
+ from itasc.contact_analysis import ContactBatchJob, run_contact_batch
27
+ from itasc.contact_analysis.catalog import (
28
+ CONTACT_ANALYSIS_RELPATH,
29
+ columns_from_levels,
30
+ load_catalog,
31
+ merge_catalog_records,
32
+ relative_levels,
33
+ save_catalog,
34
+ )
35
+ from itasc.core.paths import CONFIG_NAME, position_config_path
36
+ from itasc.napari._experiments_panel import ExperimentsPanel
37
+ from itasc.napari._icons import stage_action_icon
38
+ from itasc.napari.aggregate_widget import AggregateWidget
39
+ from itasc.napari._stage_loader import load_stage
40
+ from itasc.napari._stage_status import (
41
+ CONTACT_STAGES,
42
+ STAGE_CONTACTS,
43
+ STAGES,
44
+ position_contact_status,
45
+ position_stage_status,
46
+ )
47
+ from itasc.napari.contact_analysis_widget import (
48
+ ContactAnalysisWidget,
49
+ _ProgressEmitter,
50
+ )
51
+ from itasc.napari.widgets import (
52
+ CollapsibleSection,
53
+ PipelineFilesWidget,
54
+ pipeline_status_from_files,
55
+ )
56
+ from itasc.napari._widget_helpers import tool_btn
57
+ from itasc.napari.ui_gate import ControlClass, UiGate
58
+ from itasc.napari.ui_style import (
59
+ active_theme_name,
60
+ muted_accent,
61
+ muted_label,
62
+ refresh_stage_header_labels,
63
+ set_active_theme,
64
+ stage_accent,
65
+ theme_names,
66
+ )
67
+
68
+
69
+ #: Innermost nesting levels seeded with the recognized identity axes (innermost =
70
+ #: position_id), so the common ``condition / experiment_id / pos`` layout names
71
+ #: itself; deeper levels get generic ``level_k`` placeholders.
72
+ _SEED_LEVEL_NAMES = ("condition", "experiment_id", "position_id")
73
+
74
+ #: Committed-output paths relative to a position folder (see
75
+ #: ``itasc.napari._paths.NucleusArtifactPaths.cell_labels`` / ``.nucleus_labels``
76
+ #: — the files the finalize/"commit" button writes), stamped onto catalog rows so
77
+ #: a project CSV saved from the full app carries everything the aggregate needs.
78
+ _CELL_LABELS_RELPATH = "cell_labels.tif"
79
+ _NUCLEUS_LABELS_RELPATH = "nucleus_labels.tif"
80
+
81
+ #: Seconds per minute — the Setup frame-length field is entered in minutes but the
82
+ #: backend (dynamics, persisted ``metadata.time_interval_s``) works in seconds.
83
+ _SECONDS_PER_MINUTE = 60.0
84
+
85
+
86
+ def _positive_float_or_none(value) -> float | None:
87
+ """*value* as a positive float, or ``None`` when blank / non-numeric / ≤ 0."""
88
+ try:
89
+ result = float(value)
90
+ except (TypeError, ValueError):
91
+ return None
92
+ return result if result > 0 else None
93
+
94
+
95
+ def _min_str_to_s_str(text) -> str:
96
+ """A minutes field text → its seconds string for persistence (blank stays blank)."""
97
+ minutes = _positive_float_or_none(text)
98
+ return "" if minutes is None else _format_calibration(minutes * _SECONDS_PER_MINUTE)
99
+
100
+
101
+ def _s_str_to_min_str(text) -> str:
102
+ """A persisted seconds value → its minutes string for the field (blank stays blank)."""
103
+ seconds = _positive_float_or_none(text)
104
+ return "" if seconds is None else _format_calibration(seconds / _SECONDS_PER_MINUTE)
105
+
106
+
107
+ def _format_calibration(value: float) -> str:
108
+ """Compact numeric text (no trailing-zero noise), matching the panel's own."""
109
+ return f"{value:g}"
110
+
111
+
112
+ def _seed_level_names(depth: int) -> list[str]:
113
+ """Default name for each of *depth* nesting levels (anchored at the root)."""
114
+ names = [f"level_{i + 1}" for i in range(depth)]
115
+ for offset, seed in enumerate(reversed(_SEED_LEVEL_NAMES), start=1):
116
+ if offset <= depth:
117
+ names[depth - offset] = seed
118
+ return names
119
+
120
+
121
+ def _discover_positions(root: str, input_names: dict[str, str]) -> list[dict]:
122
+ """Find ITASC position folders under *root* by their raw input files.
123
+
124
+ A *position* is any folder containing at least one named raw input — the
125
+ nucleus and/or cell image (each a bare file name or a path relative to the
126
+ position folder, e.g. ``0_input/nucleus.tif``; the name is user-configurable
127
+ in the discovery fields and need not sit under ``0_input``). Each match
128
+ becomes a panel entry whose columns are derived from its nesting under *root*
129
+ (folder-derived columns, seeded with the recognized identity axes)."""
130
+ root_path = Path(root)
131
+ if not root_path.is_dir():
132
+ return []
133
+ names = [n for n in input_names.values() if n]
134
+ if not names:
135
+ return []
136
+
137
+ by_position: dict[Path, set[str]] = {}
138
+ for name in names:
139
+ rel = Path(name)
140
+ for match in sorted(root_path.rglob(rel.name)):
141
+ if not match.is_file():
142
+ continue
143
+ if len(rel.parts) > 1 and match.parts[-len(rel.parts):] != rel.parts:
144
+ continue
145
+ pos = match
146
+ for _ in rel.parts:
147
+ pos = pos.parent
148
+ by_position.setdefault(pos.resolve(), set()).add(name)
149
+
150
+ entries: list[dict] = []
151
+ for pos in sorted(by_position):
152
+ try:
153
+ levels = relative_levels(root_path, pos)
154
+ except ValueError:
155
+ # The chosen root sits inside the position (the user picked the
156
+ # position's own ``0_input`` folder, so the position is root's
157
+ # parent): there is no nesting under root. Add it plainly, its own
158
+ # folder name as the identity.
159
+ levels = ()
160
+ columns = columns_from_levels(_seed_level_names(len(levels)), levels)
161
+ columns.setdefault("position_id", levels[-1] if levels else pos.name)
162
+ entries.append(
163
+ {"key": str(pos), "columns": columns, "payload": {"position_path": pos}}
164
+ )
165
+ return entries
166
+
167
+
168
+ def _import_upstream_stage_widgets():
169
+ """The three upstream (segmentation/tracking) stage-widget classes, or ``None``.
170
+
171
+ Returns ``(CellposeWidget, NucleusWorkflowWidget, CellWorkflowWidget)`` when
172
+ their distributions (``itasc-cellpose`` / ``itasc-tracking``) are
173
+ installed — the full app. The standalone ``itasc-aggregate`` wheel ships
174
+ neither, so the imports fail and this returns ``None``, driving the widget
175
+ into contact-analysis-only mode. Import is deferred to call time so importing
176
+ this module never pulls the heavy segmentation/tracking stack.
177
+ """
178
+ try:
179
+ from itasc.napari.cellpose_widget import CellposeWidget
180
+ from itasc.napari.cell_workflow_widget import CellWorkflowWidget
181
+ from itasc.napari.nucleus_workflow_widget import NucleusWorkflowWidget
182
+ except ImportError:
183
+ return None
184
+ return CellposeWidget, NucleusWorkflowWidget, CellWorkflowWidget
185
+
186
+
187
+ class ITASCMainWidget(QWidget):
188
+ """The unified workflow-based UI for ITASC.
189
+
190
+ Runs in one of two modes, set at construction:
191
+
192
+ * **full** (``upstream_stages=True``, the default) — the whole pipeline:
193
+ Cellpose → Nucleus → Cell → Contact Analysis stages plus the Aggregate
194
+ capstone, and a four-dot per-position status rail.
195
+ * **contact-only** (``upstream_stages=False``, or whenever the upstream
196
+ stage distributions are not installed) — only the Contact Analysis stage
197
+ and the Aggregate capstone. The committed ``cell_labels.tif`` /
198
+ ``nucleus_labels.tif`` are treated as *inputs*, so the rail shows three
199
+ dots (cell labels → nucleus labels → contact analysis). This is the
200
+ standalone ``itasc-aggregate`` app.
201
+ """
202
+
203
+ refresh_requested = Signal(object) # emits pos_dir: Path | None
204
+
205
+ #: Preferred dock width on first open. This is only a *hint* — napari uses
206
+ #: it to size the dock initially, but the user can still drag it narrower
207
+ #: because minimumSizeHint (driven by the child widgets) is left untouched.
208
+ _PREFERRED_WIDTH = 480
209
+
210
+ def sizeHint(self) -> QSize:
211
+ hint = super().sizeHint()
212
+ return QSize(max(hint.width(), self._PREFERRED_WIDTH), hint.height())
213
+
214
+ def __init__(
215
+ self,
216
+ napari_viewer: napari.Viewer,
217
+ parent: QWidget | None = None,
218
+ *,
219
+ upstream_stages: bool = True,
220
+ ) -> None:
221
+ super().__init__(parent)
222
+ self.viewer = napari_viewer
223
+
224
+ # Which pipeline stages this app runs. The upstream segmentation/tracking
225
+ # stages appear only when requested *and* their distributions are
226
+ # installed; the standalone aggregate wheel ships neither, so it always
227
+ # comes up contact-analysis-only. See ``_import_upstream_stage_widgets``.
228
+ upstream = _import_upstream_stage_widgets() if upstream_stages else None
229
+ self._has_upstream = upstream is not None
230
+
231
+ # The selected position folder — the unit of work. ``None`` until the
232
+ # user picks one. This folder *is* ``pos_dir``; there is no separate
233
+ # project root or position index.
234
+ self._pos_dir: Path | None = None
235
+
236
+ # In-flight "Run all contact analyses" batch worker (None when idle) and
237
+ # its worker-thread → UI-thread progress bridge.
238
+ self._run_all_worker = None
239
+ self._run_all_progress_emitter = _ProgressEmitter(self)
240
+ self._run_all_progress_emitter.progress.connect(self._on_run_all_progress)
241
+
242
+ # Single app-wide UI gate shared by all sections. It is the one source
243
+ # of truth for control enablement: viewer-owner mutual exclusion (only
244
+ # one of correction / db-browser / live preview at a time) and the
245
+ # context-change guard for folder selection / config loading.
246
+ self.gate = UiGate(self)
247
+
248
+ main_layout = QVBoxLayout(self)
249
+ main_layout.setContentsMargins(4, 4, 4, 4)
250
+ main_layout.setSpacing(4)
251
+
252
+ # ── Project Info (Top Level) ──────────────────────────────────
253
+ self._setup_project_ui(main_layout)
254
+
255
+ # Main scroll area
256
+ self.scroll = QScrollArea()
257
+ self.scroll.setWidgetResizable(True)
258
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
259
+ self.scroll_widget = QWidget()
260
+ self.scroll_widget.setMinimumWidth(0)
261
+ self.scroll_widget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
262
+ self.scroll_layout = QVBoxLayout(self.scroll_widget)
263
+ self.scroll_layout.setContentsMargins(2, 2, 2, 2)
264
+ self.scroll_layout.setAlignment(Qt.AlignTop)
265
+ self.scroll.setWidget(self.scroll_widget)
266
+
267
+ main_layout.addWidget(self.scroll)
268
+
269
+ # Add sections. The three upstream (segmentation/tracking) stages appear
270
+ # only in full mode; contact-only omits them (and their widgets).
271
+ if self._has_upstream:
272
+ CellposeWidget, NucleusWorkflowWidget, CellWorkflowWidget = upstream
273
+ self._cellpose_widget = CellposeWidget(self.viewer, gate=self.gate)
274
+ self.cellpose_section = CollapsibleSection(
275
+ "Cellpose",
276
+ self._cellpose_widget,
277
+ expanded=False,
278
+
279
+ accent_color=stage_accent("cellpose"),
280
+ )
281
+
282
+ self.nucleus_workflow_widget = NucleusWorkflowWidget(self.viewer, gate=self.gate)
283
+ self.nucleus_section = CollapsibleSection(
284
+ "Nucleus Segmentation & Tracking",
285
+ self.nucleus_workflow_widget,
286
+ expanded=False,
287
+
288
+ accent_color=stage_accent("nucleus"),
289
+ )
290
+
291
+ self.cell_workflow_widget = CellWorkflowWidget(self.viewer, gate=self.gate)
292
+ self.cell_section = CollapsibleSection(
293
+ "Cell Segmentation",
294
+ self.cell_workflow_widget,
295
+ expanded=False,
296
+
297
+ accent_color=stage_accent("cell"),
298
+ )
299
+ self._connect_label_selection_sync()
300
+
301
+ self.contact_analysis_widget = ContactAnalysisWidget(self.viewer, gate=self.gate)
302
+ self.contact_analysis_section = CollapsibleSection(
303
+ "Contact Analysis",
304
+ self.contact_analysis_widget,
305
+ expanded=False,
306
+
307
+ accent_color=stage_accent("contact_analysis"),
308
+ )
309
+
310
+ self.aggregate_widget = AggregateWidget()
311
+ # No stage accent: Aggregate is NOT a fifth per-position stage. It pools
312
+ # across the whole catalog (keyed by the grouping columns), so it belongs
313
+ # to the Data folders scope, not the active-position detail stack. The
314
+ # scope band below re-parents it visually; the neutral (accent-less)
315
+ # section echoes the catalog-scoped folders panel rather than the stages.
316
+ self.aggregate_scope_band = self._make_scope_band(
317
+ "All positions", "Pooled across the whole catalog, grouped by your columns."
318
+ )
319
+ self.aggregate_section = CollapsibleSection(
320
+ "Aggregate",
321
+ self.aggregate_widget,
322
+ expanded=False,
323
+ )
324
+
325
+ # Positions panel (napariTFM ExperimentsList parity): discover a study
326
+ # root, add its position folders to a list, and select one to drive the
327
+ # detail sections below. The stage sections ARE the selected position's
328
+ # detail pane — selecting a row sets ``_pos_dir``. In full mode discovery
329
+ # keys off the raw input images and the rail shows the four pipeline
330
+ # stages; in contact-only mode it keys off the committed label images (the
331
+ # app's actual inputs) and the rail shows the three contact stages.
332
+ if self._has_upstream:
333
+ input_fields = [
334
+ ("nucleus", "Nucleus image", "0_input/nucleus.tif"),
335
+ ("cell", "Cell image", "0_input/cell.tif"),
336
+ ]
337
+ status_fn = position_stage_status
338
+ stages = STAGES
339
+ else:
340
+ input_fields = [
341
+ ("cell_labels", "Cell labels", _CELL_LABELS_RELPATH),
342
+ ("nucleus_labels", "Nucleus labels", _NUCLEUS_LABELS_RELPATH),
343
+ ]
344
+ status_fn = position_contact_status
345
+ stages = CONTACT_STAGES
346
+ self._positions_panel = ExperimentsPanel(
347
+ title="Data folders",
348
+ input_fields=input_fields,
349
+ discover_fn=_discover_positions,
350
+ status_fn=lambda payload, _fn=status_fn: _fn(
351
+ payload.get("position_path") if payload else None
352
+ ),
353
+ stages=stages,
354
+ show_calibration=True, # pixel size / frame length live in Setup
355
+ show_run=False, # batch Run selected is a later (stage-spine) pass
356
+ )
357
+ self._positions_panel.active_changed.connect(self._on_active_position)
358
+ self._positions_panel.discover_requested.connect(self._on_discover_positions)
359
+ self._positions_panel.stage_load_requested.connect(self._on_position_stage_load)
360
+ self._positions_panel.records_changed.connect(self._refresh_aggregate)
361
+ self._positions_panel.records_changed.connect(self._update_run_all_row)
362
+ # Editing the Setup calibration after folders are added must re-stamp the
363
+ # aggregate records, or param-gated quantities filled in afterwards stay grey.
364
+ self._positions_panel.calibration_changed.connect(
365
+ lambda *_: self._refresh_aggregate()
366
+ )
367
+
368
+ # Viewer-activity banner sits at the top level, above the stage
369
+ # sections, so the "exit the active mode" hint is visible regardless of which section
370
+ # holds the active viewer owner (correction / db-browser / live preview).
371
+ self.viewer_activity_banner = QLabel("")
372
+ self.viewer_activity_banner.setWordWrap(True)
373
+ self.viewer_activity_banner.setVisible(False)
374
+ self.viewer_activity_banner.setSizePolicy(
375
+ QSizePolicy.Policy.Preferred,
376
+ QSizePolicy.Policy.Fixed,
377
+ )
378
+ self.viewer_activity_banner.setStyleSheet(
379
+ "QLabel { font-weight: 700; padding: 4px 6px; "
380
+ "border: 1px solid #f9e2af; background: rgba(249, 226, 175, 35); }"
381
+ )
382
+ self.scroll_layout.addWidget(self.viewer_activity_banner)
383
+
384
+ self.scroll_layout.addWidget(self._positions_panel)
385
+ self.scroll_layout.addWidget(self._build_run_all_contacts_row())
386
+ if self._has_upstream:
387
+ self.scroll_layout.addWidget(self.cellpose_section)
388
+ self.scroll_layout.addWidget(self.nucleus_section)
389
+ self.scroll_layout.addWidget(self.cell_section)
390
+ self.scroll_layout.addWidget(self.contact_analysis_section)
391
+ self.scroll_layout.addWidget(self.aggregate_scope_band)
392
+ self.scroll_layout.addWidget(self.aggregate_section)
393
+
394
+ # The stage sections ARE the selected-position detail pane: they stay
395
+ # hidden until a position is active (progressive disclosure — an empty
396
+ # workspace shows only the toolbar + the positions panel). Contact-only
397
+ # mode has just the one stage section.
398
+ if self._has_upstream:
399
+ self._stage_sections = (
400
+ self.cellpose_section,
401
+ self.nucleus_section,
402
+ self.cell_section,
403
+ self.contact_analysis_section,
404
+ )
405
+ else:
406
+ self._stage_sections = (self.contact_analysis_section,)
407
+ for section in self._stage_sections:
408
+ section.set_status("not_started")
409
+
410
+ # The aggregate capstone is project-level, not a per-position detail
411
+ # pane: it is NOT in ``_stage_sections`` (so ``_update_disclosure``
412
+ # never hides it on selection changes). Seed its own initial state,
413
+ # hidden until the catalog has positions.
414
+ self.aggregate_scope_band.setVisible(False)
415
+ self.aggregate_section.setVisible(False)
416
+ self.aggregate_section.set_status(self.aggregate_widget.section_status())
417
+
418
+ # Add stretch at the end
419
+ self.scroll_layout.addStretch()
420
+ self._setup_theme_selector(main_layout)
421
+
422
+ # Connect signals
423
+ self.save_as_btn.clicked.connect(lambda: self._on_save_config_as())
424
+ self.load_from_btn.clicked.connect(lambda: self._on_load_config_from())
425
+ self.save_project_btn.clicked.connect(lambda: self._on_save_project())
426
+ self.load_project_btn.clicked.connect(lambda: self._on_load_project())
427
+
428
+ self.refresh_btn.clicked.connect(lambda: self._refresh_all())
429
+
430
+ # Config travels with the folder: snapshot params into the folder on every
431
+ # run. The upstream stage widgets' run buttons are the trigger (cancel
432
+ # re-clicks re-save the same config, which is harmless). Contact-only mode
433
+ # has no upstream run buttons and no per-folder params to autosave.
434
+ if self._has_upstream:
435
+ for widget_obj, attr in (
436
+ (self._cellpose_widget, "nucleus_run_btn"),
437
+ (self._cellpose_widget, "cell_run_btn"),
438
+ (self.nucleus_workflow_widget, "seg_run_btn"),
439
+ (self.nucleus_workflow_widget, "db_run_btn"),
440
+ (self.nucleus_workflow_widget, "solve_run_btn"),
441
+ (self.cell_workflow_widget, "run_btn"),
442
+ ):
443
+ getattr(widget_obj, attr).clicked.connect(self._autosave_config)
444
+
445
+ self._register_gate_controls()
446
+
447
+ self.gate.changed.connect(self._update_activity_banner)
448
+ self._update_activity_banner()
449
+ self._update_disclosure()
450
+ self._connect_status_trackers()
451
+
452
+ def _update_disclosure(self) -> None:
453
+ """Reveal the stage sections only once a position is active.
454
+
455
+ Empty workspace → only the toolbar and the positions panel show; the
456
+ stage sections (the selected-position detail pane) appear the moment a
457
+ row is selected and hide again when the selection is cleared.
458
+ """
459
+ tuning = self._pos_dir is not None
460
+ for section in self._stage_sections:
461
+ section.setVisible(tuning)
462
+
463
+ def _connect_label_selection_sync(self) -> None:
464
+ """Synchronize selected cell/nucleus IDs across correction widgets."""
465
+ if hasattr(self.nucleus_workflow_widget, "set_selection_callback"):
466
+ self.nucleus_workflow_widget.set_selection_callback(
467
+ lambda t, label: self.cell_workflow_widget.select_matching_cell_label(t, label)
468
+ )
469
+ if hasattr(self.cell_workflow_widget, "set_selection_callback"):
470
+ self.cell_workflow_widget.set_selection_callback(
471
+ lambda t, label: self.nucleus_workflow_widget.select_matching_nucleus_label(t, label)
472
+ )
473
+
474
+ def _register_gate_controls(self) -> None:
475
+ """Register top-level controls with the app-wide UI gate.
476
+
477
+ Folder selection / config-load swap the underlying data, so they are
478
+ ``CONTEXT_CHANGING``: they stay enabled, but clicking one while a viewer
479
+ owner (correction / live preview / db-browser) is active first offers to
480
+ exit that owner (see ``_change_context``). Save Config is harmless and
481
+ needs no gating.
482
+ """
483
+ for control in (
484
+ self.load_project_btn,
485
+ self.load_from_btn,
486
+ ):
487
+ self.gate.register(control, ControlClass.CONTEXT_CHANGING)
488
+ self.gate.recompute()
489
+
490
+ def _set_viewer_activity_banner(self, text: str) -> None:
491
+ visible = bool(text)
492
+ if self.viewer_activity_banner.text() != text:
493
+ self.viewer_activity_banner.setText(text)
494
+ if self.viewer_activity_banner.isVisible() != visible:
495
+ self.viewer_activity_banner.setVisible(visible)
496
+
497
+ def _update_activity_banner(self) -> None:
498
+ label = self.gate.owner_label()
499
+ if label:
500
+ self._set_viewer_activity_banner(
501
+ f"{label[0].upper()}{label[1:]} active. "
502
+ "Exit it to use disabled workflow controls."
503
+ )
504
+ else:
505
+ self._set_viewer_activity_banner("")
506
+
507
+ def _change_context(self, action) -> bool:
508
+ """Run *action*, offering to exit the active viewer owner first.
509
+
510
+ Returns ``True`` if the action ran, ``False`` if the user declined.
511
+ """
512
+ return self.gate.confirm_context_change(self, action)
513
+
514
+ def _setup_theme_selector(self, layout: QVBoxLayout) -> None:
515
+ footer = QHBoxLayout()
516
+ footer.setContentsMargins(0, 0, 0, 0)
517
+ footer.addStretch()
518
+
519
+ self.theme_btn = tool_btn("◐", "Theme")
520
+ self.theme_btn.setObjectName("theme_selector_button")
521
+ self.theme_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
522
+
523
+ self.theme_menu = QMenu(self.theme_btn)
524
+ self._theme_actions = {}
525
+ for name in theme_names():
526
+ action = self.theme_menu.addAction(name)
527
+ action.setCheckable(True)
528
+ action.triggered.connect(
529
+ lambda _checked=False, theme_name=name: self._on_theme_selected(theme_name)
530
+ )
531
+ self._theme_actions[name] = action
532
+ self.theme_btn.setMenu(self.theme_menu)
533
+ self._sync_theme_menu_state()
534
+
535
+ footer.addWidget(self.theme_btn)
536
+ layout.addLayout(footer)
537
+
538
+ def _on_theme_selected(self, name: str) -> None:
539
+ set_active_theme(name)
540
+ self._apply_theme_accents()
541
+ self._sync_theme_menu_state()
542
+
543
+ def _apply_theme_accents(self) -> None:
544
+ section_stage_keys = [(self.contact_analysis_section, "contact_analysis")]
545
+ if self._has_upstream:
546
+ section_stage_keys[:0] = [
547
+ (self.cellpose_section, "cellpose"),
548
+ (self.nucleus_section, "nucleus"),
549
+ (self.cell_section, "cell"),
550
+ ]
551
+ for section, stage_key in section_stage_keys:
552
+ section.set_accent_color(stage_accent(stage_key))
553
+ refresh_stage_header_labels(self)
554
+
555
+ def _sync_theme_menu_state(self) -> None:
556
+ current = active_theme_name()
557
+ for name, action in self._theme_actions.items():
558
+ action.setChecked(name == current)
559
+ self.theme_btn.setToolTip(f"Theme: {current}")
560
+
561
+ def _setup_project_ui(self, layout: QVBoxLayout) -> None:
562
+ """The top toolbar: a title + compact Project / Params action buttons.
563
+
564
+ Calibration (pixel size / frame length) has moved into the positions
565
+ panel's Setup section. Data folders are added via the panel's
566
+ Discover-and-select flow, so the toolbar carries just the project
567
+ catalog load/save, the standalone params-file load/save, and a global
568
+ refresh.
569
+ """
570
+ bar = QWidget()
571
+ row = QHBoxLayout(bar)
572
+ row.setContentsMargins(0, 0, 0, 0)
573
+ row.setSpacing(4)
574
+
575
+ title = QLabel("ITASC")
576
+ title.setStyleSheet("font-weight: bold;")
577
+ row.addWidget(title)
578
+
579
+ row.addWidget(self._toolbar_group_label("Project"))
580
+ # The load/save pair act on the PROJECT catalog CSV (the Data folders
581
+ # list + classification columns), not the per-folder config.
582
+ self.load_project_btn = self._toolbar_icon_btn(
583
+ "load", "Load a project catalog (CSV) into the Data folders list"
584
+ )
585
+ self.save_project_btn = self._toolbar_icon_btn(
586
+ "save", "Save the Data folders list to a project catalog (CSV)"
587
+ )
588
+ for btn in (self.load_project_btn, self.save_project_btn):
589
+ row.addWidget(btn)
590
+
591
+ row.addWidget(self._toolbar_group_label("Params"))
592
+ # Per-folder config now autosaves on run; these move a tuned param SET
593
+ # between experiments as a standalone file.
594
+ self.load_from_btn = self._toolbar_icon_btn("load", "Load config from a file…")
595
+ self.save_as_btn = self._toolbar_icon_btn("save", "Save config to a file…")
596
+ for btn in (self.load_from_btn, self.save_as_btn):
597
+ row.addWidget(btn)
598
+
599
+ row.addStretch()
600
+ self.refresh_btn = self._toolbar_icon_btn("reset", "Refresh all status")
601
+ row.addWidget(self.refresh_btn)
602
+
603
+ layout.addWidget(bar)
604
+
605
+ self.path_label = QLabel("[no folder]")
606
+ muted_label(self.path_label)
607
+ self.path_label.setWordWrap(True)
608
+ layout.addWidget(self.path_label)
609
+
610
+ #: Toolbar icon geometry — matches napariTFM's title-row buttons.
611
+ _TOOLBAR_ICON_SIZE = 18
612
+ _TOOLBAR_ICON_STROKE = 2.0
613
+
614
+ def _toolbar_icon_btn(self, icon_name: str, tooltip: str) -> QToolButton:
615
+ """A compact, icon-only, auto-raised toolbar button (napariTFM parity)."""
616
+ button = QToolButton()
617
+ accent = stage_accent("project_status")
618
+ button.setIcon(
619
+ stage_action_icon(
620
+ icon_name,
621
+ muted_accent(accent),
622
+ disabled_color=muted_accent(muted_accent(accent)),
623
+ size=self._TOOLBAR_ICON_SIZE,
624
+ stroke_width=self._TOOLBAR_ICON_STROKE,
625
+ )
626
+ )
627
+ button.setIconSize(QSize(self._TOOLBAR_ICON_SIZE, self._TOOLBAR_ICON_SIZE))
628
+ button.setToolTip(tooltip)
629
+ button.setAutoRaise(True)
630
+ return button
631
+
632
+ def _toolbar_group_label(self, text: str) -> QLabel:
633
+ label = QLabel(text)
634
+ muted_label(label)
635
+ return label
636
+
637
+ def _make_scope_band(self, title: str, subtitle: str) -> QWidget:
638
+ """A captioned divider that resets the reader's scope from the active
639
+ position to the whole catalog. Everything above it is per-position detail;
640
+ the section below it pools across all positions. Deliberately neutral (no
641
+ stage accent) so it reads as a scope break, not another workflow stage."""
642
+ band = QFrame()
643
+ band.setObjectName("itasc_scope_band")
644
+ band.setStyleSheet(
645
+ "QFrame#itasc_scope_band { "
646
+ "border-top: 1px solid palette(mid); "
647
+ "margin-top: 8px; padding-top: 8px; }"
648
+ )
649
+ layout = QVBoxLayout(band)
650
+ layout.setContentsMargins(2, 6, 2, 0)
651
+ layout.setSpacing(0)
652
+ heading = QLabel(title.upper())
653
+ heading.setStyleSheet(
654
+ "font-weight: 700; font-size: 8pt; letter-spacing: 1px; "
655
+ "color: palette(mid);"
656
+ )
657
+ caption = QLabel(subtitle)
658
+ caption.setWordWrap(True)
659
+ muted_label(caption)
660
+ layout.addWidget(heading)
661
+ layout.addWidget(caption)
662
+ return band
663
+
664
+ def _on_discover_positions(self) -> None:
665
+ """Find-data-folders button: pick a parent directory and scan it."""
666
+ path = QFileDialog.getExistingDirectory(
667
+ self, "Select a parent folder to scan for data folders"
668
+ )
669
+ if path:
670
+ self._positions_panel.discover(path)
671
+
672
+ def _on_active_position(self, payload) -> None:
673
+ """A positions-panel row was activated: make it the working ``pos_dir``.
674
+
675
+ The selected position folder *is* ``pos_dir`` — the detail sections below
676
+ retarget to it, exactly as the manual folder picker does. Routed through
677
+ ``_change_context`` so an active viewer owner is offered an exit first.
678
+ """
679
+ if not payload or not payload.get("position_path"):
680
+ return
681
+ pos = Path(payload["position_path"])
682
+ self._change_context(lambda: self._retarget_to_position(pos))
683
+
684
+ def _retarget_to_position(self, pos: Path) -> None:
685
+ """Point every detail section at *pos* — the selected folder is ``pos_dir``.
686
+
687
+ Shared by row selection and the contacts rail dot; assumes the caller has
688
+ already cleared the active viewer owner via ``_change_context``.
689
+ """
690
+ self._pos_dir = pos
691
+ self._update_disclosure()
692
+ self.path_label.setText(str(pos))
693
+ self.path_label.setToolTip(str(pos))
694
+ config_path = position_config_path(pos)
695
+ if config_path.exists():
696
+ self._load_config(str(config_path))
697
+ self._refresh_all()
698
+
699
+ def _on_position_stage_load(self, payload, stage: str) -> None:
700
+ """Click a row's rail dot → load that stage's output(s) into the viewer.
701
+
702
+ The contacts stage has no raw-image output, so its dot instead opens the
703
+ contact-analysis overlays for the row's position — the same action as the
704
+ "Visualize Contact Analysis" button. It first retargets the detail pane to
705
+ that position so the contact widget's context points at it.
706
+ """
707
+ if not payload or not payload.get("position_path"):
708
+ return
709
+ pos = Path(payload["position_path"])
710
+ if stage == STAGE_CONTACTS:
711
+ def action() -> None:
712
+ if self._pos_dir != pos:
713
+ self._retarget_to_position(pos)
714
+ self.contact_analysis_widget._on_visualize(overwrite=False)
715
+
716
+ self._change_context(action)
717
+ return
718
+ load_stage(self.viewer, pos, stage)
719
+
720
+ def get_state(self) -> dict:
721
+ """Return the current UI state as a dictionary.
722
+
723
+ Contact-only mode carries just the calibration metadata — it has no
724
+ upstream stage params.
725
+ """
726
+ calibration = self._positions_panel.calibration_values()
727
+ state = {
728
+ "metadata": {
729
+ "pixel_size_um": calibration.get("pixel_size_um", ""),
730
+ # Panel holds minutes; persist the backend's seconds.
731
+ "time_interval_s": _min_str_to_s_str(calibration.get("time_interval_min", "")),
732
+ },
733
+ }
734
+ if self._has_upstream:
735
+ state["cellpose"] = self._cellpose_widget.get_state()
736
+ state["nucleus"] = self.nucleus_workflow_widget.get_state()
737
+ state["cell"] = self.cell_workflow_widget.get_state()
738
+ return state
739
+
740
+ def set_state(self, state: dict) -> None:
741
+ """Update the UI state from a dictionary."""
742
+ if "metadata" in state:
743
+ m = state["metadata"]
744
+ # Calibration now lives in the positions panel's Setup section. The
745
+ # config stores seconds; the panel field is minutes, so translate.
746
+ self._positions_panel.set_calibration_values(
747
+ {
748
+ "pixel_size_um": m.get("pixel_size_um", ""),
749
+ "time_interval_min": _s_str_to_min_str(m.get("time_interval_s", "")),
750
+ }
751
+ )
752
+ # Legacy ``condition`` / ``position`` keys are intentionally ignored:
753
+ # condition is a folder-derived per-position column, and the selected
754
+ # folder carries position identity.
755
+
756
+ if not self._has_upstream:
757
+ # Contact-only mode has no upstream stage widgets; their config keys
758
+ # (if present in a full-app config file) are simply ignored.
759
+ return
760
+
761
+ if "cellpose" in state:
762
+ self._cellpose_widget.set_state(state["cellpose"])
763
+
764
+ if "nucleus" in state:
765
+ self.nucleus_workflow_widget.set_state(state["nucleus"])
766
+
767
+ if "cell" in state:
768
+ self.cell_workflow_widget.set_state(state["cell"])
769
+
770
+ def _autosave_config(self) -> None:
771
+ """Write the current config into the active folder (quiet) on each run."""
772
+ if self._pos_dir is None:
773
+ return
774
+ self._save_config(str(self._pos_dir / CONFIG_NAME), quiet=True)
775
+
776
+ def _catalog_record_for_position(self, position_path: Path, columns: dict) -> dict:
777
+ """A catalog record for one data folder, stamped with committed output paths.
778
+
779
+ Panel rows carry only ``position_path`` + classification ``columns``; the
780
+ aggregate catalog also needs the contact-analysis ``.h5`` and the two label
781
+ images. Those are the *committed* outputs — ``cell_labels.tif`` /
782
+ ``nucleus_labels.tif`` (what the finalize/"commit" button writes; see
783
+ ``NucleusArtifactPaths.cell_labels`` / ``.nucleus_labels``) and the
784
+ canonical ``contact_analysis.h5`` (beside those labels) — not the pre-commit
785
+ working paths under ``2_nucleus`` / ``3_cell``. Fill their defaults here
786
+ (whether or not the folder has been processed yet).
787
+ """
788
+ pos = Path(position_path)
789
+ return {
790
+ "position_path": pos,
791
+ "contact_analysis_path": pos / CONTACT_ANALYSIS_RELPATH,
792
+ "cell_tracked_labels_path": pos / _CELL_LABELS_RELPATH,
793
+ "nucleus_tracked_labels_path": pos / _NUCLEUS_LABELS_RELPATH,
794
+ "columns": dict(columns or {}),
795
+ }
796
+
797
+ def _catalog_records_for_panel(self, panel_records) -> list[dict]:
798
+ """Catalog records (committed output paths + calibration) for panel rows.
799
+
800
+ The Setup calibration (pixel size, frame length) is stamped onto every
801
+ record so the aggregate's pooled cheap quantities (shape, dynamics) compute
802
+ in physical units and light up in the panel; without it they stay greyed.
803
+ """
804
+ calibration = self._calibration_params()
805
+ records = []
806
+ for rec in panel_records:
807
+ record = self._catalog_record_for_position(
808
+ rec["position_path"], rec.get("columns", {})
809
+ )
810
+ record.update(calibration)
811
+ records.append(record)
812
+ return records
813
+
814
+ def _calibration_params(self) -> dict[str, float]:
815
+ """Setup calibration as backend ``{param: float}``, dropping blank /
816
+ non-positive entries (an unset field contributes nothing rather than a zero).
817
+
818
+ The frame-length field is entered in minutes; the backend's dynamics work
819
+ in seconds, so it is converted to ``time_interval_s`` here.
820
+ """
821
+ values = self._positions_panel.calibration_values()
822
+ params: dict[str, float] = {}
823
+ pixel_size = _positive_float_or_none(values.get("pixel_size_um", ""))
824
+ if pixel_size is not None:
825
+ params["pixel_size_um"] = pixel_size
826
+ minutes = _positive_float_or_none(values.get("time_interval_min", ""))
827
+ if minutes is not None:
828
+ params["time_interval_s"] = minutes * 60.0
829
+ return params
830
+
831
+ def _refresh_aggregate(self) -> None:
832
+ """Feed the project-level catalog to the capstone; show it once positions exist."""
833
+ records = self._catalog_records_for_panel(self._positions_panel.records())
834
+ self.aggregate_widget.set_records(records)
835
+ self.aggregate_scope_band.setVisible(bool(records))
836
+ self.aggregate_section.setVisible(bool(records))
837
+ self.aggregate_section.set_status(self.aggregate_widget.section_status())
838
+
839
+ # -- run all contact analyses ----------------------------------------
840
+ def _build_run_all_contacts_row(self) -> QWidget:
841
+ """A catalog-scoped action: build the contact analysis for every listed
842
+ position that is missing one, in one headless batch.
843
+
844
+ It lives directly under the Data folders list (not inside the per-position
845
+ Contact Analysis section, which is a selected-position detail pane that
846
+ hides when nothing is selected): the operation ranges over the whole
847
+ catalog, so it stays visible whenever there are positions.
848
+ """
849
+ container = QWidget()
850
+ col = QVBoxLayout(container)
851
+ col.setContentsMargins(0, 0, 0, 0)
852
+ col.setSpacing(2)
853
+
854
+ self._run_all_btn = QPushButton("Run all contact analyses")
855
+ self._run_all_btn.setToolTip(
856
+ "Build the contact-analysis .h5 for every listed position that has "
857
+ "cell labels but no result yet. Positions already built are skipped."
858
+ )
859
+ self._run_all_btn.clicked.connect(self._on_run_all_contacts)
860
+ col.addWidget(self._run_all_btn)
861
+
862
+ self._run_all_progress = QProgressBar()
863
+ self._run_all_progress.setRange(0, 100)
864
+ self._run_all_progress.setValue(0)
865
+ self._run_all_progress.setVisible(False)
866
+ self._run_all_progress.setTextVisible(True)
867
+ col.addWidget(self._run_all_progress)
868
+
869
+ self._run_all_status = QLabel("")
870
+ self._run_all_status.setWordWrap(True)
871
+ muted_label(self._run_all_status)
872
+ col.addWidget(self._run_all_status)
873
+
874
+ self._run_all_row = container
875
+ self._update_run_all_row()
876
+ return container
877
+
878
+ def _update_run_all_row(self) -> None:
879
+ """Show the Run-all action only with positions listed; disable while busy."""
880
+ has_positions = bool(self._positions_panel.keys())
881
+ self._run_all_row.setVisible(has_positions)
882
+ if self._run_all_worker is None:
883
+ self._run_all_btn.setEnabled(has_positions)
884
+
885
+ def _missing_contact_jobs(self) -> list[ContactBatchJob]:
886
+ """One :class:`ContactBatchJob` per listed position that can and must run.
887
+
888
+ A position qualifies when its committed ``cell_labels.tif`` is present
889
+ (the only required input) but its ``contact_analysis.h5`` is not — the
890
+ "doesn't have it yet" set. The nucleus is attached only when its committed
891
+ file exists; otherwise the job runs cell-only.
892
+ """
893
+ jobs: list[ContactBatchJob] = []
894
+ for rec in self._positions_panel.records():
895
+ position = rec.get("position_path") if isinstance(rec, dict) else None
896
+ if not position:
897
+ continue
898
+ pos = Path(position)
899
+ cell = pos / _CELL_LABELS_RELPATH
900
+ out = pos / CONTACT_ANALYSIS_RELPATH
901
+ if not cell.is_file() or out.is_file():
902
+ continue
903
+ nucleus = pos / _NUCLEUS_LABELS_RELPATH
904
+ jobs.append(
905
+ ContactBatchJob(
906
+ group_dir=pos,
907
+ cell_labels=cell,
908
+ output=out,
909
+ nucleus_labels=nucleus if nucleus.is_file() else None,
910
+ )
911
+ )
912
+ return jobs
913
+
914
+ def _on_run_all_contacts(self) -> None:
915
+ """Batch-build contact analyses for every listed, unbuilt position."""
916
+ if self._run_all_worker is not None:
917
+ return
918
+ jobs = self._missing_contact_jobs()
919
+ if not jobs:
920
+ self._run_all_status.setText(
921
+ "Nothing to run: every listed position already has a contact "
922
+ "analysis (or is missing its cell labels)."
923
+ )
924
+ return
925
+
926
+ self._run_all_progress.setRange(0, len(jobs))
927
+ self._run_all_progress.setValue(0)
928
+ self._run_all_progress.setVisible(True)
929
+ self._run_all_status.setText(
930
+ f"Running contact analysis on {len(jobs)} position(s)…"
931
+ )
932
+ self._run_all_btn.setEnabled(False)
933
+
934
+ emit = self._run_all_progress_emitter.progress.emit
935
+
936
+ @thread_worker(
937
+ connect={
938
+ "returned": self._on_run_all_done,
939
+ "errored": self._on_run_all_error,
940
+ }
941
+ )
942
+ def _worker():
943
+ # overwrite=False so the batch itself also honours the missing-only
944
+ # contract, even if a result appeared on disk since the jobs were built.
945
+ return run_contact_batch(jobs, overwrite=False, progress_cb=emit)
946
+
947
+ self._run_all_worker = _worker()
948
+ self._update_run_all_row()
949
+
950
+ def _on_run_all_progress(self, done: int, total: int, label: str) -> None:
951
+ if total > 0:
952
+ self._run_all_progress.setRange(0, total)
953
+ self._run_all_progress.setValue(done)
954
+ self._run_all_status.setText(f"Contact analysis: {done}/{total} ({label})")
955
+
956
+ def _on_run_all_done(self, results: list) -> None:
957
+ self._run_all_worker = None
958
+ self._run_all_progress.setVisible(False)
959
+ built = sum(1 for r in results if r.status == "built")
960
+ skipped = sum(1 for r in results if r.status == "skipped")
961
+ failed = sum(1 for r in results if r.status == "failed")
962
+ parts = [f"built {built}"]
963
+ if skipped:
964
+ parts.append(f"skipped {skipped}")
965
+ if failed:
966
+ parts.append(f"failed {failed}")
967
+ self._run_all_status.setText("Run all contact analyses: " + " / ".join(parts))
968
+ self._update_run_all_row()
969
+ # New .h5 files appeared: repaint every section dot and the catalog rail.
970
+ self._refresh_all()
971
+
972
+ def _on_run_all_error(self, exc: Exception) -> None:
973
+ self._run_all_worker = None
974
+ self._run_all_progress.setVisible(False)
975
+ self._run_all_status.setText(f"Run all contact analyses: error: {exc}")
976
+ self._update_run_all_row()
977
+
978
+ def _on_save_config_as(self) -> None:
979
+ """Save current configuration to a specific file."""
980
+ path = QFileDialog.getSaveFileName(self, "Save Config As", filter="JSON (*.json)")[0]
981
+ if path:
982
+ self._save_config(path)
983
+
984
+ def _on_load_config_from(self) -> None:
985
+ """Load configuration from a specific file."""
986
+ path = QFileDialog.getOpenFileName(self, "Load Config From", filter="JSON (*.json)")[0]
987
+ if path:
988
+ self._change_context(lambda: self._load_config(path))
989
+
990
+ def _on_save_project(self) -> None:
991
+ """Write the Data folders catalog to a CSV the aggregate studio can run."""
992
+ if not self._positions_panel.keys():
993
+ show_warning("No data folders to save: the catalog is empty.")
994
+ return
995
+ path = QFileDialog.getSaveFileName(
996
+ self, "Save project catalog", "catalog.csv", filter="CSV (*.csv)"
997
+ )[0]
998
+ if not path:
999
+ return
1000
+ # getSaveFileName does not always append the filter suffix on Linux/Qt.
1001
+ if not path.lower().endswith(".csv"):
1002
+ path = f"{path}.csv"
1003
+ records = self._catalog_records_for_panel(self._positions_panel.records())
1004
+ try:
1005
+ save_catalog(Path(path), records)
1006
+ show_info(f"Project saved to {path}")
1007
+ except Exception as e:
1008
+ show_error(f"Error saving project: {e}")
1009
+
1010
+ def _on_load_project(self) -> None:
1011
+ """Load a project catalog CSV, merging its rows into the Data folders list."""
1012
+ path = QFileDialog.getOpenFileName(
1013
+ self, "Load project catalog", filter="CSV (*.csv)"
1014
+ )[0]
1015
+ if not path:
1016
+ return
1017
+
1018
+ def action() -> None:
1019
+ try:
1020
+ loaded = load_catalog(Path(path))
1021
+ except Exception as e:
1022
+ show_error(f"Error loading project: {e}")
1023
+ return
1024
+ merged = merge_catalog_records(self._positions_panel.records(), loaded)
1025
+ entries = [
1026
+ {
1027
+ "key": str(rec.get("position_path") or rec.get("contact_analysis_path") or ""),
1028
+ "columns": dict(rec.get("columns") or {}),
1029
+ "payload": {"position_path": Path(rec["position_path"])}
1030
+ if rec.get("position_path")
1031
+ else {"position_path": None},
1032
+ }
1033
+ for rec in merged
1034
+ ]
1035
+ self._positions_panel.set_records(entries)
1036
+ show_info(f"Project loaded: {len(entries)} data folder(s).")
1037
+
1038
+ self._change_context(action)
1039
+
1040
+ def _save_config(self, path: str, *, quiet: bool = False) -> None:
1041
+ """Save state to a JSON file. ``quiet`` suppresses the success toast."""
1042
+ state = self.get_state()
1043
+ try:
1044
+ with open(path, "w") as f:
1045
+ json.dump(state, f, indent=4)
1046
+ if not quiet:
1047
+ show_info(f"Config saved to {path}")
1048
+ except Exception as e:
1049
+ show_error(f"Error saving config: {e}")
1050
+
1051
+ def _load_config(self, path: str) -> None:
1052
+ """Load state from a JSON file."""
1053
+ # Defense-in-depth: loading rewrites position + every section's params,
1054
+ # which would corrupt an in-progress correction. Callers route through
1055
+ # ``_change_context`` (which exits the owner first); refuse any path
1056
+ # that reaches here while a viewer owner is still active.
1057
+ if not self.gate.can_change_context():
1058
+ show_warning("Refusing to load config while a viewer mode is active.")
1059
+ return
1060
+ try:
1061
+ with open(path) as f:
1062
+ state = json.load(f)
1063
+ self.set_state(state)
1064
+ show_info(f"Config loaded from {path}")
1065
+ except Exception as e:
1066
+ show_error(f"Error loading config: {e}")
1067
+
1068
+ def _connect_status_trackers(self) -> None:
1069
+ """Recompute aggregated status whenever any stage's tracker refreshes.
1070
+
1071
+ Every stage widget refreshes its ``PipelineFilesWidget`` after a run,
1072
+ commit, or finalize (the moments its on-disk output changes). Hooking
1073
+ each tracker's ``refreshed`` signal is what keeps the section dots and
1074
+ the catalog rail live: without it they stay frozen at whatever they
1075
+ showed when the position was last selected, so committing a label or
1076
+ running a contact analysis left the circles stale until a manual
1077
+ Refresh. Connecting the whole tree (via ``findChildren``) catches nested
1078
+ trackers too, e.g. the divergence-maps widget inside Cellpose.
1079
+ """
1080
+ for tracker in self.findChildren(PipelineFilesWidget):
1081
+ tracker.refreshed.connect(self._on_stage_files_refreshed)
1082
+
1083
+ def _on_stage_files_refreshed(self) -> None:
1084
+ """A stage's on-disk output changed → repaint section dots + rail.
1085
+
1086
+ Suppressed during ``_refresh_all`` (which drives every tracker and then
1087
+ recomputes once itself) so a bulk refresh doesn't re-read the catalog
1088
+ per tracker.
1089
+ """
1090
+ if getattr(self, "_refreshing_all", False):
1091
+ return
1092
+ self._update_section_statuses()
1093
+ self._positions_panel.refresh_statuses()
1094
+
1095
+ def _refresh_all(self) -> None:
1096
+ """Refresh file status in all child widgets."""
1097
+ pos_dir = self._pos_dir
1098
+
1099
+ self._refreshing_all = True
1100
+ try:
1101
+ if self._has_upstream:
1102
+ # The raw-input names are configured once, in the Data-folders
1103
+ # panel's discovery fields, and need not be the canonical
1104
+ # 0_input/*.tif. Hand them to the cellpose stage so its
1105
+ # run/preview/status track the file discovery pointed at.
1106
+ self._cellpose_widget.set_input_names(self._positions_panel.input_names())
1107
+ self._cellpose_widget.refresh(pos_dir)
1108
+ self.nucleus_workflow_widget.refresh(pos_dir)
1109
+ self.cell_workflow_widget.refresh(pos_dir)
1110
+ # The contact piece is position-agnostic; the orchestrator maps the
1111
+ # staged layout onto its explicit working context.
1112
+ if pos_dir is not None:
1113
+ # Consume the *committed* labels in the position base folder
1114
+ # (what finalize/"commit" writes), not the pre-commit working
1115
+ # ``3_cell`` / ``2_nucleus`` tracked labels — matching the paths
1116
+ # the aggregate catalog stamps (see ``_catalog_record_for_position``).
1117
+ self.contact_analysis_widget.set_context(
1118
+ cell_labels=pos_dir / _CELL_LABELS_RELPATH,
1119
+ nucleus_labels=pos_dir / _NUCLEUS_LABELS_RELPATH,
1120
+ out_path=pos_dir / CONTACT_ANALYSIS_RELPATH,
1121
+ status_root=pos_dir,
1122
+ )
1123
+ else:
1124
+ self.contact_analysis_widget.set_context(
1125
+ cell_labels=None, nucleus_labels=None, out_path=None, status_root=None
1126
+ )
1127
+ self._update_section_statuses()
1128
+ finally:
1129
+ self._refreshing_all = False
1130
+ # The catalog rail reads on-disk status per row directly; keep it in
1131
+ # step with the section dots on every full refresh.
1132
+ self._positions_panel.refresh_statuses()
1133
+ self._refresh_aggregate()
1134
+ # Emit signal for other widgets
1135
+ self.refresh_requested.emit(pos_dir)
1136
+
1137
+ def _update_section_statuses(self) -> None:
1138
+ """Refresh stage-status dots from on-disk file presence."""
1139
+ contact = pipeline_status_from_files(
1140
+ self.contact_analysis_widget._files_widget, done_group="Output"
1141
+ )
1142
+ self.contact_analysis_section.set_status(contact)
1143
+
1144
+ if not self._has_upstream:
1145
+ return
1146
+
1147
+ # The cellpose stage is "done" once its divergence maps exist (the same
1148
+ # done-signal the catalog rail derives from). Its tracker groups are
1149
+ # "Inputs" / "Cellpose Outputs" / "Divergence Maps" — there is no
1150
+ # "Outputs" group, so the old key never matched and the dot never lit.
1151
+ cellpose = pipeline_status_from_files(
1152
+ self._cellpose_widget.output_files_tracker, done_group="Divergence Maps"
1153
+ )
1154
+ nucleus = pipeline_status_from_files(
1155
+ self.nucleus_workflow_widget._files_widget, done_group="Output"
1156
+ )
1157
+ cell = pipeline_status_from_files(
1158
+ self.cell_workflow_widget._files_widget, done_group="Output"
1159
+ )
1160
+
1161
+ self.cellpose_section.set_status(cellpose)
1162
+ self.nucleus_section.set_status(nucleus)
1163
+ self.cell_section.set_status(cell)
1164
+
1165
+
1166
+ def make_aggregate_app_widget(napari_viewer: napari.Viewer | None = None):
1167
+ """napari entry point: the standalone ITASC Aggregate app.
1168
+
1169
+ The full catalog UI (Data folders → Contact Analysis → Aggregate capstone)
1170
+ restricted to the contact-analysis stage, with a three-dot per-position rail
1171
+ (cell labels → nucleus labels → contact analysis). The upstream
1172
+ segmentation/tracking stages are omitted, so the ``itasc-cellpose`` /
1173
+ ``itasc-tracking`` distributions need not be installed. Runs the napari
1174
+ layer-delegate patch (normally done by the orchestrator package).
1175
+ """
1176
+ try:
1177
+ from itasc.napari._napari_compat import patch_napari_layer_delegate
1178
+
1179
+ patch_napari_layer_delegate()
1180
+ except Exception: # pragma: no cover - patch is best-effort
1181
+ pass
1182
+ if napari_viewer is None:
1183
+ napari_viewer = napari.current_viewer()
1184
+ return ITASCMainWidget(napari_viewer, upstream_stages=False)