itasc 0.2.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,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)
|