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,1034 @@
|
|
|
1
|
+
"""Local Cellpose-SAM widget — per-channel rows with preview, run, cancel."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import napari
|
|
8
|
+
import numpy as np
|
|
9
|
+
import tifffile
|
|
10
|
+
from napari.qt.threading import thread_worker
|
|
11
|
+
from qtpy.QtCore import Signal
|
|
12
|
+
from qtpy.QtWidgets import (
|
|
13
|
+
QCheckBox,
|
|
14
|
+
QComboBox,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QLabel,
|
|
17
|
+
QProgressBar,
|
|
18
|
+
QSizePolicy,
|
|
19
|
+
QVBoxLayout,
|
|
20
|
+
QWidget,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from itasc.napari._standalone_paths import StandalonePathsMixin
|
|
24
|
+
from itasc.napari._widget_helpers import (
|
|
25
|
+
dslider as _dslider,
|
|
26
|
+
islider as _islider,
|
|
27
|
+
tool_btn as _tool_btn,
|
|
28
|
+
)
|
|
29
|
+
from itasc.napari.divergence_maps_widget import DivergenceMapsWidget
|
|
30
|
+
from itasc.napari.ui_gate import ControlClass, UiGate
|
|
31
|
+
from itasc.napari.ui_style import (
|
|
32
|
+
add_section_full_row,
|
|
33
|
+
add_section_pair_row,
|
|
34
|
+
section_grid,
|
|
35
|
+
stage_header_action_button,
|
|
36
|
+
stage_header_label,
|
|
37
|
+
status_label,
|
|
38
|
+
)
|
|
39
|
+
from itasc.napari.widgets import (
|
|
40
|
+
CollapsibleSection,
|
|
41
|
+
PipelineFilesWidget,
|
|
42
|
+
make_pipeline_files_header,
|
|
43
|
+
)
|
|
44
|
+
from itasc.cellpose import cellpose_runner
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Canonical declared rel-paths for the two configurable inputs. The actual
|
|
50
|
+
# location is resolved live in ``_input_path`` (a relative override under the
|
|
51
|
+
# position dir, an absolute path, or a standalone pick) and need not sit under
|
|
52
|
+
# ``0_input`` — see ``_input_overrides``, which feeds the real paths to the
|
|
53
|
+
# Pipeline Files panel so its status tracks the file the stage will read.
|
|
54
|
+
_INPUT_NUCLEUS_REL = "0_input/nucleus.tif"
|
|
55
|
+
_INPUT_CELL_REL = "0_input/cell.tif"
|
|
56
|
+
|
|
57
|
+
_PIPELINE_FILES = [
|
|
58
|
+
("Inputs", [
|
|
59
|
+
(_INPUT_NUCLEUS_REL, "Nucleus 3D+t"),
|
|
60
|
+
(_INPUT_CELL_REL, "Cell 3D+t"),
|
|
61
|
+
]),
|
|
62
|
+
("Cellpose Outputs", [
|
|
63
|
+
("1_cellpose/nucleus_prob.tif", "Nucleus prob 3D+t"),
|
|
64
|
+
("1_cellpose/nucleus_dp.tif", "Nucleus dp 3D+t"),
|
|
65
|
+
("1_cellpose/cell_prob.tif", "Cell prob 3D+t"),
|
|
66
|
+
("1_cellpose/cell_dp.tif", "Cell dp 3D+t"),
|
|
67
|
+
]),
|
|
68
|
+
("Divergence Maps", [
|
|
69
|
+
("1_cellpose/nucleus_contours.tif", "Nucleus contours"),
|
|
70
|
+
("1_cellpose/nucleus_foreground.tif", "Nucleus foreground"),
|
|
71
|
+
("1_cellpose/cell_contours.tif", "Cell contours"),
|
|
72
|
+
("1_cellpose/cell_foreground.tif", "Cell foreground"),
|
|
73
|
+
]),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_REFERENCE_LAYER_NAMES = {
|
|
78
|
+
"nucleus": "Reference: Nucleus 3D+t",
|
|
79
|
+
"cell": "Reference: Cell 3D+t",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Input dimensionality options; default to 3D+t (the historical assumption).
|
|
83
|
+
_LAYOUT_OPTIONS = ["2D", "2D+t", "3D", "3D+t"]
|
|
84
|
+
_DEFAULT_LAYOUT = "3D+t"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _layout_combo() -> QComboBox:
|
|
88
|
+
combo = QComboBox()
|
|
89
|
+
combo.addItems(_LAYOUT_OPTIONS)
|
|
90
|
+
combo.setCurrentText(_DEFAULT_LAYOUT)
|
|
91
|
+
return combo
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _make_status() -> QLabel:
|
|
95
|
+
lbl = QLabel("")
|
|
96
|
+
lbl.setWordWrap(True)
|
|
97
|
+
lbl.setVisible(False)
|
|
98
|
+
status_label(lbl)
|
|
99
|
+
return lbl
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_progress() -> QProgressBar:
|
|
103
|
+
bar = QProgressBar()
|
|
104
|
+
bar.setRange(0, 100)
|
|
105
|
+
bar.setValue(0)
|
|
106
|
+
bar.setTextVisible(True)
|
|
107
|
+
bar.setVisible(False)
|
|
108
|
+
return bar
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CellposeWidget(StandalonePathsMixin, QWidget):
|
|
112
|
+
"""Local Cellpose-SAM runner — two rows (Nucleus, Cell)."""
|
|
113
|
+
|
|
114
|
+
_progress_signal = Signal(int, int, str)
|
|
115
|
+
|
|
116
|
+
#: QSettings application key for the standalone path pickers.
|
|
117
|
+
_SETTINGS_APP = "itasc_cellpose"
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
viewer: napari.Viewer,
|
|
122
|
+
parent: QWidget | None = None,
|
|
123
|
+
gate: UiGate | None = None,
|
|
124
|
+
standalone: bool = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
super().__init__(parent)
|
|
127
|
+
self.viewer = viewer
|
|
128
|
+
#: App-wide UI gate; a private one is created for standalone use.
|
|
129
|
+
self.gate = gate if gate is not None else UiGate(self)
|
|
130
|
+
self._pos_dir: Path | None = None
|
|
131
|
+
#: Standalone explicit paths (orchestrated mode leaves these ``None`` and
|
|
132
|
+
#: derives everything from ``_pos_dir``'s staged subdirectories).
|
|
133
|
+
self._standalone = standalone
|
|
134
|
+
self._sa_nucleus: Path | None = None
|
|
135
|
+
self._sa_cell: Path | None = None
|
|
136
|
+
self._sa_output_dir: Path | None = None
|
|
137
|
+
self._running_stage: str | None = None
|
|
138
|
+
#: Whether the in-flight job can be cancelled. Full runs iterate frames
|
|
139
|
+
#: and poll ``_cancel_requested``; previews are a single blocking frame
|
|
140
|
+
#: with no cancellation point, so they show no ✕ and disable the row.
|
|
141
|
+
self._running_cancellable = True
|
|
142
|
+
self._worker = None
|
|
143
|
+
self._cancel_requested = False
|
|
144
|
+
|
|
145
|
+
self._setup_ui()
|
|
146
|
+
self._connect_signals()
|
|
147
|
+
self._register_gate_controls()
|
|
148
|
+
self._progress_signal.connect(self._progress)
|
|
149
|
+
if self._standalone:
|
|
150
|
+
self._load_standalone_settings()
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# UI
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
def _setup_ui(self) -> None:
|
|
156
|
+
root = QVBoxLayout(self)
|
|
157
|
+
root.setContentsMargins(2, 2, 2, 2)
|
|
158
|
+
root.setSpacing(6)
|
|
159
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
|
160
|
+
|
|
161
|
+
# ── Input pickers (both modes) + standalone output dir ─────────
|
|
162
|
+
# In the full app every path is fixed by the project structure
|
|
163
|
+
# (inputs at <pos_dir>/0_input/*.tif, maps at <pos_dir>/1_cellpose/),
|
|
164
|
+
# so none of these pickers are shown — see the container visibility gate
|
|
165
|
+
# below. Standalone: the two inputs are explicit and required, and the
|
|
166
|
+
# output-dir picker chooses where the maps are written.
|
|
167
|
+
self._paths_container = QWidget()
|
|
168
|
+
paths_col = QVBoxLayout(self._paths_container)
|
|
169
|
+
paths_col.setContentsMargins(0, 0, 0, 0)
|
|
170
|
+
self._nucleus_edit = self._add_path_row(
|
|
171
|
+
paths_col,
|
|
172
|
+
"Nucleus channel",
|
|
173
|
+
("raw nucleus stack (.tif)" if self._standalone
|
|
174
|
+
else "default: 0_input/nucleus.tif — or pick any .tif"),
|
|
175
|
+
self._on_browse_nucleus,
|
|
176
|
+
self._on_input_paths_changed,
|
|
177
|
+
)
|
|
178
|
+
self._cell_edit = self._add_path_row(
|
|
179
|
+
paths_col,
|
|
180
|
+
"Cell channel",
|
|
181
|
+
("raw cell stack (.tif)" if self._standalone
|
|
182
|
+
else "default: 0_input/cell.tif — or pick any .tif"),
|
|
183
|
+
self._on_browse_cell,
|
|
184
|
+
self._on_input_paths_changed,
|
|
185
|
+
)
|
|
186
|
+
self._output_dir_row = QWidget()
|
|
187
|
+
out_col = QVBoxLayout(self._output_dir_row)
|
|
188
|
+
out_col.setContentsMargins(0, 0, 0, 0)
|
|
189
|
+
self._output_dir_edit = self._add_path_row(
|
|
190
|
+
out_col,
|
|
191
|
+
"Output dir",
|
|
192
|
+
"directory for Cellpose maps",
|
|
193
|
+
self._on_browse_output_dir,
|
|
194
|
+
self._apply_standalone_paths,
|
|
195
|
+
)
|
|
196
|
+
paths_col.addWidget(self._output_dir_row)
|
|
197
|
+
root.addWidget(self._paths_container)
|
|
198
|
+
# The full app derives all paths from the project structure, so the
|
|
199
|
+
# input and output pickers are standalone-only.
|
|
200
|
+
self._paths_container.setVisible(self._standalone)
|
|
201
|
+
|
|
202
|
+
# ── Pipeline files ─────────────────────────────────────────────
|
|
203
|
+
self._files_widget = PipelineFilesWidget(_PIPELINE_FILES, viewer=self.viewer)
|
|
204
|
+
self.output_files_tracker = self._files_widget
|
|
205
|
+
self.input_files_tracker = self._files_widget
|
|
206
|
+
self._pipeline_files_section = CollapsibleSection(
|
|
207
|
+
"Pipeline Files", self._files_widget, expanded=False,
|
|
208
|
+
)
|
|
209
|
+
(
|
|
210
|
+
self.pipeline_files_header,
|
|
211
|
+
self.pipeline_files_header_lbl,
|
|
212
|
+
self.pipeline_files_toggle_btn,
|
|
213
|
+
) = make_pipeline_files_header(
|
|
214
|
+
self._pipeline_files_section, stage_key="cellpose", parent=self,
|
|
215
|
+
)
|
|
216
|
+
root.addWidget(self.pipeline_files_header)
|
|
217
|
+
root.addWidget(self._pipeline_files_section)
|
|
218
|
+
|
|
219
|
+
# ── Nucleus row + params ───────────────────────────────────────
|
|
220
|
+
self.nucleus_params_btn = _tool_btn(
|
|
221
|
+
"⚙", "Show parameters for nucleus Cellpose.", checkable=True,
|
|
222
|
+
)
|
|
223
|
+
self.nucleus_preview_btn = _tool_btn("▷", "Preview on current frame.")
|
|
224
|
+
self.nucleus_run_btn = _tool_btn("▶", "Run nucleus Cellpose on all frames.")
|
|
225
|
+
for button in (
|
|
226
|
+
self.nucleus_params_btn,
|
|
227
|
+
self.nucleus_preview_btn,
|
|
228
|
+
self.nucleus_run_btn,
|
|
229
|
+
):
|
|
230
|
+
stage_header_action_button(button, "cellpose")
|
|
231
|
+
self.nucleus_section = self._build_nucleus_params_section()
|
|
232
|
+
self.nucleus_section.set_header_visible(False)
|
|
233
|
+
self.nucleus_section.collapse()
|
|
234
|
+
self.nucleus_params_btn.toggled.connect(
|
|
235
|
+
lambda checked: self.nucleus_section._toggle.setChecked(checked)
|
|
236
|
+
)
|
|
237
|
+
root.addLayout(self._stage_row(
|
|
238
|
+
self._stage_label("Nucleus Cellpose"),
|
|
239
|
+
self.nucleus_params_btn,
|
|
240
|
+
self.nucleus_preview_btn,
|
|
241
|
+
self.nucleus_run_btn,
|
|
242
|
+
))
|
|
243
|
+
root.addWidget(self.nucleus_section)
|
|
244
|
+
|
|
245
|
+
# ── Cell row + params ──────────────────────────────────────────
|
|
246
|
+
self.cell_params_btn = _tool_btn(
|
|
247
|
+
"⚙", "Show parameters for cell Cellpose.", checkable=True,
|
|
248
|
+
)
|
|
249
|
+
self.cell_preview_btn = _tool_btn("▷", "Preview on current frame/z-slice.")
|
|
250
|
+
self.cell_run_btn = _tool_btn("▶", "Run cell Cellpose on all frames.")
|
|
251
|
+
for button in (
|
|
252
|
+
self.cell_params_btn,
|
|
253
|
+
self.cell_preview_btn,
|
|
254
|
+
self.cell_run_btn,
|
|
255
|
+
):
|
|
256
|
+
stage_header_action_button(button, "cellpose")
|
|
257
|
+
self.cell_section = self._build_cell_params_section()
|
|
258
|
+
self.cell_section.set_header_visible(False)
|
|
259
|
+
self.cell_section.collapse()
|
|
260
|
+
self.cell_params_btn.toggled.connect(
|
|
261
|
+
lambda checked: self.cell_section._toggle.setChecked(checked)
|
|
262
|
+
)
|
|
263
|
+
root.addLayout(self._stage_row(
|
|
264
|
+
self._stage_label("Cell Cellpose"),
|
|
265
|
+
self.cell_params_btn,
|
|
266
|
+
self.cell_preview_btn,
|
|
267
|
+
self.cell_run_btn,
|
|
268
|
+
))
|
|
269
|
+
root.addWidget(self.cell_section)
|
|
270
|
+
|
|
271
|
+
# ── Status + progress (shared) ─────────────────────────────────
|
|
272
|
+
self.status_lbl = _make_status()
|
|
273
|
+
root.addWidget(self.status_lbl)
|
|
274
|
+
self.progress_bar = _make_progress()
|
|
275
|
+
root.addWidget(self.progress_bar)
|
|
276
|
+
|
|
277
|
+
# ── Divergence maps from Cellpose prob/dp outputs ─────────────
|
|
278
|
+
self.divergence_maps_widget = DivergenceMapsWidget(
|
|
279
|
+
self.viewer,
|
|
280
|
+
show_pipeline_files=False,
|
|
281
|
+
gate=self.gate,
|
|
282
|
+
)
|
|
283
|
+
# The divergence widget owns no Pipeline Files panel here, yet its output
|
|
284
|
+
# (the foreground/contour maps) is the cellpose stage's done-signal.
|
|
285
|
+
# Refresh ours when it finishes so the section dot + catalog rail repaint.
|
|
286
|
+
self.divergence_maps_widget.maps_built.connect(self._on_divergence_maps_built)
|
|
287
|
+
root.addWidget(self.divergence_maps_widget)
|
|
288
|
+
|
|
289
|
+
def _build_nucleus_params_section(self) -> CollapsibleSection:
|
|
290
|
+
body = QWidget(self)
|
|
291
|
+
grid = section_grid()
|
|
292
|
+
grid.setContentsMargins(8, 4, 4, 4)
|
|
293
|
+
body.setLayout(grid)
|
|
294
|
+
self.nuc_layout_combo = _layout_combo()
|
|
295
|
+
self.nuc_3d_chk = QCheckBox("3D mode")
|
|
296
|
+
self.nuc_3d_chk.setChecked(True)
|
|
297
|
+
self.nuc_anisotropy_spin = _dslider(0.1, 20.0, 1.5, 0.1, 2)
|
|
298
|
+
self.nuc_diameter_spin = _dslider(0.0, 500.0, 25.0, 1.0, 1)
|
|
299
|
+
self.nuc_min_size_spin = _islider(0, 100000, 15)
|
|
300
|
+
self.nuc_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
|
|
301
|
+
row = 0
|
|
302
|
+
add_section_pair_row(grid, row, "Input layout:", self.nuc_layout_combo); row += 1
|
|
303
|
+
add_section_full_row(grid, row, self.nuc_3d_chk); row += 1
|
|
304
|
+
add_section_pair_row(
|
|
305
|
+
grid, row,
|
|
306
|
+
"Anisotropy:", self.nuc_anisotropy_spin,
|
|
307
|
+
"Diameter:", self.nuc_diameter_spin,
|
|
308
|
+
); row += 1
|
|
309
|
+
add_section_pair_row(
|
|
310
|
+
grid, row,
|
|
311
|
+
"Min size:", self.nuc_min_size_spin,
|
|
312
|
+
"Gamma:", self.nuc_gamma_spin,
|
|
313
|
+
)
|
|
314
|
+
# True-3D segmentation only makes sense when the input has a Z axis.
|
|
315
|
+
self.nuc_layout_combo.currentTextChanged.connect(self._sync_nucleus_3d_enabled)
|
|
316
|
+
self._sync_nucleus_3d_enabled(self.nuc_layout_combo.currentText())
|
|
317
|
+
return CollapsibleSection("Nucleus parameters", body, expanded=False)
|
|
318
|
+
|
|
319
|
+
def _sync_nucleus_3d_enabled(self, layout: str) -> None:
|
|
320
|
+
has_z = cellpose_runner.layout_has_z(layout)
|
|
321
|
+
self.nuc_3d_chk.setEnabled(has_z)
|
|
322
|
+
self.nuc_anisotropy_spin.setEnabled(has_z)
|
|
323
|
+
|
|
324
|
+
def _build_cell_params_section(self) -> CollapsibleSection:
|
|
325
|
+
body = QWidget(self)
|
|
326
|
+
grid = section_grid()
|
|
327
|
+
grid.setContentsMargins(8, 4, 4, 4)
|
|
328
|
+
body.setLayout(grid)
|
|
329
|
+
self.cell_layout_combo = _layout_combo()
|
|
330
|
+
self.cell_diameter_spin = _dslider(0.0, 500.0, 0.0, 1.0, 1)
|
|
331
|
+
self.cell_min_size_spin = _islider(0, 100000, 0)
|
|
332
|
+
self.cell_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
|
|
333
|
+
row = 0
|
|
334
|
+
add_section_pair_row(grid, row, "Input layout:", self.cell_layout_combo); row += 1
|
|
335
|
+
add_section_pair_row(
|
|
336
|
+
grid, row,
|
|
337
|
+
"Diameter:", self.cell_diameter_spin,
|
|
338
|
+
"Min size:", self.cell_min_size_spin,
|
|
339
|
+
); row += 1
|
|
340
|
+
add_section_pair_row(grid, row, "Gamma:", self.cell_gamma_spin)
|
|
341
|
+
return CollapsibleSection("Cell parameters", body, expanded=False)
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _stage_label(text: str) -> QLabel:
|
|
345
|
+
return stage_header_label(QLabel(text), "cellpose")
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
|
|
349
|
+
row = QHBoxLayout()
|
|
350
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
351
|
+
row.setSpacing(4)
|
|
352
|
+
row.addWidget(label)
|
|
353
|
+
for w in trailing:
|
|
354
|
+
row.addWidget(w)
|
|
355
|
+
row.addStretch(1)
|
|
356
|
+
return row
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# Signals (run/cancel handlers are filled in in later tasks)
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
def _connect_signals(self) -> None:
|
|
362
|
+
self.nucleus_run_btn.clicked.connect(self._on_nucleus_run_clicked)
|
|
363
|
+
self.cell_run_btn.clicked.connect(self._on_cell_run_clicked)
|
|
364
|
+
self.nucleus_preview_btn.clicked.connect(self._on_nucleus_preview)
|
|
365
|
+
self.cell_preview_btn.clicked.connect(self._on_cell_preview)
|
|
366
|
+
|
|
367
|
+
def _on_nucleus_run_clicked(self) -> None:
|
|
368
|
+
if self._running_stage is not None:
|
|
369
|
+
self._on_cancel()
|
|
370
|
+
return
|
|
371
|
+
self._run_channel("nucleus")
|
|
372
|
+
|
|
373
|
+
def _on_cell_run_clicked(self) -> None:
|
|
374
|
+
if self._running_stage is not None:
|
|
375
|
+
self._on_cancel()
|
|
376
|
+
return
|
|
377
|
+
self._run_channel("cell")
|
|
378
|
+
|
|
379
|
+
def _on_nucleus_preview(self) -> None:
|
|
380
|
+
self._preview_channel("nucleus")
|
|
381
|
+
|
|
382
|
+
def _on_cell_preview(self) -> None:
|
|
383
|
+
self._preview_channel("cell")
|
|
384
|
+
|
|
385
|
+
def _on_cancel(self) -> None:
|
|
386
|
+
self._cancel_requested = True
|
|
387
|
+
worker = self._worker
|
|
388
|
+
if worker is not None and hasattr(worker, "quit"):
|
|
389
|
+
worker.quit()
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
# Path helpers
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
def _input_path(self, channel: str) -> Path | None:
|
|
395
|
+
if self._standalone:
|
|
396
|
+
return self._sa_nucleus if channel == "nucleus" else self._sa_cell
|
|
397
|
+
if self._pos_dir is None:
|
|
398
|
+
return None
|
|
399
|
+
# Integrated: the input field overrides the default 0_input name. A blank
|
|
400
|
+
# field falls back to the canonical name; a relative override resolves
|
|
401
|
+
# under the position dir, an absolute one is used verbatim.
|
|
402
|
+
edit = self._nucleus_edit if channel == "nucleus" else self._cell_edit
|
|
403
|
+
default_name = "nucleus.tif" if channel == "nucleus" else "cell.tif"
|
|
404
|
+
text = edit.text().strip()
|
|
405
|
+
if text:
|
|
406
|
+
override = Path(text)
|
|
407
|
+
return override if override.is_absolute() else self._pos_dir / override
|
|
408
|
+
return self._pos_dir / "0_input" / default_name
|
|
409
|
+
|
|
410
|
+
def _output_dir(self) -> Path | None:
|
|
411
|
+
if self._standalone:
|
|
412
|
+
return self._sa_output_dir
|
|
413
|
+
return None if self._pos_dir is None else self._pos_dir / "1_cellpose"
|
|
414
|
+
|
|
415
|
+
def _input_overrides(self) -> dict[str, Path | None]:
|
|
416
|
+
"""Live locations for the two configurable Pipeline-Files input rows.
|
|
417
|
+
|
|
418
|
+
The rows are declared with the canonical ``0_input/*.tif`` names, but
|
|
419
|
+
the real input is configurable and need not sit under ``0_input``. Resolve
|
|
420
|
+
it via :meth:`_input_path` so the panel checks the file the stage reads.
|
|
421
|
+
"""
|
|
422
|
+
return {
|
|
423
|
+
_INPUT_NUCLEUS_REL: self._input_path("nucleus"),
|
|
424
|
+
_INPUT_CELL_REL: self._input_path("cell"),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
def _refresh_files(self, pos_dir: Path | None) -> None:
|
|
428
|
+
"""Refresh the Pipeline Files panel with the live input overrides."""
|
|
429
|
+
self._files_widget.refresh(pos_dir, overrides=self._input_overrides())
|
|
430
|
+
|
|
431
|
+
# ── Standalone helpers ─────────────────────────────────────────────
|
|
432
|
+
# Row building / browse plumbing / QSettings come from StandalonePathsMixin;
|
|
433
|
+
# the apply step is Cellpose-specific (two explicit input stacks + a flat
|
|
434
|
+
# output directory that also serves as the divergence widget's maps dir).
|
|
435
|
+
def _apply_standalone_paths(self) -> None:
|
|
436
|
+
nuc = self._nucleus_edit.text().strip()
|
|
437
|
+
cel = self._cell_edit.text().strip()
|
|
438
|
+
out = self._output_dir_edit.text().strip()
|
|
439
|
+
self._sa_nucleus = Path(nuc) if nuc else None
|
|
440
|
+
self._sa_cell = Path(cel) if cel else None
|
|
441
|
+
self._sa_output_dir = Path(out) if out else None
|
|
442
|
+
self._save_standalone_settings()
|
|
443
|
+
# The existing _pos_dir-based guards and the pipeline-files panel expect a
|
|
444
|
+
# real directory; the output dir doubles as the staged-file root here.
|
|
445
|
+
self._pos_dir = self._sa_output_dir
|
|
446
|
+
self._refresh_files(self._sa_output_dir)
|
|
447
|
+
self.divergence_maps_widget.set_maps_dir(self._sa_output_dir)
|
|
448
|
+
self.gate.recompute()
|
|
449
|
+
|
|
450
|
+
def _on_browse_nucleus(self) -> None:
|
|
451
|
+
self._browse_file_into(
|
|
452
|
+
self._nucleus_edit, "Select nucleus channel", self._on_nucleus_selected
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def _on_input_paths_changed(self) -> None:
|
|
456
|
+
"""Input-field edit handler.
|
|
457
|
+
|
|
458
|
+
Standalone reads all three pickers together (inputs + output dir);
|
|
459
|
+
integrated reads the two input fields lazily in :meth:`_input_path`, so a
|
|
460
|
+
light status refresh is enough — and calling the standalone apply here
|
|
461
|
+
would wrongly clobber ``_pos_dir`` with the (empty) output-dir field.
|
|
462
|
+
"""
|
|
463
|
+
if self._standalone:
|
|
464
|
+
self._apply_standalone_paths()
|
|
465
|
+
else:
|
|
466
|
+
self._refresh_files(self._pos_dir)
|
|
467
|
+
self.gate.recompute()
|
|
468
|
+
|
|
469
|
+
def _on_nucleus_selected(self) -> None:
|
|
470
|
+
self._on_input_paths_changed()
|
|
471
|
+
self._autoselect_layout("nucleus")
|
|
472
|
+
|
|
473
|
+
def _on_browse_cell(self) -> None:
|
|
474
|
+
self._browse_file_into(
|
|
475
|
+
self._cell_edit, "Select cell channel", self._on_cell_selected
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _on_cell_selected(self) -> None:
|
|
479
|
+
self._on_input_paths_changed()
|
|
480
|
+
self._autoselect_layout("cell")
|
|
481
|
+
|
|
482
|
+
def _on_browse_output_dir(self) -> None:
|
|
483
|
+
self._browse_dir_into(
|
|
484
|
+
self._output_dir_edit, "Select output directory", self._apply_standalone_paths
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _standalone_fields(self) -> dict:
|
|
488
|
+
return {
|
|
489
|
+
"nucleus": self._nucleus_edit,
|
|
490
|
+
"cell": self._cell_edit,
|
|
491
|
+
"output_dir": self._output_dir_edit,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
def _load_standalone_settings(self) -> None:
|
|
495
|
+
self._load_path_settings(self._SETTINGS_APP, self._standalone_fields())
|
|
496
|
+
if any(edit.text().strip() for edit in self._standalone_fields().values()):
|
|
497
|
+
self._apply_standalone_paths()
|
|
498
|
+
|
|
499
|
+
def _save_standalone_settings(self) -> None:
|
|
500
|
+
self._save_path_settings(self._SETTINGS_APP, self._standalone_fields())
|
|
501
|
+
|
|
502
|
+
# ------------------------------------------------------------------
|
|
503
|
+
# Run flow
|
|
504
|
+
# ------------------------------------------------------------------
|
|
505
|
+
def _channel_layout(self, channel: str) -> str:
|
|
506
|
+
combo = self.nuc_layout_combo if channel == "nucleus" else self.cell_layout_combo
|
|
507
|
+
return combo.currentText()
|
|
508
|
+
|
|
509
|
+
def _autoselect_layout(self, channel: str) -> None:
|
|
510
|
+
"""Best-effort: preselect the layout from the input file's ndim.
|
|
511
|
+
|
|
512
|
+
Only acts on the unambiguous 2-D / 4-D cases; a 3-D file keeps the user's
|
|
513
|
+
explicit choice. Never raises — a missing/unreadable file is ignored.
|
|
514
|
+
"""
|
|
515
|
+
path = self._input_path(channel)
|
|
516
|
+
if path is None:
|
|
517
|
+
return
|
|
518
|
+
try:
|
|
519
|
+
with tifffile.TiffFile(str(path)) as tf:
|
|
520
|
+
ndim = len(tf.series[0].shape)
|
|
521
|
+
except Exception:
|
|
522
|
+
return
|
|
523
|
+
inferred = cellpose_runner.infer_layout_from_ndim(ndim)
|
|
524
|
+
if inferred is None:
|
|
525
|
+
return
|
|
526
|
+
combo = self.nuc_layout_combo if channel == "nucleus" else self.cell_layout_combo
|
|
527
|
+
combo.setCurrentText(inferred)
|
|
528
|
+
|
|
529
|
+
def _build_nucleus_params(self) -> cellpose_runner.NucleusParams:
|
|
530
|
+
# True-3D segmentation requires a Z axis; a Z-less input forces 2D.
|
|
531
|
+
do_3d = self.nuc_3d_chk.isChecked() and cellpose_runner.layout_has_z(
|
|
532
|
+
self._channel_layout("nucleus")
|
|
533
|
+
)
|
|
534
|
+
return cellpose_runner.NucleusParams(
|
|
535
|
+
do_3d=do_3d,
|
|
536
|
+
anisotropy=float(self.nuc_anisotropy_spin.value()),
|
|
537
|
+
diameter=float(self.nuc_diameter_spin.value()),
|
|
538
|
+
min_size=int(self.nuc_min_size_spin.value()),
|
|
539
|
+
gamma=float(self.nuc_gamma_spin.value()),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _build_cell_params(self) -> cellpose_runner.CellParams:
|
|
543
|
+
return cellpose_runner.CellParams(
|
|
544
|
+
diameter=float(self.cell_diameter_spin.value()),
|
|
545
|
+
min_size=int(self.cell_min_size_spin.value()),
|
|
546
|
+
gamma=float(self.cell_gamma_spin.value()),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _run_channel(self, channel: str) -> None:
|
|
550
|
+
if self._pos_dir is None:
|
|
551
|
+
self._status("No project open.")
|
|
552
|
+
return
|
|
553
|
+
in_path = self._input_path(channel)
|
|
554
|
+
if in_path is None or not in_path.exists():
|
|
555
|
+
self._status(f"Missing: {in_path.name if in_path else '(no path)'}")
|
|
556
|
+
return
|
|
557
|
+
out_dir = self._output_dir()
|
|
558
|
+
params = (
|
|
559
|
+
self._build_nucleus_params() if channel == "nucleus"
|
|
560
|
+
else self._build_cell_params()
|
|
561
|
+
)
|
|
562
|
+
layout = self._channel_layout(channel)
|
|
563
|
+
pos_dir = self._pos_dir
|
|
564
|
+
self._cancel_requested = False
|
|
565
|
+
|
|
566
|
+
def _done(result):
|
|
567
|
+
self._worker = None
|
|
568
|
+
self._set_running_stage(None)
|
|
569
|
+
self._clear_progress()
|
|
570
|
+
self._refresh_files(pos_dir)
|
|
571
|
+
self._refresh_divergence(pos_dir)
|
|
572
|
+
label = "Nucleus" if channel == "nucleus" else "Cell"
|
|
573
|
+
self._status(f"{label} Cellpose complete — wrote {channel}_*.tif")
|
|
574
|
+
|
|
575
|
+
def _error(exc):
|
|
576
|
+
self._worker = None
|
|
577
|
+
self._set_running_stage(None)
|
|
578
|
+
self._clear_progress()
|
|
579
|
+
if isinstance(exc, cellpose_runner.CancelledError):
|
|
580
|
+
self._status("Cancelled.")
|
|
581
|
+
else:
|
|
582
|
+
self._status(f"Error: {exc}")
|
|
583
|
+
logger.exception("Cellpose run error", exc_info=exc)
|
|
584
|
+
|
|
585
|
+
progress_signal = self._progress_signal
|
|
586
|
+
|
|
587
|
+
@thread_worker(connect={
|
|
588
|
+
"yielded": self._on_progress,
|
|
589
|
+
"returned": _done,
|
|
590
|
+
"errored": _error,
|
|
591
|
+
})
|
|
592
|
+
def _worker():
|
|
593
|
+
yield (0, 1, "Loading input...")
|
|
594
|
+
stack = cellpose_runner.to_tzyx(
|
|
595
|
+
np.asarray(tifffile.imread(str(in_path))), layout
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def _cb_progress(done, total, msg):
|
|
599
|
+
progress_signal.emit(int(done), int(total), str(msg))
|
|
600
|
+
|
|
601
|
+
def _cb_cancel():
|
|
602
|
+
return self._cancel_requested
|
|
603
|
+
|
|
604
|
+
if channel == "nucleus":
|
|
605
|
+
prob, dp = cellpose_runner.run_nucleus_stack(
|
|
606
|
+
stack, params,
|
|
607
|
+
progress_cb=_cb_progress, cancel_cb=_cb_cancel,
|
|
608
|
+
)
|
|
609
|
+
else:
|
|
610
|
+
prob, dp = cellpose_runner.run_cell_stack(
|
|
611
|
+
stack, params,
|
|
612
|
+
progress_cb=_cb_progress, cancel_cb=_cb_cancel,
|
|
613
|
+
)
|
|
614
|
+
yield (1, 1, "Writing outputs...")
|
|
615
|
+
cellpose_runner.write_outputs(prob, dp, out_dir, channel)
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
self._set_running_stage(channel)
|
|
619
|
+
self._status(
|
|
620
|
+
f"Loading Cellpose-SAM model on {cellpose_runner.device_label()} "
|
|
621
|
+
f"(~10s on first run)..." if not cellpose_runner.is_model_loaded()
|
|
622
|
+
else f"Running {channel} Cellpose..."
|
|
623
|
+
)
|
|
624
|
+
self._worker = _worker()
|
|
625
|
+
|
|
626
|
+
# ------------------------------------------------------------------
|
|
627
|
+
# Preview flow
|
|
628
|
+
# ------------------------------------------------------------------
|
|
629
|
+
def _current_tz(self) -> tuple[int, int]:
|
|
630
|
+
step = getattr(getattr(self.viewer, "dims", None), "current_step", (0, 0))
|
|
631
|
+
t = int(step[0]) if len(step) >= 1 else 0
|
|
632
|
+
z = int(step[1]) if len(step) >= 2 else 0
|
|
633
|
+
return t, z
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def _flow_magnitude(dp: np.ndarray) -> np.ndarray:
|
|
637
|
+
# dp has shape (C, ...) — sum-of-squares over the channel axis.
|
|
638
|
+
return np.sqrt(np.sum(np.asarray(dp, dtype=np.float32) ** 2, axis=0))
|
|
639
|
+
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _sigmoid(x: np.ndarray) -> np.ndarray:
|
|
642
|
+
x = np.asarray(x, dtype=np.float32)
|
|
643
|
+
return (1.0 / (1.0 + np.exp(-x))).astype(np.float32)
|
|
644
|
+
|
|
645
|
+
@staticmethod
|
|
646
|
+
def _flow_contrast_limits(flow: np.ndarray) -> tuple[float, float]:
|
|
647
|
+
# Derive limits from the populated frame only — flow_full is mostly
|
|
648
|
+
# zeros, so napari's auto-contrast undersamples and clips the peaks.
|
|
649
|
+
hi = float(np.asarray(flow, dtype=np.float32).max())
|
|
650
|
+
return 0.0, max(hi, 1e-6)
|
|
651
|
+
|
|
652
|
+
def _preview_channel(self, channel: str) -> None:
|
|
653
|
+
if self._running_stage is not None:
|
|
654
|
+
self._status("Cellpose task already running.")
|
|
655
|
+
return
|
|
656
|
+
if self._pos_dir is None:
|
|
657
|
+
self._status("No project open.")
|
|
658
|
+
return
|
|
659
|
+
in_path = self._input_path(channel)
|
|
660
|
+
if in_path is None or not in_path.exists():
|
|
661
|
+
self._status(f"Missing: {in_path.name if in_path else '(no path)'}")
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
params = (
|
|
665
|
+
self._build_nucleus_params() if channel == "nucleus"
|
|
666
|
+
else self._build_cell_params()
|
|
667
|
+
)
|
|
668
|
+
layout = self._channel_layout(channel)
|
|
669
|
+
self._cancel_requested = False
|
|
670
|
+
self._set_running_stage(channel, cancellable=False)
|
|
671
|
+
self._progress(0, 0, f"Loading {channel} reference stack for preview...")
|
|
672
|
+
try:
|
|
673
|
+
stack = cellpose_runner.to_tzyx(
|
|
674
|
+
np.asarray(tifffile.imread(str(in_path))), layout
|
|
675
|
+
)
|
|
676
|
+
self._show_reference_stack(channel, stack)
|
|
677
|
+
t, z = self._current_tz()
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
self._set_running_stage(None)
|
|
680
|
+
self._clear_progress()
|
|
681
|
+
self._status(f"Error: {exc}")
|
|
682
|
+
logger.exception("Cellpose preview load error", exc_info=exc)
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
def _done(result):
|
|
686
|
+
self._worker = None
|
|
687
|
+
self._set_running_stage(None)
|
|
688
|
+
self._clear_progress()
|
|
689
|
+
status_msg, layers = result
|
|
690
|
+
for name, data, kwargs in layers:
|
|
691
|
+
self._show_layer(name, data, kwargs, self.viewer.add_image)
|
|
692
|
+
self._status(status_msg)
|
|
693
|
+
|
|
694
|
+
def _error(exc):
|
|
695
|
+
self._worker = None
|
|
696
|
+
self._set_running_stage(None)
|
|
697
|
+
self._clear_progress()
|
|
698
|
+
self._status(f"Error: {exc}")
|
|
699
|
+
logger.exception("Cellpose preview error", exc_info=exc)
|
|
700
|
+
|
|
701
|
+
@thread_worker(connect={
|
|
702
|
+
"yielded": self._on_progress,
|
|
703
|
+
"returned": _done,
|
|
704
|
+
"errored": _error,
|
|
705
|
+
})
|
|
706
|
+
def _worker():
|
|
707
|
+
T, Z = stack.shape[:2]
|
|
708
|
+
t_clamped = min(max(t, 0), T - 1)
|
|
709
|
+
z_clamped = min(max(z, 0), Z - 1)
|
|
710
|
+
|
|
711
|
+
if channel == "nucleus":
|
|
712
|
+
if params.do_3d:
|
|
713
|
+
yield (
|
|
714
|
+
0, 0,
|
|
715
|
+
f"Previewing nucleus 3D t={t_clamped} "
|
|
716
|
+
f"on {cellpose_runner.device_label()} "
|
|
717
|
+
f"(Z={Z}, anisotropy={params.anisotropy})...",
|
|
718
|
+
)
|
|
719
|
+
prob_logits, dp = cellpose_runner.run_nucleus_frame(
|
|
720
|
+
stack[t_clamped], z=None, params=params,
|
|
721
|
+
)
|
|
722
|
+
prob = self._sigmoid(prob_logits)
|
|
723
|
+
flow = self._flow_magnitude(dp) # (Z, Y, X)
|
|
724
|
+
prob_full = np.zeros((T, Z, *prob.shape[-2:]), dtype=np.float32)
|
|
725
|
+
flow_full = np.zeros_like(prob_full)
|
|
726
|
+
prob_full[t_clamped] = prob
|
|
727
|
+
flow_full[t_clamped] = flow
|
|
728
|
+
flow_clim = self._flow_contrast_limits(flow)
|
|
729
|
+
status_msg = (
|
|
730
|
+
f"Preview: nucleus 3D t={t_clamped} "
|
|
731
|
+
f"(Z={Z}, anisotropy={params.anisotropy})"
|
|
732
|
+
)
|
|
733
|
+
else:
|
|
734
|
+
yield (
|
|
735
|
+
0, 0,
|
|
736
|
+
f"Previewing nucleus 2D t={t_clamped} z={z_clamped} "
|
|
737
|
+
f"on {cellpose_runner.device_label()}...",
|
|
738
|
+
)
|
|
739
|
+
prob_logits, dp = cellpose_runner.run_nucleus_frame(
|
|
740
|
+
stack[t_clamped], z=z_clamped, params=params,
|
|
741
|
+
)
|
|
742
|
+
prob = self._sigmoid(prob_logits)
|
|
743
|
+
flow = self._flow_magnitude(dp) # (Y, X)
|
|
744
|
+
prob_full = np.zeros((T, Z, *prob.shape), dtype=np.float32)
|
|
745
|
+
flow_full = np.zeros_like(prob_full)
|
|
746
|
+
prob_full[t_clamped, z_clamped] = prob
|
|
747
|
+
flow_full[t_clamped, z_clamped] = flow
|
|
748
|
+
flow_clim = self._flow_contrast_limits(flow)
|
|
749
|
+
status_msg = (
|
|
750
|
+
f"Preview: nucleus 2D t={t_clamped} z={z_clamped} "
|
|
751
|
+
f"(diameter={params.diameter})"
|
|
752
|
+
)
|
|
753
|
+
return status_msg, [
|
|
754
|
+
(
|
|
755
|
+
"Preview: Nucleus prob",
|
|
756
|
+
prob_full,
|
|
757
|
+
{
|
|
758
|
+
"colormap": "viridis",
|
|
759
|
+
"blending": "additive",
|
|
760
|
+
"contrast_limits": (0.0, 1.0),
|
|
761
|
+
},
|
|
762
|
+
),
|
|
763
|
+
(
|
|
764
|
+
"Preview: Nucleus flow",
|
|
765
|
+
flow_full,
|
|
766
|
+
{
|
|
767
|
+
"colormap": "inferno",
|
|
768
|
+
"blending": "additive",
|
|
769
|
+
"contrast_limits": flow_clim,
|
|
770
|
+
},
|
|
771
|
+
),
|
|
772
|
+
]
|
|
773
|
+
|
|
774
|
+
yield (
|
|
775
|
+
0, 0,
|
|
776
|
+
f"Previewing cell 2D t={t_clamped} z={z_clamped} "
|
|
777
|
+
f"on {cellpose_runner.device_label()}...",
|
|
778
|
+
)
|
|
779
|
+
prob_logits, dp = cellpose_runner.run_cell_frame(
|
|
780
|
+
stack[t_clamped], z=z_clamped, params=params,
|
|
781
|
+
)
|
|
782
|
+
prob = self._sigmoid(prob_logits)
|
|
783
|
+
flow = self._flow_magnitude(dp)
|
|
784
|
+
prob_full = np.zeros((T, Z, *prob.shape), dtype=np.float32)
|
|
785
|
+
flow_full = np.zeros_like(prob_full)
|
|
786
|
+
prob_full[t_clamped, z_clamped] = prob
|
|
787
|
+
flow_full[t_clamped, z_clamped] = flow
|
|
788
|
+
flow_clim = self._flow_contrast_limits(flow)
|
|
789
|
+
return (
|
|
790
|
+
f"Preview: cell t={t_clamped} z={z_clamped} "
|
|
791
|
+
f"(diameter={params.diameter})",
|
|
792
|
+
[
|
|
793
|
+
(
|
|
794
|
+
"Preview: Cell prob",
|
|
795
|
+
prob_full,
|
|
796
|
+
{
|
|
797
|
+
"colormap": "viridis",
|
|
798
|
+
"blending": "additive",
|
|
799
|
+
"contrast_limits": (0.0, 1.0),
|
|
800
|
+
},
|
|
801
|
+
),
|
|
802
|
+
(
|
|
803
|
+
"Preview: Cell flow",
|
|
804
|
+
flow_full,
|
|
805
|
+
{
|
|
806
|
+
"colormap": "inferno",
|
|
807
|
+
"blending": "additive",
|
|
808
|
+
"contrast_limits": flow_clim,
|
|
809
|
+
},
|
|
810
|
+
),
|
|
811
|
+
],
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
self._worker = _worker()
|
|
815
|
+
|
|
816
|
+
def _show_reference_stack(self, channel: str, stack: np.ndarray) -> None:
|
|
817
|
+
name = _REFERENCE_LAYER_NAMES[channel]
|
|
818
|
+
self._show_layer(
|
|
819
|
+
name, stack,
|
|
820
|
+
{"colormap": "gray", "blending": "additive"},
|
|
821
|
+
self.viewer.add_image,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
# ------------------------------------------------------------------
|
|
825
|
+
# Public API
|
|
826
|
+
# ------------------------------------------------------------------
|
|
827
|
+
def set_input_names(self, names: dict[str, str]) -> None:
|
|
828
|
+
"""Adopt the host's configured raw-input names (integrated mode only).
|
|
829
|
+
|
|
830
|
+
The full app configures the input names once, in the Data-folders panel's
|
|
831
|
+
discovery fields, and they need not be the canonical ``0_input/*.tif``.
|
|
832
|
+
Mirror them into the (hidden) per-channel input fields so ``_input_path``
|
|
833
|
+
— and thus run, preview, and Pipeline Files status — track the file the
|
|
834
|
+
stage will actually read. Standalone owns its own explicit pickers.
|
|
835
|
+
"""
|
|
836
|
+
if self._standalone:
|
|
837
|
+
return
|
|
838
|
+
self._nucleus_edit.setText(names.get("nucleus", ""))
|
|
839
|
+
self._cell_edit.setText(names.get("cell", ""))
|
|
840
|
+
# editingFinished does not fire on programmatic setText — refresh by hand.
|
|
841
|
+
self._on_input_paths_changed()
|
|
842
|
+
|
|
843
|
+
def refresh(self, pos_dir: Path | None) -> None:
|
|
844
|
+
self._pos_dir = pos_dir
|
|
845
|
+
self._refresh_files(pos_dir)
|
|
846
|
+
self._refresh_divergence(pos_dir)
|
|
847
|
+
if pos_dir is not None:
|
|
848
|
+
self._autoselect_layout("nucleus")
|
|
849
|
+
self._autoselect_layout("cell")
|
|
850
|
+
|
|
851
|
+
def _on_divergence_maps_built(self) -> None:
|
|
852
|
+
"""Repaint Pipeline Files after the embedded divergence widget writes maps.
|
|
853
|
+
|
|
854
|
+
The maps are this stage's tracked output but the divergence widget has no
|
|
855
|
+
Pipeline Files panel of its own, so refresh ours against the active root
|
|
856
|
+
(standalone output dir or the staged position dir).
|
|
857
|
+
"""
|
|
858
|
+
root = self._sa_output_dir if self._standalone else self._pos_dir
|
|
859
|
+
self._refresh_files(root)
|
|
860
|
+
|
|
861
|
+
def _refresh_divergence(self, pos_dir: Path | None) -> None:
|
|
862
|
+
"""Point the embedded divergence widget at the active maps location.
|
|
863
|
+
|
|
864
|
+
Orchestrated: maps live under ``<pos_dir>/1_cellpose``. Standalone: maps
|
|
865
|
+
live directly in the chosen output directory.
|
|
866
|
+
"""
|
|
867
|
+
if self._standalone:
|
|
868
|
+
self.divergence_maps_widget.set_maps_dir(self._sa_output_dir)
|
|
869
|
+
else:
|
|
870
|
+
self.divergence_maps_widget.refresh(pos_dir)
|
|
871
|
+
|
|
872
|
+
def get_state(self) -> dict:
|
|
873
|
+
return {
|
|
874
|
+
"nucleus": {
|
|
875
|
+
"layout": self.nuc_layout_combo.currentText(),
|
|
876
|
+
"do_3d": self.nuc_3d_chk.isChecked(),
|
|
877
|
+
"anisotropy": self.nuc_anisotropy_spin.value(),
|
|
878
|
+
"diameter": self.nuc_diameter_spin.value(),
|
|
879
|
+
"min_size": self.nuc_min_size_spin.value(),
|
|
880
|
+
"gamma": self.nuc_gamma_spin.value(),
|
|
881
|
+
},
|
|
882
|
+
"cell": {
|
|
883
|
+
"layout": self.cell_layout_combo.currentText(),
|
|
884
|
+
"diameter": self.cell_diameter_spin.value(),
|
|
885
|
+
"min_size": self.cell_min_size_spin.value(),
|
|
886
|
+
"gamma": self.cell_gamma_spin.value(),
|
|
887
|
+
},
|
|
888
|
+
"divergence_maps": self.divergence_maps_widget.get_state(),
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
def set_state(self, state: dict) -> None:
|
|
892
|
+
if not isinstance(state, dict):
|
|
893
|
+
return
|
|
894
|
+
nuc = state.get("nucleus", {})
|
|
895
|
+
if isinstance(nuc, dict):
|
|
896
|
+
if nuc.get("layout") in _LAYOUT_OPTIONS:
|
|
897
|
+
self.nuc_layout_combo.setCurrentText(nuc["layout"])
|
|
898
|
+
if "do_3d" in nuc:
|
|
899
|
+
self.nuc_3d_chk.setChecked(bool(nuc["do_3d"]))
|
|
900
|
+
if "anisotropy" in nuc:
|
|
901
|
+
self.nuc_anisotropy_spin.setValue(float(nuc["anisotropy"]))
|
|
902
|
+
if "diameter" in nuc:
|
|
903
|
+
self.nuc_diameter_spin.setValue(float(nuc["diameter"]))
|
|
904
|
+
if "min_size" in nuc:
|
|
905
|
+
self.nuc_min_size_spin.setValue(int(nuc["min_size"]))
|
|
906
|
+
if "gamma" in nuc:
|
|
907
|
+
self.nuc_gamma_spin.setValue(float(nuc["gamma"]))
|
|
908
|
+
cel = state.get("cell", {})
|
|
909
|
+
if isinstance(cel, dict):
|
|
910
|
+
if cel.get("layout") in _LAYOUT_OPTIONS:
|
|
911
|
+
self.cell_layout_combo.setCurrentText(cel["layout"])
|
|
912
|
+
if "diameter" in cel:
|
|
913
|
+
self.cell_diameter_spin.setValue(float(cel["diameter"]))
|
|
914
|
+
if "min_size" in cel:
|
|
915
|
+
self.cell_min_size_spin.setValue(int(cel["min_size"]))
|
|
916
|
+
if "gamma" in cel:
|
|
917
|
+
self.cell_gamma_spin.setValue(float(cel["gamma"]))
|
|
918
|
+
if "divergence_maps" in state:
|
|
919
|
+
self.divergence_maps_widget.set_state(state["divergence_maps"])
|
|
920
|
+
|
|
921
|
+
# ------------------------------------------------------------------
|
|
922
|
+
# State helpers
|
|
923
|
+
# ------------------------------------------------------------------
|
|
924
|
+
def _status(self, msg: str) -> None:
|
|
925
|
+
self.status_lbl.setText(msg)
|
|
926
|
+
self.status_lbl.setVisible(bool(msg))
|
|
927
|
+
if msg:
|
|
928
|
+
logger.info(msg)
|
|
929
|
+
|
|
930
|
+
def _progress(self, done: int, total: int, msg: str) -> None:
|
|
931
|
+
self.progress_bar.setVisible(True)
|
|
932
|
+
self.progress_bar.setRange(0, total)
|
|
933
|
+
self.progress_bar.setValue(done)
|
|
934
|
+
self._status(msg)
|
|
935
|
+
|
|
936
|
+
def _on_progress(self, data) -> None:
|
|
937
|
+
if isinstance(data, tuple):
|
|
938
|
+
self._progress(*data)
|
|
939
|
+
else:
|
|
940
|
+
self._status(str(data))
|
|
941
|
+
|
|
942
|
+
def _clear_progress(self) -> None:
|
|
943
|
+
self.progress_bar.setValue(0)
|
|
944
|
+
self.progress_bar.setVisible(False)
|
|
945
|
+
|
|
946
|
+
def _register_gate_controls(self) -> None:
|
|
947
|
+
"""Register the two channel rows with the app-wide UI gate.
|
|
948
|
+
|
|
949
|
+
Cellpose writes to the viewer, so its run/preview/params are blocked
|
|
950
|
+
while any viewer owner (correction / live preview) is active. Within
|
|
951
|
+
the widget, the active channel's row stays usable while the other is
|
|
952
|
+
disabled — expressed via ``when`` predicates over ``_running_stage``.
|
|
953
|
+
"""
|
|
954
|
+
g = self.gate
|
|
955
|
+
idle = lambda: self._running_stage is None
|
|
956
|
+
for channel, params_btn, preview_btn, run_btn in (
|
|
957
|
+
("nucleus", self.nucleus_params_btn, self.nucleus_preview_btn, self.nucleus_run_btn),
|
|
958
|
+
("cell", self.cell_params_btn, self.cell_preview_btn, self.cell_run_btn),
|
|
959
|
+
):
|
|
960
|
+
# The run/✕ button stays live on its own row only while a
|
|
961
|
+
# cancellable job (a full run) is in flight; a preview disables it.
|
|
962
|
+
own = lambda c=channel: self._running_stage is None or (
|
|
963
|
+
self._running_stage == c and self._running_cancellable
|
|
964
|
+
)
|
|
965
|
+
# ⚙ params just toggle a parameter panel — always available.
|
|
966
|
+
g.register(params_btn, ControlClass.HARMLESS)
|
|
967
|
+
g.register(preview_btn, ControlClass.RUN_VIEWER, when=idle)
|
|
968
|
+
g.register(run_btn, ControlClass.RUN_VIEWER, when=own)
|
|
969
|
+
g.recompute()
|
|
970
|
+
|
|
971
|
+
def _set_running_stage(self, stage_key: str | None, *, cancellable: bool = True) -> None:
|
|
972
|
+
"""Swap the active row's ▶/✕ glyph; enablement is owned by the gate.
|
|
973
|
+
|
|
974
|
+
``None`` means idle; ``'nucleus'`` or ``'cell'`` claims that row. The
|
|
975
|
+
gate's ``when`` predicates read ``self._running_stage`` to disable the
|
|
976
|
+
other row and the active row's preview while a job is in flight.
|
|
977
|
+
|
|
978
|
+
``cancellable`` is ``True`` for full runs (the ✕ cancels them); previews
|
|
979
|
+
pass ``False`` — they can't be interrupted, so the glyph stays ▶ and the
|
|
980
|
+
gate disables the whole row until the frame returns.
|
|
981
|
+
"""
|
|
982
|
+
self._running_stage = stage_key
|
|
983
|
+
self._running_cancellable = cancellable
|
|
984
|
+
if stage_key is None:
|
|
985
|
+
self.nucleus_run_btn.setText("▶")
|
|
986
|
+
self.nucleus_run_btn.setToolTip("Run nucleus Cellpose on all frames.")
|
|
987
|
+
self.cell_run_btn.setText("▶")
|
|
988
|
+
self.cell_run_btn.setToolTip("Run cell Cellpose on all frames.")
|
|
989
|
+
self._cancel_requested = False
|
|
990
|
+
elif cancellable:
|
|
991
|
+
run_btn = self.nucleus_run_btn if stage_key == "nucleus" else self.cell_run_btn
|
|
992
|
+
run_btn.setText("✕")
|
|
993
|
+
run_btn.setToolTip("Cancel.")
|
|
994
|
+
self.gate.recompute()
|
|
995
|
+
|
|
996
|
+
# ------------------------------------------------------------------
|
|
997
|
+
# Layer helper (mirrors CellWorkflowWidget._show_layer)
|
|
998
|
+
# ------------------------------------------------------------------
|
|
999
|
+
def _show_layer(self, name, data, kwargs, adder):
|
|
1000
|
+
if name in self.viewer.layers:
|
|
1001
|
+
try:
|
|
1002
|
+
layer = self.viewer.layers[name]
|
|
1003
|
+
layer.data = data
|
|
1004
|
+
clim = kwargs.get("contrast_limits")
|
|
1005
|
+
if clim is not None:
|
|
1006
|
+
layer.contrast_limits = clim
|
|
1007
|
+
return
|
|
1008
|
+
except Exception:
|
|
1009
|
+
self.viewer.layers.remove(self.viewer.layers[name])
|
|
1010
|
+
adder(data, name=name, **kwargs)
|
|
1011
|
+
else:
|
|
1012
|
+
adder(data, name=name, **kwargs)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def make_cellpose_widget(napari_viewer=None):
|
|
1016
|
+
"""napari plugin factory for the standalone Cellpose piece.
|
|
1017
|
+
|
|
1018
|
+
Patches the napari layer-controls delegate (best-effort) and returns the
|
|
1019
|
+
Cellpose widget in standalone mode, with its own nucleus/cell input stacks
|
|
1020
|
+
and output-directory pickers.
|
|
1021
|
+
"""
|
|
1022
|
+
try:
|
|
1023
|
+
from itasc.napari._napari_compat import patch_napari_layer_delegate
|
|
1024
|
+
|
|
1025
|
+
patch_napari_layer_delegate()
|
|
1026
|
+
except Exception:
|
|
1027
|
+
pass
|
|
1028
|
+
# napari does not inject the viewer into function-based widget factories
|
|
1029
|
+
# (only into class-based callables / magicgui types), so ``napari_viewer``
|
|
1030
|
+
# arrives as ``None``. The widget needs a live viewer, so fall back to the
|
|
1031
|
+
# active one.
|
|
1032
|
+
if napari_viewer is None:
|
|
1033
|
+
napari_viewer = napari.current_viewer()
|
|
1034
|
+
return CellposeWidget(napari_viewer, standalone=True)
|