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,795 @@
|
|
|
1
|
+
"""Per-channel widget that builds nucleus/cell foreground & contour maps
|
|
2
|
+
directly from Cellpose ``prob_3dt`` and ``dp_3dt`` outputs.
|
|
3
|
+
|
|
4
|
+
Mirrors :class:`CellposeWidget` layout (one row per channel with
|
|
5
|
+
⚙ params / ▶ run-cancel and a shared status + progress bar).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import napari
|
|
15
|
+
import numpy as np
|
|
16
|
+
from napari.qt.threading import thread_worker
|
|
17
|
+
from qtpy.QtCore import QTimer, Signal
|
|
18
|
+
from qtpy.QtWidgets import (
|
|
19
|
+
QComboBox,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QLabel,
|
|
22
|
+
QProgressBar,
|
|
23
|
+
QSizePolicy,
|
|
24
|
+
QVBoxLayout,
|
|
25
|
+
QWidget,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from itasc.napari._preview_cache import FramePreviewCache
|
|
29
|
+
from itasc.napari._widget_helpers import (
|
|
30
|
+
dslider as _dslider,
|
|
31
|
+
islider as _islider,
|
|
32
|
+
tool_btn as _tool_btn,
|
|
33
|
+
)
|
|
34
|
+
from itasc.napari.ui_gate import ControlClass, UiGate
|
|
35
|
+
from itasc.napari.ui_style import (
|
|
36
|
+
add_section_pair_row,
|
|
37
|
+
section_grid,
|
|
38
|
+
stage_header_action_button,
|
|
39
|
+
stage_header_label,
|
|
40
|
+
status_label,
|
|
41
|
+
)
|
|
42
|
+
from itasc.napari.widgets import (
|
|
43
|
+
CollapsibleSection,
|
|
44
|
+
PipelineFilesWidget,
|
|
45
|
+
make_pipeline_files_header,
|
|
46
|
+
)
|
|
47
|
+
from itasc.core.cancellation import CancelledError
|
|
48
|
+
from itasc.cellpose.divergence_maps import (
|
|
49
|
+
_LazyTiffStack,
|
|
50
|
+
build_divergence_maps,
|
|
51
|
+
contour_from_dp,
|
|
52
|
+
foreground_from_prob,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Per-channel live-preview layer names: (foreground, contours).
|
|
58
|
+
_PREVIEW_LAYER_NAMES = {
|
|
59
|
+
"nucleus": (
|
|
60
|
+
"Divergence preview: Nucleus foreground",
|
|
61
|
+
"Divergence preview: Nucleus contours",
|
|
62
|
+
),
|
|
63
|
+
"cell": (
|
|
64
|
+
"Divergence preview: Cell foreground",
|
|
65
|
+
"Divergence preview: Cell contours",
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_PIPELINE_FILES = [
|
|
71
|
+
("Inputs", [
|
|
72
|
+
("1_cellpose/nucleus_prob.tif", "Nucleus prob 3D+t"),
|
|
73
|
+
("1_cellpose/nucleus_dp.tif", "Nucleus dp 3D+t"),
|
|
74
|
+
("1_cellpose/cell_prob.tif", "Cell prob 3D+t"),
|
|
75
|
+
("1_cellpose/cell_dp.tif", "Cell dp 3D+t"),
|
|
76
|
+
]),
|
|
77
|
+
("Outputs", [
|
|
78
|
+
("1_cellpose/nucleus_contours.tif", "Nucleus contours"),
|
|
79
|
+
("1_cellpose/nucleus_foreground.tif", "Nucleus foreground"),
|
|
80
|
+
("1_cellpose/cell_contours.tif", "Cell contours"),
|
|
81
|
+
("1_cellpose/cell_foreground.tif", "Cell foreground"),
|
|
82
|
+
]),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _make_status() -> QLabel:
|
|
87
|
+
lbl = QLabel("")
|
|
88
|
+
lbl.setWordWrap(True)
|
|
89
|
+
lbl.setVisible(False)
|
|
90
|
+
status_label(lbl)
|
|
91
|
+
return lbl
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _make_progress() -> QProgressBar:
|
|
95
|
+
bar = QProgressBar()
|
|
96
|
+
bar.setRange(0, 100)
|
|
97
|
+
bar.setValue(0)
|
|
98
|
+
bar.setTextVisible(True)
|
|
99
|
+
bar.setVisible(False)
|
|
100
|
+
return bar
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DivergenceMapsWidget(QWidget):
|
|
104
|
+
"""Build per-channel foreground & contour maps from Cellpose prob/dp."""
|
|
105
|
+
|
|
106
|
+
_progress_signal = Signal(int, int, str)
|
|
107
|
+
#: Emitted after a channel's maps are written to disk. The embedded (Cellpose)
|
|
108
|
+
#: use passes ``show_pipeline_files=False`` and so has no own Pipeline Files
|
|
109
|
+
#: panel to refresh; the host connects this to repaint the cellpose status.
|
|
110
|
+
maps_built = Signal()
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
viewer: napari.Viewer,
|
|
115
|
+
parent: QWidget | None = None,
|
|
116
|
+
*,
|
|
117
|
+
show_pipeline_files: bool = True,
|
|
118
|
+
gate: UiGate | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
super().__init__(parent)
|
|
121
|
+
self.viewer = viewer
|
|
122
|
+
#: App-wide UI gate; a private one is created for standalone use.
|
|
123
|
+
self.gate = gate if gate is not None else UiGate(self)
|
|
124
|
+
self._pos_dir: Path | None = None
|
|
125
|
+
#: Standalone override: when set, prob/dp/foreground/contour maps resolve
|
|
126
|
+
#: flatly under this directory instead of ``<pos_dir>/1_cellpose``.
|
|
127
|
+
self._maps_dir: Path | None = None
|
|
128
|
+
self._running_stage: str | None = None
|
|
129
|
+
self._worker = None
|
|
130
|
+
self._cancel_event: threading.Event | None = None
|
|
131
|
+
self._show_pipeline_files = bool(show_pipeline_files)
|
|
132
|
+
|
|
133
|
+
# Live-preview state. Previews are mutually-exclusive viewer owners, so
|
|
134
|
+
# at most one channel is active at a time.
|
|
135
|
+
self._active_preview_channel: str | None = None
|
|
136
|
+
self._preview_worker = None
|
|
137
|
+
self._preview_pending = False
|
|
138
|
+
# Per-frame result cache (frame t → (fg, contour)) keyed on the active
|
|
139
|
+
# channel + its params, so scrubbing back to a computed frame repaints
|
|
140
|
+
# instantly and any param edit drops every cached frame. Mirrors the
|
|
141
|
+
# cell-segmentation preview's cache; freed when the preview deactivates.
|
|
142
|
+
self._preview_cache = FramePreviewCache()
|
|
143
|
+
# Preview layers whose contrast has not yet been seeded from real data.
|
|
144
|
+
self._preview_needs_autocontrast: set[str] = set()
|
|
145
|
+
self._preview_timer = QTimer(self)
|
|
146
|
+
self._preview_timer.setSingleShot(True)
|
|
147
|
+
self._preview_timer.setInterval(150)
|
|
148
|
+
self._preview_timer.timeout.connect(self._refresh_preview)
|
|
149
|
+
|
|
150
|
+
self._setup_ui()
|
|
151
|
+
self._connect_signals()
|
|
152
|
+
self._register_gate_controls()
|
|
153
|
+
self._progress_signal.connect(self._progress)
|
|
154
|
+
|
|
155
|
+
# ── UI ───────────────────────────────────────────────────────────
|
|
156
|
+
def _setup_ui(self) -> None:
|
|
157
|
+
root = QVBoxLayout(self)
|
|
158
|
+
root.setContentsMargins(2, 2, 2, 2)
|
|
159
|
+
root.setSpacing(6)
|
|
160
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
|
161
|
+
|
|
162
|
+
if self._show_pipeline_files:
|
|
163
|
+
self._files_widget = PipelineFilesWidget(_PIPELINE_FILES, viewer=self.viewer)
|
|
164
|
+
self.output_files_tracker = self._files_widget
|
|
165
|
+
self.input_files_tracker = self._files_widget
|
|
166
|
+
self._pipeline_files_section = CollapsibleSection(
|
|
167
|
+
"Pipeline Files", self._files_widget, expanded=False,
|
|
168
|
+
)
|
|
169
|
+
(
|
|
170
|
+
self.pipeline_files_header,
|
|
171
|
+
self.pipeline_files_header_lbl,
|
|
172
|
+
self.pipeline_files_toggle_btn,
|
|
173
|
+
) = make_pipeline_files_header(
|
|
174
|
+
self._pipeline_files_section, stage_key="cellpose", parent=self,
|
|
175
|
+
)
|
|
176
|
+
root.addWidget(self.pipeline_files_header)
|
|
177
|
+
root.addWidget(self._pipeline_files_section)
|
|
178
|
+
else:
|
|
179
|
+
self._files_widget = None
|
|
180
|
+
|
|
181
|
+
# Nucleus row
|
|
182
|
+
self.nucleus_params_btn = _tool_btn(
|
|
183
|
+
"⚙", "Show parameters for nucleus divergence maps.", checkable=True,
|
|
184
|
+
)
|
|
185
|
+
self.nucleus_preview_btn = _tool_btn(
|
|
186
|
+
"◉", "Live preview nucleus divergence maps on the current frame.",
|
|
187
|
+
checkable=True,
|
|
188
|
+
)
|
|
189
|
+
self.nucleus_run_btn = _tool_btn("▶", "Build nucleus divergence maps.")
|
|
190
|
+
for button in (
|
|
191
|
+
self.nucleus_params_btn, self.nucleus_preview_btn, self.nucleus_run_btn,
|
|
192
|
+
):
|
|
193
|
+
stage_header_action_button(button, "cellpose")
|
|
194
|
+
self.nucleus_section = self._build_channel_params_section("nucleus")
|
|
195
|
+
self.nucleus_section.set_header_visible(False)
|
|
196
|
+
self.nucleus_section.collapse()
|
|
197
|
+
self.nucleus_params_btn.toggled.connect(
|
|
198
|
+
lambda checked: self.nucleus_section._toggle.setChecked(checked)
|
|
199
|
+
)
|
|
200
|
+
root.addLayout(self._stage_row(
|
|
201
|
+
self._stage_label("Nucleus divergence maps"),
|
|
202
|
+
self.nucleus_params_btn,
|
|
203
|
+
self.nucleus_preview_btn,
|
|
204
|
+
self.nucleus_run_btn,
|
|
205
|
+
))
|
|
206
|
+
root.addWidget(self.nucleus_section)
|
|
207
|
+
|
|
208
|
+
# Cell row
|
|
209
|
+
self.cell_params_btn = _tool_btn(
|
|
210
|
+
"⚙", "Show parameters for cell divergence maps.", checkable=True,
|
|
211
|
+
)
|
|
212
|
+
self.cell_preview_btn = _tool_btn(
|
|
213
|
+
"◉", "Live preview cell divergence maps on the current frame.",
|
|
214
|
+
checkable=True,
|
|
215
|
+
)
|
|
216
|
+
self.cell_run_btn = _tool_btn("▶", "Build cell divergence maps.")
|
|
217
|
+
for button in (
|
|
218
|
+
self.cell_params_btn, self.cell_preview_btn, self.cell_run_btn,
|
|
219
|
+
):
|
|
220
|
+
stage_header_action_button(button, "cellpose")
|
|
221
|
+
self.cell_section = self._build_channel_params_section("cell")
|
|
222
|
+
self.cell_section.set_header_visible(False)
|
|
223
|
+
self.cell_section.collapse()
|
|
224
|
+
self.cell_params_btn.toggled.connect(
|
|
225
|
+
lambda checked: self.cell_section._toggle.setChecked(checked)
|
|
226
|
+
)
|
|
227
|
+
root.addLayout(self._stage_row(
|
|
228
|
+
self._stage_label("Cell divergence maps"),
|
|
229
|
+
self.cell_params_btn,
|
|
230
|
+
self.cell_preview_btn,
|
|
231
|
+
self.cell_run_btn,
|
|
232
|
+
))
|
|
233
|
+
root.addWidget(self.cell_section)
|
|
234
|
+
|
|
235
|
+
self.status_lbl = _make_status()
|
|
236
|
+
root.addWidget(self.status_lbl)
|
|
237
|
+
self.progress_bar = _make_progress()
|
|
238
|
+
root.addWidget(self.progress_bar)
|
|
239
|
+
|
|
240
|
+
def _build_channel_params_section(
|
|
241
|
+
self, channel: Literal["nucleus", "cell"],
|
|
242
|
+
) -> CollapsibleSection:
|
|
243
|
+
body = QWidget(self)
|
|
244
|
+
grid = section_grid()
|
|
245
|
+
grid.setContentsMargins(8, 4, 4, 4)
|
|
246
|
+
body.setLayout(grid)
|
|
247
|
+
|
|
248
|
+
fg_reduction = QComboBox()
|
|
249
|
+
fg_reduction.addItems(["mean", "max"])
|
|
250
|
+
fg_reduction.setCurrentText("mean")
|
|
251
|
+
contour_reduction = QComboBox()
|
|
252
|
+
contour_reduction.addItems(["mean", "max"])
|
|
253
|
+
contour_reduction.setCurrentText("mean")
|
|
254
|
+
fg_smoothing_spin = _dslider(0.0, 20.0, 0.0, 0.1, 2)
|
|
255
|
+
fg_median_spin = _islider(0, 20, 0)
|
|
256
|
+
smoothing_spin = _dslider(0.0, 20.0, 1.0, 0.1, 2)
|
|
257
|
+
median_spin = _islider(0, 20, 0)
|
|
258
|
+
row = 0
|
|
259
|
+
add_section_pair_row(
|
|
260
|
+
grid, row,
|
|
261
|
+
"Foreground z-reduction:", fg_reduction,
|
|
262
|
+
"Contour z-reduction:", contour_reduction,
|
|
263
|
+
); row += 1
|
|
264
|
+
add_section_pair_row(
|
|
265
|
+
grid, row,
|
|
266
|
+
"Foreground smoothing sigma:", fg_smoothing_spin,
|
|
267
|
+
"Foreground median radius:", fg_median_spin,
|
|
268
|
+
); row += 1
|
|
269
|
+
add_section_pair_row(
|
|
270
|
+
grid, row,
|
|
271
|
+
"Contour smoothing sigma:", smoothing_spin,
|
|
272
|
+
"Contour median radius:", median_spin,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
prefix = "nuc" if channel == "nucleus" else "cell"
|
|
276
|
+
setattr(self, f"{prefix}_fg_reduction", fg_reduction)
|
|
277
|
+
setattr(self, f"{prefix}_contour_reduction", contour_reduction)
|
|
278
|
+
setattr(self, f"{prefix}_fg_smoothing_spin", fg_smoothing_spin)
|
|
279
|
+
setattr(self, f"{prefix}_fg_median_spin", fg_median_spin)
|
|
280
|
+
setattr(self, f"{prefix}_smoothing_spin", smoothing_spin)
|
|
281
|
+
setattr(self, f"{prefix}_median_spin", median_spin)
|
|
282
|
+
return CollapsibleSection(
|
|
283
|
+
f"{channel.title()} parameters", body, expanded=False,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _stage_label(text: str) -> QLabel:
|
|
288
|
+
return stage_header_label(QLabel(text), "cellpose")
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
|
|
292
|
+
row = QHBoxLayout()
|
|
293
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
294
|
+
row.setSpacing(4)
|
|
295
|
+
row.addWidget(label)
|
|
296
|
+
for w in trailing:
|
|
297
|
+
row.addWidget(w)
|
|
298
|
+
row.addStretch(1)
|
|
299
|
+
return row
|
|
300
|
+
|
|
301
|
+
# ── Signals ──────────────────────────────────────────────────────
|
|
302
|
+
def _connect_signals(self) -> None:
|
|
303
|
+
self.nucleus_run_btn.clicked.connect(lambda: self._on_run("nucleus"))
|
|
304
|
+
self.cell_run_btn.clicked.connect(lambda: self._on_run("cell"))
|
|
305
|
+
self.nucleus_preview_btn.toggled.connect(
|
|
306
|
+
lambda checked: self._on_preview_toggled("nucleus", checked)
|
|
307
|
+
)
|
|
308
|
+
self.cell_preview_btn.toggled.connect(
|
|
309
|
+
lambda checked: self._on_preview_toggled("cell", checked)
|
|
310
|
+
)
|
|
311
|
+
for prefix in ("nuc", "cell"):
|
|
312
|
+
getattr(self, f"{prefix}_fg_reduction").currentTextChanged.connect(
|
|
313
|
+
self._on_param_changed
|
|
314
|
+
)
|
|
315
|
+
getattr(self, f"{prefix}_contour_reduction").currentTextChanged.connect(
|
|
316
|
+
self._on_param_changed
|
|
317
|
+
)
|
|
318
|
+
for suffix in (
|
|
319
|
+
"fg_smoothing_spin", "fg_median_spin",
|
|
320
|
+
"smoothing_spin", "median_spin",
|
|
321
|
+
):
|
|
322
|
+
getattr(self, f"{prefix}_{suffix}").valueChanged.connect(
|
|
323
|
+
self._on_param_changed
|
|
324
|
+
)
|
|
325
|
+
if hasattr(self.viewer, "dims") and hasattr(self.viewer.dims, "events"):
|
|
326
|
+
try:
|
|
327
|
+
self.viewer.dims.events.current_step.connect(self._on_time_changed)
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
def _register_gate_controls(self) -> None:
|
|
332
|
+
"""Register the two channel rows with the app-wide UI gate.
|
|
333
|
+
|
|
334
|
+
Each channel's ◉ live preview is a mutually-exclusive viewer owner.
|
|
335
|
+
Build/run writes data downstream owners view, so it is blocked while any
|
|
336
|
+
owner is active. ⚙ params just toggle a panel and stay available.
|
|
337
|
+
"""
|
|
338
|
+
g = self.gate
|
|
339
|
+
has_pos = lambda: self._resolved_maps_dir() is not None
|
|
340
|
+
idle = lambda: self._running_stage is None
|
|
341
|
+
for channel, params_btn, preview_btn, run_btn in (
|
|
342
|
+
("nucleus", self.nucleus_params_btn, self.nucleus_preview_btn, self.nucleus_run_btn),
|
|
343
|
+
("cell", self.cell_params_btn, self.cell_preview_btn, self.cell_run_btn),
|
|
344
|
+
):
|
|
345
|
+
token = f"div_preview:{channel}"
|
|
346
|
+
g.register_owner(
|
|
347
|
+
token,
|
|
348
|
+
f"{channel} divergence preview",
|
|
349
|
+
exit_fn=lambda b=preview_btn: b.setChecked(False),
|
|
350
|
+
)
|
|
351
|
+
g.register(params_btn, ControlClass.HARMLESS)
|
|
352
|
+
g.register(
|
|
353
|
+
preview_btn,
|
|
354
|
+
ControlClass.VIEWER_OWNER,
|
|
355
|
+
owner_token=token,
|
|
356
|
+
when=lambda: has_pos() and idle(),
|
|
357
|
+
)
|
|
358
|
+
g.register(
|
|
359
|
+
run_btn,
|
|
360
|
+
ControlClass.RUN_VIEWER,
|
|
361
|
+
when=lambda c=channel: has_pos() and self._running_stage in (None, c),
|
|
362
|
+
)
|
|
363
|
+
g.recompute()
|
|
364
|
+
|
|
365
|
+
def _on_run(self, channel: Literal["nucleus", "cell"]) -> None:
|
|
366
|
+
if self._running_stage is not None:
|
|
367
|
+
self._on_cancel()
|
|
368
|
+
return
|
|
369
|
+
self._start_worker(channel)
|
|
370
|
+
|
|
371
|
+
def _start_worker(self, channel: Literal["nucleus", "cell"]) -> None:
|
|
372
|
+
prob_path, dp_path, contours_out, fg_out = self._channel_paths(channel)
|
|
373
|
+
if prob_path is None:
|
|
374
|
+
self._set_status("No project open.")
|
|
375
|
+
return
|
|
376
|
+
for p in (prob_path, dp_path):
|
|
377
|
+
if p is None or not p.exists():
|
|
378
|
+
self._set_status(f"Missing: {p}")
|
|
379
|
+
return
|
|
380
|
+
params = self._channel_state("nuc" if channel == "nucleus" else "cell")
|
|
381
|
+
cancel_event = threading.Event()
|
|
382
|
+
self._cancel_event = cancel_event
|
|
383
|
+
self._running_stage = channel
|
|
384
|
+
self._set_button_running(channel)
|
|
385
|
+
|
|
386
|
+
def _done(report) -> None:
|
|
387
|
+
self._worker = None
|
|
388
|
+
self._cancel_event = None
|
|
389
|
+
self._running_stage = None
|
|
390
|
+
self._set_button_idle()
|
|
391
|
+
self._progress_bar_hide()
|
|
392
|
+
self._set_status(
|
|
393
|
+
f"{channel.title()} divergence maps built ({report.frames} frames)."
|
|
394
|
+
)
|
|
395
|
+
if self._files_widget is not None:
|
|
396
|
+
self._files_widget.refresh(self._pos_dir)
|
|
397
|
+
# The maps are the cellpose stage's done-signal; tell the host so it
|
|
398
|
+
# refreshes even when this widget has no Pipeline Files panel of its
|
|
399
|
+
# own (the embedded, show_pipeline_files=False case).
|
|
400
|
+
self.maps_built.emit()
|
|
401
|
+
|
|
402
|
+
@thread_worker(
|
|
403
|
+
connect={
|
|
404
|
+
"yielded": self._on_yield,
|
|
405
|
+
"returned": _done,
|
|
406
|
+
"errored": self._on_errored,
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
def _worker():
|
|
410
|
+
progress_signal = self._progress_signal
|
|
411
|
+
|
|
412
|
+
def _progress_cb(done: int, total: int, msg: str) -> None:
|
|
413
|
+
progress_signal.emit(
|
|
414
|
+
int(done),
|
|
415
|
+
int(total),
|
|
416
|
+
self._channel_progress_message(channel, str(msg)),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
yield (0, 1, f"Starting {channel} divergence maps...")
|
|
420
|
+
return build_divergence_maps(
|
|
421
|
+
prob_path,
|
|
422
|
+
dp_path,
|
|
423
|
+
contours_out,
|
|
424
|
+
fg_out,
|
|
425
|
+
foreground_z_reduction=params["foreground_z_reduction"],
|
|
426
|
+
contour_z_reduction=params["contour_z_reduction"],
|
|
427
|
+
smoothing_sigma=params["smoothing_sigma"],
|
|
428
|
+
median_radius=params["median_radius"],
|
|
429
|
+
foreground_smoothing_sigma=params["foreground_smoothing_sigma"],
|
|
430
|
+
foreground_median_radius=params["foreground_median_radius"],
|
|
431
|
+
progress_cb=_progress_cb,
|
|
432
|
+
cancel=cancel_event.is_set,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
self._worker = _worker()
|
|
436
|
+
|
|
437
|
+
def _run_blocking(self, channel: Literal["nucleus", "cell"]) -> None:
|
|
438
|
+
"""Synchronous test helper: runs build_divergence_maps in this thread."""
|
|
439
|
+
prob_path, dp_path, contours_out, fg_out = self._channel_paths(channel)
|
|
440
|
+
if prob_path is None or dp_path is None:
|
|
441
|
+
raise RuntimeError("No project open.")
|
|
442
|
+
params = self._channel_state("nuc" if channel == "nucleus" else "cell")
|
|
443
|
+
contours_out.parent.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
build_divergence_maps(
|
|
445
|
+
prob_path,
|
|
446
|
+
dp_path,
|
|
447
|
+
contours_out,
|
|
448
|
+
fg_out,
|
|
449
|
+
foreground_z_reduction=params["foreground_z_reduction"],
|
|
450
|
+
contour_z_reduction=params["contour_z_reduction"],
|
|
451
|
+
smoothing_sigma=params["smoothing_sigma"],
|
|
452
|
+
median_radius=params["median_radius"],
|
|
453
|
+
foreground_smoothing_sigma=params["foreground_smoothing_sigma"],
|
|
454
|
+
foreground_median_radius=params["foreground_median_radius"],
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _channel_paths(self, channel: Literal["nucleus", "cell"]):
|
|
458
|
+
d = self._resolved_maps_dir()
|
|
459
|
+
if d is None:
|
|
460
|
+
return None, None, None, None
|
|
461
|
+
return (
|
|
462
|
+
d / f"{channel}_prob.tif",
|
|
463
|
+
d / f"{channel}_dp.tif",
|
|
464
|
+
d / f"{channel}_contours.tif",
|
|
465
|
+
d / f"{channel}_foreground.tif",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _set_status(self, msg: str) -> None:
|
|
469
|
+
self.status_lbl.setText(msg)
|
|
470
|
+
self.status_lbl.setVisible(bool(msg))
|
|
471
|
+
|
|
472
|
+
def _progress_bar_hide(self) -> None:
|
|
473
|
+
self.progress_bar.setValue(0)
|
|
474
|
+
self.progress_bar.setVisible(False)
|
|
475
|
+
|
|
476
|
+
def _set_button_running(self, channel: Literal["nucleus", "cell"]) -> None:
|
|
477
|
+
run_btn = self.nucleus_run_btn if channel == "nucleus" else self.cell_run_btn
|
|
478
|
+
run_btn.setText("✕")
|
|
479
|
+
run_btn.setToolTip("Cancel.")
|
|
480
|
+
# Enablement (other row, previews) is derived by the gate from
|
|
481
|
+
# ``_running_stage``.
|
|
482
|
+
self.gate.recompute()
|
|
483
|
+
|
|
484
|
+
def _set_button_idle(self) -> None:
|
|
485
|
+
for btn, tip in (
|
|
486
|
+
(self.nucleus_run_btn, "Build nucleus divergence maps."),
|
|
487
|
+
(self.cell_run_btn, "Build cell divergence maps."),
|
|
488
|
+
):
|
|
489
|
+
btn.setText("▶")
|
|
490
|
+
btn.setToolTip(tip)
|
|
491
|
+
self.gate.recompute()
|
|
492
|
+
|
|
493
|
+
def _on_yield(self, payload) -> None:
|
|
494
|
+
if not isinstance(payload, tuple):
|
|
495
|
+
self._set_status(str(payload))
|
|
496
|
+
return
|
|
497
|
+
done, total, msg = payload
|
|
498
|
+
self._progress(done, total, msg)
|
|
499
|
+
|
|
500
|
+
def _on_errored(self, exc: Exception) -> None:
|
|
501
|
+
self._worker = None
|
|
502
|
+
self._cancel_event = None
|
|
503
|
+
self._running_stage = None
|
|
504
|
+
self._set_button_idle()
|
|
505
|
+
self._progress_bar_hide()
|
|
506
|
+
if isinstance(exc, CancelledError):
|
|
507
|
+
self._set_status("Cancelled.")
|
|
508
|
+
return
|
|
509
|
+
logger.exception("Divergence-maps worker error", exc_info=exc)
|
|
510
|
+
self._set_status(f"Error: {exc}")
|
|
511
|
+
|
|
512
|
+
def _on_cancel(self) -> None:
|
|
513
|
+
if self._cancel_event is not None:
|
|
514
|
+
self._cancel_event.set()
|
|
515
|
+
if self._worker is not None and hasattr(self._worker, "quit"):
|
|
516
|
+
self._worker.quit()
|
|
517
|
+
|
|
518
|
+
def _progress(self, done: int, total: int, msg: str) -> None:
|
|
519
|
+
self.progress_bar.setVisible(True)
|
|
520
|
+
self.progress_bar.setRange(0, max(1, total))
|
|
521
|
+
self.progress_bar.setValue(done)
|
|
522
|
+
self.status_lbl.setText(msg)
|
|
523
|
+
self.status_lbl.setVisible(bool(msg))
|
|
524
|
+
|
|
525
|
+
@staticmethod
|
|
526
|
+
def _channel_progress_message(channel: Literal["nucleus", "cell"], msg: str) -> str:
|
|
527
|
+
prefix = "Divergence maps: "
|
|
528
|
+
if msg.startswith(prefix):
|
|
529
|
+
return f"{channel.title()} divergence maps: {msg[len(prefix):]}"
|
|
530
|
+
return msg
|
|
531
|
+
|
|
532
|
+
# ── Live preview (single frame) ──────────────────────────────────
|
|
533
|
+
def _on_preview_toggled(self, channel: Literal["nucleus", "cell"], checked: bool) -> None:
|
|
534
|
+
token = f"div_preview:{channel}"
|
|
535
|
+
if checked:
|
|
536
|
+
self._active_preview_channel = channel
|
|
537
|
+
self.gate.claim_viewer(token)
|
|
538
|
+
self._refresh_preview()
|
|
539
|
+
else:
|
|
540
|
+
if self._active_preview_channel == channel:
|
|
541
|
+
self._active_preview_channel = None
|
|
542
|
+
self._preview_pending = False
|
|
543
|
+
self.gate.release_viewer(token)
|
|
544
|
+
self._teardown_preview_layers(channel)
|
|
545
|
+
self._preview_cache.clear()
|
|
546
|
+
self._set_status("")
|
|
547
|
+
|
|
548
|
+
def _on_param_changed(self, *_args) -> None:
|
|
549
|
+
if self._active_preview_channel is not None:
|
|
550
|
+
self._preview_timer.start()
|
|
551
|
+
|
|
552
|
+
def _on_time_changed(self, *_args) -> None:
|
|
553
|
+
if self._active_preview_channel is not None:
|
|
554
|
+
self._preview_timer.start()
|
|
555
|
+
|
|
556
|
+
def _refresh_preview(self):
|
|
557
|
+
"""Repaint the active channel's current-frame preview, computing if needed.
|
|
558
|
+
|
|
559
|
+
Reuses a cached frame (same channel + params) for an instant repaint —
|
|
560
|
+
even while a worker is in flight — and only starts a compute for a frame
|
|
561
|
+
the cache doesn't already hold. While a pass is in flight, further edits
|
|
562
|
+
arm ``_preview_pending`` so one fresh pass (latest params/frame) fires
|
|
563
|
+
when the current one returns.
|
|
564
|
+
"""
|
|
565
|
+
channel = self._active_preview_channel
|
|
566
|
+
if channel is None:
|
|
567
|
+
return None
|
|
568
|
+
prob_path, dp_path, _, _ = self._channel_paths(channel)
|
|
569
|
+
if (
|
|
570
|
+
prob_path is None or not prob_path.exists()
|
|
571
|
+
or dp_path is None or not dp_path.exists()
|
|
572
|
+
):
|
|
573
|
+
self._set_status("Cellpose prob/dp not found — run Cellpose first.")
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
shape = self._channel_map_shape(prob_path)
|
|
577
|
+
if shape is None:
|
|
578
|
+
self._set_status("Could not read prob map.")
|
|
579
|
+
return None
|
|
580
|
+
# Create full-length (T, Y, X) layers up front so napari shows a time
|
|
581
|
+
# slider even before any frame is computed; each visited frame fills its
|
|
582
|
+
# own slice on demand.
|
|
583
|
+
self._ensure_preview_layers(channel, shape)
|
|
584
|
+
|
|
585
|
+
t = max(0, min(self._current_t(), shape[0] - 1))
|
|
586
|
+
params = self._channel_state("nuc" if channel == "nucleus" else "cell")
|
|
587
|
+
key = (channel, tuple(sorted(params.items())))
|
|
588
|
+
self._preview_cache.sync(key)
|
|
589
|
+
|
|
590
|
+
cached = self._preview_cache.get(t)
|
|
591
|
+
if cached is not None:
|
|
592
|
+
self._paint_preview(channel, t, *cached)
|
|
593
|
+
self._set_status(f"{channel.title()} divergence preview — frame {t}.")
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
if self._preview_worker is not None:
|
|
597
|
+
self._preview_pending = True
|
|
598
|
+
return self._preview_worker
|
|
599
|
+
|
|
600
|
+
self._set_status(f"Computing {channel} divergence preview for frame {t}…")
|
|
601
|
+
|
|
602
|
+
@thread_worker(connect={
|
|
603
|
+
"returned": self._preview_done,
|
|
604
|
+
"errored": self._preview_error,
|
|
605
|
+
})
|
|
606
|
+
def _worker():
|
|
607
|
+
fg, contour = self._compute_channel_frame(prob_path, dp_path, t, params)
|
|
608
|
+
return channel, key, t, fg, contour
|
|
609
|
+
|
|
610
|
+
self._preview_worker = _worker()
|
|
611
|
+
return self._preview_worker
|
|
612
|
+
|
|
613
|
+
def _preview_done(self, payload) -> None:
|
|
614
|
+
self._preview_worker = None
|
|
615
|
+
channel, key, t, fg, contour = payload
|
|
616
|
+
self._preview_cache.put(key, t, (fg, contour))
|
|
617
|
+
if self._active_preview_channel == channel:
|
|
618
|
+
self._paint_preview(channel, t, fg, contour)
|
|
619
|
+
self._set_status(f"{channel.title()} divergence preview — frame {t}.")
|
|
620
|
+
if self._preview_pending and self._active_preview_channel is not None:
|
|
621
|
+
self._preview_pending = False
|
|
622
|
+
self._refresh_preview()
|
|
623
|
+
|
|
624
|
+
def _paint_preview(self, channel, t: int, fg, contour) -> None:
|
|
625
|
+
"""Fill frame ``t``'s slice of the channel's foreground + contour layers."""
|
|
626
|
+
fg_name, ct_name = _PREVIEW_LAYER_NAMES[channel]
|
|
627
|
+
self._fill_image_layer(fg_name, t, fg)
|
|
628
|
+
self._fill_image_layer(ct_name, t, contour)
|
|
629
|
+
|
|
630
|
+
def _preview_error(self, exc: Exception) -> None:
|
|
631
|
+
self._preview_worker = None
|
|
632
|
+
logger.exception("Divergence preview worker error", exc_info=exc)
|
|
633
|
+
self._set_status(f"Preview failed: {exc}")
|
|
634
|
+
|
|
635
|
+
def _teardown_preview_layers(self, channel: Literal["nucleus", "cell"]) -> None:
|
|
636
|
+
for name in _PREVIEW_LAYER_NAMES[channel]:
|
|
637
|
+
if name in self.viewer.layers:
|
|
638
|
+
self.viewer.layers.remove(name)
|
|
639
|
+
self._preview_needs_autocontrast.discard(name)
|
|
640
|
+
|
|
641
|
+
def _compute_channel_frame(self, prob_path, dp_path, t: int, params: dict):
|
|
642
|
+
"""Single-frame foreground + contour maps (worker-thread body)."""
|
|
643
|
+
# Read through the same lazy stack the build path uses so singleton
|
|
644
|
+
# ``Z``/``T`` axes (squeezed on disk for 2D / 2D+t / single-z stacks) are
|
|
645
|
+
# recovered to canonical ``(Z,Y,X)`` / ``(Z,2,Y,X)`` frames.
|
|
646
|
+
prob_t = _LazyTiffStack(prob_path, ndim=4, name="prob").frame(t) # (Z, Y, X)
|
|
647
|
+
dp_t = _LazyTiffStack(dp_path, ndim=5, name="dp").frame(t) # (Z, 2, Y, X)
|
|
648
|
+
fg = foreground_from_prob(
|
|
649
|
+
prob_t[np.newaxis],
|
|
650
|
+
reduction=params["foreground_z_reduction"],
|
|
651
|
+
smoothing_sigma=params["foreground_smoothing_sigma"],
|
|
652
|
+
median_radius=params["foreground_median_radius"],
|
|
653
|
+
)[0]
|
|
654
|
+
contour = contour_from_dp(
|
|
655
|
+
dp_t[np.newaxis],
|
|
656
|
+
smoothing_sigma=params["smoothing_sigma"],
|
|
657
|
+
median_radius=params["median_radius"],
|
|
658
|
+
reduction=params["contour_z_reduction"],
|
|
659
|
+
)[0]
|
|
660
|
+
return fg, contour
|
|
661
|
+
|
|
662
|
+
def _channel_map_shape(self, prob_path):
|
|
663
|
+
"""``(T, Y, X)`` from the prob-map TIFF header (no pixel load).
|
|
664
|
+
|
|
665
|
+
Goes through :class:`_LazyTiffStack` so a singleton ``Z`` that TIFF
|
|
666
|
+
squeezes on disk (2D / 2D+t / single-z stacks) is still recovered to the
|
|
667
|
+
canonical ``(T, Z, Y, X)`` shape — otherwise the surviving leading axis is
|
|
668
|
+
mistaken for ``Z`` and ``T`` collapses to ``1``, leaving the preview a
|
|
669
|
+
single-frame layer that renders black at every other timepoint.
|
|
670
|
+
"""
|
|
671
|
+
try:
|
|
672
|
+
shape = _LazyTiffStack(prob_path, ndim=4, name="prob").shape
|
|
673
|
+
except Exception:
|
|
674
|
+
return None
|
|
675
|
+
return int(shape[0]), int(shape[-2]), int(shape[-1])
|
|
676
|
+
|
|
677
|
+
def _current_t(self) -> int:
|
|
678
|
+
step = getattr(getattr(self.viewer, "dims", None), "current_step", (0,))
|
|
679
|
+
return int(step[0]) if len(step) >= 1 else 0
|
|
680
|
+
|
|
681
|
+
def _ensure_preview_layers(self, channel: Literal["nucleus", "cell"], shape) -> None:
|
|
682
|
+
"""Create zero-filled ``(T, Y, X)`` preview layers if absent/mis-shaped.
|
|
683
|
+
|
|
684
|
+
A full-length stack is what gives napari a time slider; per-frame slices
|
|
685
|
+
are filled on demand as the user scrubs.
|
|
686
|
+
"""
|
|
687
|
+
fg_name, ct_name = _PREVIEW_LAYER_NAMES[channel]
|
|
688
|
+
self._ensure_image_layer(fg_name, shape, "gray")
|
|
689
|
+
self._ensure_image_layer(ct_name, shape, "magma")
|
|
690
|
+
|
|
691
|
+
def _ensure_image_layer(self, name: str, shape, colormap: str) -> None:
|
|
692
|
+
if name in self.viewer.layers:
|
|
693
|
+
layer = self.viewer.layers[name]
|
|
694
|
+
if tuple(layer.data.shape) == tuple(shape):
|
|
695
|
+
return
|
|
696
|
+
self.viewer.layers.remove(name)
|
|
697
|
+
self.viewer.add_image(
|
|
698
|
+
np.zeros(shape, dtype=np.float32), name=name, colormap=colormap,
|
|
699
|
+
)
|
|
700
|
+
# Seed contrast from the first real frame this layer receives.
|
|
701
|
+
self._preview_needs_autocontrast.add(name)
|
|
702
|
+
|
|
703
|
+
def _fill_image_layer(self, name: str, t: int, frame: np.ndarray) -> None:
|
|
704
|
+
if name not in self.viewer.layers:
|
|
705
|
+
return
|
|
706
|
+
layer = self.viewer.layers[name]
|
|
707
|
+
data = layer.data
|
|
708
|
+
if getattr(data, "ndim", 0) != 3 or not 0 <= t < data.shape[0]:
|
|
709
|
+
return
|
|
710
|
+
data[t] = np.asarray(frame, dtype=data.dtype)
|
|
711
|
+
if name in self._preview_needs_autocontrast:
|
|
712
|
+
finite = frame[np.isfinite(frame)]
|
|
713
|
+
if finite.size:
|
|
714
|
+
lo, hi = float(finite.min()), float(finite.max())
|
|
715
|
+
if hi > lo:
|
|
716
|
+
layer.contrast_limits = (lo, hi)
|
|
717
|
+
self._preview_needs_autocontrast.discard(name)
|
|
718
|
+
if hasattr(layer, "refresh"):
|
|
719
|
+
layer.refresh()
|
|
720
|
+
|
|
721
|
+
# ── Public API ───────────────────────────────────────────────────
|
|
722
|
+
def refresh(self, pos_dir: Path | str | None) -> None:
|
|
723
|
+
self._pos_dir = None if pos_dir is None or str(pos_dir) == "[no project]" else Path(pos_dir)
|
|
724
|
+
if self._files_widget is not None:
|
|
725
|
+
self._files_widget.refresh(self._pos_dir)
|
|
726
|
+
# A live preview points at the current project's maps; if the project
|
|
727
|
+
# goes away, deactivate it so we don't preview stale paths.
|
|
728
|
+
if self._pos_dir is None and self._active_preview_channel is not None:
|
|
729
|
+
channel = self._active_preview_channel
|
|
730
|
+
btn = self.nucleus_preview_btn if channel == "nucleus" else self.cell_preview_btn
|
|
731
|
+
btn.setChecked(False)
|
|
732
|
+
self._update_enabled()
|
|
733
|
+
|
|
734
|
+
def _update_enabled(self) -> None:
|
|
735
|
+
# Enablement is owned by the gate; its predicates read ``_pos_dir`` and
|
|
736
|
+
# ``_running_stage``.
|
|
737
|
+
self.gate.recompute()
|
|
738
|
+
|
|
739
|
+
def get_state(self) -> dict:
|
|
740
|
+
return {
|
|
741
|
+
"nucleus": self._channel_state("nuc"),
|
|
742
|
+
"cell": self._channel_state("cell"),
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
def set_state(self, state: dict) -> None:
|
|
746
|
+
if not isinstance(state, dict):
|
|
747
|
+
return
|
|
748
|
+
if "nucleus" in state:
|
|
749
|
+
self._apply_channel_state("nuc", state["nucleus"])
|
|
750
|
+
if "cell" in state:
|
|
751
|
+
self._apply_channel_state("cell", state["cell"])
|
|
752
|
+
|
|
753
|
+
def _channel_state(self, prefix: str) -> dict:
|
|
754
|
+
# ``smoothing_sigma`` / ``median_radius`` are the *contour* knobs (kept
|
|
755
|
+
# under their original keys for saved-config back-compat); the
|
|
756
|
+
# ``foreground_*`` keys are the separate foreground knobs.
|
|
757
|
+
return {
|
|
758
|
+
"foreground_z_reduction": getattr(self, f"{prefix}_fg_reduction").currentText(),
|
|
759
|
+
"contour_z_reduction": getattr(self, f"{prefix}_contour_reduction").currentText(),
|
|
760
|
+
"smoothing_sigma": float(getattr(self, f"{prefix}_smoothing_spin").value()),
|
|
761
|
+
"median_radius": int(getattr(self, f"{prefix}_median_spin").value()),
|
|
762
|
+
"foreground_smoothing_sigma": float(getattr(self, f"{prefix}_fg_smoothing_spin").value()),
|
|
763
|
+
"foreground_median_radius": int(getattr(self, f"{prefix}_fg_median_spin").value()),
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
def _apply_channel_state(self, prefix: str, state: dict) -> None:
|
|
767
|
+
if "foreground_z_reduction" in state:
|
|
768
|
+
getattr(self, f"{prefix}_fg_reduction").setCurrentText(state["foreground_z_reduction"])
|
|
769
|
+
if "contour_z_reduction" in state:
|
|
770
|
+
getattr(self, f"{prefix}_contour_reduction").setCurrentText(state["contour_z_reduction"])
|
|
771
|
+
if "smoothing_sigma" in state:
|
|
772
|
+
getattr(self, f"{prefix}_smoothing_spin").setValue(float(state["smoothing_sigma"]))
|
|
773
|
+
if "median_radius" in state:
|
|
774
|
+
getattr(self, f"{prefix}_median_spin").setValue(int(state["median_radius"]))
|
|
775
|
+
if "foreground_smoothing_sigma" in state:
|
|
776
|
+
getattr(self, f"{prefix}_fg_smoothing_spin").setValue(float(state["foreground_smoothing_sigma"]))
|
|
777
|
+
if "foreground_median_radius" in state:
|
|
778
|
+
getattr(self, f"{prefix}_fg_median_spin").setValue(int(state["foreground_median_radius"]))
|
|
779
|
+
|
|
780
|
+
# ── Path helpers ────────────────────────────────────────────────
|
|
781
|
+
def set_maps_dir(self, maps_dir: Path | str | None) -> None:
|
|
782
|
+
"""Standalone seam: resolve maps flatly under an explicit directory.
|
|
783
|
+
|
|
784
|
+
The orchestrator drives the widget through ``refresh(pos_dir)`` (maps
|
|
785
|
+
live under ``<pos_dir>/1_cellpose``); the standalone Cellpose piece
|
|
786
|
+
instead points it at the chosen output directory, where the Cellpose
|
|
787
|
+
prob/dp stacks and the derived foreground/contour maps live side by side.
|
|
788
|
+
"""
|
|
789
|
+
self._maps_dir = None if maps_dir is None else Path(maps_dir)
|
|
790
|
+
self.gate.recompute()
|
|
791
|
+
|
|
792
|
+
def _resolved_maps_dir(self) -> Path | None:
|
|
793
|
+
if self._maps_dir is not None:
|
|
794
|
+
return self._maps_dir
|
|
795
|
+
return self._pos_dir / "1_cellpose" if self._pos_dir is not None else None
|