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,1405 @@
|
|
|
1
|
+
"""Cell segmentation workflow widget — simplified divergence pipeline.
|
|
2
|
+
|
|
3
|
+
A single run path and a single preview path (no per-stage run buttons). The
|
|
4
|
+
widget consumes the cached divergence maps produced upstream by
|
|
5
|
+
``DivergenceMapsWidget`` (``1_cellpose/cell_contours.tif`` +
|
|
6
|
+
``1_cellpose/cell_foreground.tif``) plus the tracked nucleus seeds, and runs
|
|
7
|
+
the unary-only geodesic-Voronoi pipeline from
|
|
8
|
+
:func:`itasc.segmentation.segment_cells_divergence`:
|
|
9
|
+
|
|
10
|
+
1. Map cleanup (foreground + contours) — local-mean residual + threshold.
|
|
11
|
+
2. Temporal contour smoothing (full run only).
|
|
12
|
+
3. Foreground mask.
|
|
13
|
+
4. Unary-only segmentation → ``3_cell/tracked_labels.tif``.
|
|
14
|
+
|
|
15
|
+
Live preview recomputes the current frame off the GUI thread on any param edit
|
|
16
|
+
or time scrub. A row of *Compute* checkboxes — Foreground, Contours, Cost,
|
|
17
|
+
Labels — selects which layer groups the preview computes (not merely shows):
|
|
18
|
+
only the ticked groups are calculated, so leaving the slow geodesic *Labels* box
|
|
19
|
+
off (the default) keeps tuning responsive while the cheaper foreground / contour
|
|
20
|
+
/ cost intermediates still update. Each computed frame is cached in memory keyed
|
|
21
|
+
on the params, so scrubbing back to an already-computed frame repaints instantly;
|
|
22
|
+
any param edit clears the cache.
|
|
23
|
+
|
|
24
|
+
Temporal smoothing (``memory_tau > 0``) needs the whole movie, so the preview
|
|
25
|
+
computes the cleaned-and-smoothed contour stack once, caches it keyed on the
|
|
26
|
+
contour/temporal knobs, and slices the current frame from it for the cost field
|
|
27
|
+
and labels — reusing the cache across frame scrubs and edits to non-smoothing
|
|
28
|
+
knobs. The previewed frame then matches the full run exactly. The full run
|
|
29
|
+
processes all frames in memory (deriving the foreground fill mask in-process),
|
|
30
|
+
runs the geodesic walk, and persists only the labels. Correction is delegated to
|
|
31
|
+
:class:`CellCorrectionWidget`, unchanged.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import dataclasses
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import napari
|
|
41
|
+
import numpy as np
|
|
42
|
+
import tifffile
|
|
43
|
+
from napari.qt.threading import thread_worker
|
|
44
|
+
from qtpy.QtCore import Qt, QTimer, Signal
|
|
45
|
+
from qtpy.QtWidgets import (
|
|
46
|
+
QCheckBox,
|
|
47
|
+
QHBoxLayout,
|
|
48
|
+
QLabel,
|
|
49
|
+
QLineEdit,
|
|
50
|
+
QProgressBar,
|
|
51
|
+
QSizePolicy,
|
|
52
|
+
QVBoxLayout,
|
|
53
|
+
QWidget,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from itasc.correction.labels import best_overlapping_label
|
|
57
|
+
from itasc.napari._widget_helpers import (
|
|
58
|
+
dslider as _dslider,
|
|
59
|
+
heading as _heading,
|
|
60
|
+
islider as _islider,
|
|
61
|
+
tool_btn as _tool_btn,
|
|
62
|
+
)
|
|
63
|
+
from itasc.napari._standalone_paths import StandalonePathsMixin
|
|
64
|
+
from itasc.napari.correction.cell_correction_widget import CellCorrectionWidget
|
|
65
|
+
from itasc.napari.ui_gate import ControlClass, UiGate
|
|
66
|
+
from itasc.napari.ui_style import (
|
|
67
|
+
add_section_header,
|
|
68
|
+
add_section_pair_row,
|
|
69
|
+
section_grid,
|
|
70
|
+
stage_header_action_button,
|
|
71
|
+
stage_header_label,
|
|
72
|
+
status_label,
|
|
73
|
+
)
|
|
74
|
+
from itasc.napari.widgets import (
|
|
75
|
+
CollapsibleSection,
|
|
76
|
+
PipelineFilesWidget,
|
|
77
|
+
make_pipeline_files_header,
|
|
78
|
+
)
|
|
79
|
+
from itasc.segmentation import CancelledError
|
|
80
|
+
from itasc.segmentation.cell_divergence_segmentation import (
|
|
81
|
+
CellDivergenceParams,
|
|
82
|
+
clean_and_smooth_contours,
|
|
83
|
+
compute_cell_foreground,
|
|
84
|
+
segment_cells_divergence,
|
|
85
|
+
)
|
|
86
|
+
from itasc.segmentation.cell_label_icm import commit_labels
|
|
87
|
+
from itasc.core.commit import commit_state, promote_labels
|
|
88
|
+
from itasc.napari._paths import NucleusArtifactPaths
|
|
89
|
+
|
|
90
|
+
logger = logging.getLogger(__name__)
|
|
91
|
+
|
|
92
|
+
# ── Preview layer names (pipeline order) ──────────────────────────────────────
|
|
93
|
+
_PREFIX = "[Cell]"
|
|
94
|
+
_FG_RAW_LAYER = f"{_PREFIX} foreground (sigmoid)"
|
|
95
|
+
_FG_CLEAN_LAYER = f"{_PREFIX} foreground cleaned"
|
|
96
|
+
_FG_MASK_LAYER = f"{_PREFIX} foreground mask"
|
|
97
|
+
_CT_RAW_LAYER = f"{_PREFIX} contours raw"
|
|
98
|
+
_CT_CLEAN_LAYER = f"{_PREFIX} contours cleaned"
|
|
99
|
+
_COST_LAYER = f"{_PREFIX} weighted cost field"
|
|
100
|
+
_LABELS_LAYER = f"{_PREFIX} cell labels"
|
|
101
|
+
|
|
102
|
+
# Tracked pipeline layer the correction widget syncs against.
|
|
103
|
+
_TRACKED_CELL_LAYER = "Tracked: Cell"
|
|
104
|
+
|
|
105
|
+
# ── Compute groups ────────────────────────────────────────────────────────────
|
|
106
|
+
# Each checkbox owns one group of preview layers and gates whether that group is
|
|
107
|
+
# *computed at all* (not merely shown). The live preview computes only the
|
|
108
|
+
# groups whose box is ticked and caches the per-frame result so scrubbing back
|
|
109
|
+
# to a frame repaints instantly.
|
|
110
|
+
_FG_GROUP_LAYERS = (_FG_RAW_LAYER, _FG_CLEAN_LAYER, _FG_MASK_LAYER)
|
|
111
|
+
_CONTOUR_GROUP_LAYERS = (_CT_RAW_LAYER, _CT_CLEAN_LAYER)
|
|
112
|
+
_COST_GROUP_LAYERS = (_COST_LAYER,)
|
|
113
|
+
_LABELS_GROUP_LAYERS = (_LABELS_LAYER,)
|
|
114
|
+
_ALL_PREVIEW_LAYERS = (
|
|
115
|
+
_FG_GROUP_LAYERS + _CONTOUR_GROUP_LAYERS
|
|
116
|
+
+ _COST_GROUP_LAYERS + _LABELS_GROUP_LAYERS
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Colormap per image layer; the rest are napari Labels layers.
|
|
120
|
+
_IMAGE_COLORMAPS = {
|
|
121
|
+
_FG_RAW_LAYER: "gray",
|
|
122
|
+
_FG_CLEAN_LAYER: "gray",
|
|
123
|
+
_CT_RAW_LAYER: "magma",
|
|
124
|
+
_CT_CLEAN_LAYER: "magma",
|
|
125
|
+
_COST_LAYER: "turbo",
|
|
126
|
+
}
|
|
127
|
+
_LABELS_KIND = {_FG_MASK_LAYER, _LABELS_LAYER}
|
|
128
|
+
|
|
129
|
+
# How much a single-frame compute produces, in increasing cost. The desired
|
|
130
|
+
# level is the max over the ticked checkboxes; a cached frame is reused only when
|
|
131
|
+
# its stored level already covers the desired one.
|
|
132
|
+
_LEVEL_NONE = 0
|
|
133
|
+
_LEVEL_FG = 1 # foreground raw/clean/mask — compute_cell_foreground (cheap)
|
|
134
|
+
_LEVEL_SEG = 2 # + contours raw/clean + cost field (with_labels=False)
|
|
135
|
+
_LEVEL_LABELS = 3 # + the geodesic cell labels (with_labels=True, slow)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _make_status() -> QLabel:
|
|
139
|
+
lbl = QLabel("")
|
|
140
|
+
lbl.setWordWrap(True)
|
|
141
|
+
lbl.setVisible(False)
|
|
142
|
+
status_label(lbl)
|
|
143
|
+
return lbl
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _make_progress() -> QProgressBar:
|
|
147
|
+
bar = QProgressBar()
|
|
148
|
+
bar.setRange(0, 100)
|
|
149
|
+
bar.setValue(0)
|
|
150
|
+
bar.setTextVisible(True)
|
|
151
|
+
bar.setVisible(False)
|
|
152
|
+
return bar
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Canonical input/output paths (kept as literals so the file-contract test and
|
|
156
|
+
# the PipelineFilesWidget stay in sync):
|
|
157
|
+
# 1_cellpose/cell_contours.tif, 1_cellpose/cell_foreground.tif,
|
|
158
|
+
# nucleus_labels.tif (committed) → 3_cell/tracked_labels.tif
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CellWorkflowWidget(StandalonePathsMixin, QWidget):
|
|
162
|
+
"""Simplified divergence-based cell segmentation widget."""
|
|
163
|
+
|
|
164
|
+
_run_progress = Signal(str)
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
viewer: napari.Viewer,
|
|
169
|
+
parent: QWidget | None = None,
|
|
170
|
+
gate: UiGate | None = None,
|
|
171
|
+
standalone: bool = False,
|
|
172
|
+
) -> None:
|
|
173
|
+
super().__init__(parent)
|
|
174
|
+
self.viewer = viewer
|
|
175
|
+
#: App-wide UI gate; a private one is created for standalone use.
|
|
176
|
+
self.gate = gate if gate is not None else UiGate(self)
|
|
177
|
+
#: When True the piece runs on its own, with its own input/output path
|
|
178
|
+
#: pickers; the orchestrator drives the embedded widget via refresh().
|
|
179
|
+
self._standalone = standalone
|
|
180
|
+
self._pos_dir: Path | None = None
|
|
181
|
+
#: Standalone explicit inputs/output (mirrors the orchestrator's staged
|
|
182
|
+
#: 1_cellpose/2_nucleus/3_cell layout with arbitrary file locations).
|
|
183
|
+
self._sa_foreground: Path | None = None
|
|
184
|
+
self._sa_contours: Path | None = None
|
|
185
|
+
self._sa_nucleus: Path | None = None
|
|
186
|
+
self._sa_output_dir: Path | None = None
|
|
187
|
+
|
|
188
|
+
# Live preview state — a compute is in flight (None when idle); rapid
|
|
189
|
+
# edits while one runs set _preview_pending so exactly one fresh pass
|
|
190
|
+
# fires when it returns.
|
|
191
|
+
self._preview_active = False
|
|
192
|
+
self._preview_worker = None
|
|
193
|
+
self._preview_pending = False
|
|
194
|
+
# Image preview layers whose contrast has not yet been auto-set. We seed
|
|
195
|
+
# contrast once, from the first real frame a freshly created layer
|
|
196
|
+
# receives, then leave it alone so a scrub to another frame (or a manual
|
|
197
|
+
# contrast tweak) is not clobbered on every refresh.
|
|
198
|
+
self._image_needs_autocontrast: set[str] = set()
|
|
199
|
+
self._preview_timer = QTimer(self)
|
|
200
|
+
self._preview_timer.setSingleShot(True)
|
|
201
|
+
self._preview_timer.setInterval(150)
|
|
202
|
+
self._preview_timer.timeout.connect(self._refresh_preview)
|
|
203
|
+
|
|
204
|
+
# Per-frame result cache: frame t → (level, result). Holds whatever the
|
|
205
|
+
# last compute produced for each visited frame so scrubbing back to an
|
|
206
|
+
# already-computed frame repaints instantly. Keyed (``_frame_cache_key``)
|
|
207
|
+
# on the full param tuple — any param edit clears it.
|
|
208
|
+
self._frame_cache: dict[int, tuple[int, object]] = {}
|
|
209
|
+
self._frame_cache_key: tuple | None = None
|
|
210
|
+
|
|
211
|
+
# Cached cleaned+smoothed contour stack for the preview when
|
|
212
|
+
# ``memory_tau > 0`` (the smoothing needs the whole movie). Keyed on the
|
|
213
|
+
# contour/temporal knobs so it survives frame scrubs and edits to the
|
|
214
|
+
# non-smoothing knobs (fg_*, balance, feature_strength) and only
|
|
215
|
+
# recomputes when a
|
|
216
|
+
# knob that actually changes the smoothing is touched. Dropped on
|
|
217
|
+
# deactivate to free the (T, Y, X) array.
|
|
218
|
+
self._smoothed_stack = None
|
|
219
|
+
self._smoothed_key = None
|
|
220
|
+
|
|
221
|
+
# Full-run worker state.
|
|
222
|
+
self._run_worker = None
|
|
223
|
+
self._running = False
|
|
224
|
+
|
|
225
|
+
self._setup_ui()
|
|
226
|
+
self._connect_signals()
|
|
227
|
+
self._register_gate_controls()
|
|
228
|
+
self._run_progress.connect(self._set_status)
|
|
229
|
+
|
|
230
|
+
if self._standalone:
|
|
231
|
+
self._load_standalone_settings()
|
|
232
|
+
|
|
233
|
+
# ================================================================
|
|
234
|
+
# UI
|
|
235
|
+
# ================================================================
|
|
236
|
+
def _setup_ui(self) -> None:
|
|
237
|
+
root = QVBoxLayout(self)
|
|
238
|
+
root.setContentsMargins(2, 2, 2, 2)
|
|
239
|
+
root.setSpacing(6)
|
|
240
|
+
# Embedded, the widget lives in main_widget's AlignTop scroll layout and
|
|
241
|
+
# a Maximum policy keeps each section compact. Standalone, napari docks it
|
|
242
|
+
# directly, so fill the dock (Preferred) and pin content to the top with a
|
|
243
|
+
# trailing stretch (mirrors NucleusWorkflowWidget).
|
|
244
|
+
if self._standalone:
|
|
245
|
+
root.setAlignment(Qt.AlignTop)
|
|
246
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
|
247
|
+
else:
|
|
248
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
|
249
|
+
|
|
250
|
+
# ── Standalone input/output path pickers ──────────────────────
|
|
251
|
+
# Only shown when the piece runs on its own; the orchestrator drives the
|
|
252
|
+
# workspace through refresh() instead. The cell segmentation consumes the
|
|
253
|
+
# Cellpose-produced foreground/contours plus the tracked nucleus seeds.
|
|
254
|
+
self._paths_container = QWidget()
|
|
255
|
+
paths_col = QVBoxLayout(self._paths_container)
|
|
256
|
+
paths_col.setContentsMargins(0, 0, 0, 0)
|
|
257
|
+
paths_col.setSpacing(2)
|
|
258
|
+
apply = self._apply_standalone_paths
|
|
259
|
+
self._foreground_edit = self._add_path_row(
|
|
260
|
+
paths_col, "Foreground:", "Cell foreground .tif",
|
|
261
|
+
lambda: self._browse_file_into(self._foreground_edit, "Select cell foreground image", apply),
|
|
262
|
+
apply,
|
|
263
|
+
)
|
|
264
|
+
self._contours_edit = self._add_path_row(
|
|
265
|
+
paths_col, "Contours:", "Cell contours .tif",
|
|
266
|
+
lambda: self._browse_file_into(self._contours_edit, "Select cell contours image", apply),
|
|
267
|
+
apply,
|
|
268
|
+
)
|
|
269
|
+
self._nucleus_edit = self._add_path_row(
|
|
270
|
+
paths_col, "Nucleus:", "Tracked nucleus labels .tif",
|
|
271
|
+
lambda: self._browse_file_into(self._nucleus_edit, "Select tracked nucleus labels", apply),
|
|
272
|
+
apply,
|
|
273
|
+
)
|
|
274
|
+
self._output_dir_edit = self._add_path_row(
|
|
275
|
+
paths_col, "Output dir:", "Folder for 3_cell/tracked_labels.tif",
|
|
276
|
+
lambda: self._browse_dir_into(self._output_dir_edit, "Select output directory", apply),
|
|
277
|
+
apply,
|
|
278
|
+
)
|
|
279
|
+
root.addWidget(self._paths_container)
|
|
280
|
+
self._paths_container.setVisible(self._standalone)
|
|
281
|
+
|
|
282
|
+
# ── Pipeline files ────────────────────────────────────────────
|
|
283
|
+
self._files_widget = PipelineFilesWidget(
|
|
284
|
+
[
|
|
285
|
+
("Inputs", [
|
|
286
|
+
("1_cellpose/cell_contours.tif", "Cell contours (divergence)"),
|
|
287
|
+
("1_cellpose/cell_foreground.tif", "Cell foreground (sigmoid)"),
|
|
288
|
+
("nucleus_labels.tif", "Nucleus labels (committed)"),
|
|
289
|
+
]),
|
|
290
|
+
("Output", [
|
|
291
|
+
("3_cell/tracked_labels.tif", "Cell tracked labels"),
|
|
292
|
+
]),
|
|
293
|
+
],
|
|
294
|
+
viewer=self.viewer,
|
|
295
|
+
)
|
|
296
|
+
self._pipeline_files_section = CollapsibleSection(
|
|
297
|
+
"Pipeline Files", self._files_widget, expanded=False,
|
|
298
|
+
)
|
|
299
|
+
(
|
|
300
|
+
self.pipeline_files_header,
|
|
301
|
+
self.pipeline_files_header_lbl,
|
|
302
|
+
self.pipeline_files_toggle_btn,
|
|
303
|
+
) = make_pipeline_files_header(
|
|
304
|
+
self._pipeline_files_section, stage_key="cell", parent=self,
|
|
305
|
+
)
|
|
306
|
+
root.addWidget(self.pipeline_files_header)
|
|
307
|
+
root.addWidget(self._pipeline_files_section)
|
|
308
|
+
# The staged-files panel lists 1_cellpose/2_nucleus/3_cell paths that
|
|
309
|
+
# don't exist in the standalone flat layout; the path pickers cover it.
|
|
310
|
+
self.pipeline_files_header.setVisible(not self._standalone)
|
|
311
|
+
self._pipeline_files_section.setVisible(not self._standalone)
|
|
312
|
+
|
|
313
|
+
# ── Segmentation stage row: ⚙ params / ◉ live preview / ▶ run ──
|
|
314
|
+
self.params_btn = _tool_btn(
|
|
315
|
+
"⚙", "Toggle segmentation parameters.", checkable=True
|
|
316
|
+
)
|
|
317
|
+
self.active_btn = _tool_btn(
|
|
318
|
+
"◉", "Live preview (tune against the current frame).", checkable=True
|
|
319
|
+
)
|
|
320
|
+
self.run_btn = _tool_btn(
|
|
321
|
+
"▶", "Run the full pipeline over all frames and write tracked_labels.tif."
|
|
322
|
+
)
|
|
323
|
+
# Two Finalize buttons promoting the working tracked labels to
|
|
324
|
+
# cell_labels.tif: one at the tail of the correction toolbar (visible in
|
|
325
|
+
# active correction mode) and one beside the correction on/off toggle
|
|
326
|
+
# (always visible). Both are placed by the correction widget below; the
|
|
327
|
+
# handler stays here. Finalize is not a segmentation-stage action, so it
|
|
328
|
+
# is out of the stage row and skips the stage-pill styling loop (the
|
|
329
|
+
# header button is styled by the header builder, the toolbar one plain).
|
|
330
|
+
_finalize_tip = (
|
|
331
|
+
"Finalize: promote tracked labels to cell_labels.tif "
|
|
332
|
+
"(final, downstream-stable output)."
|
|
333
|
+
)
|
|
334
|
+
self.finalize_btn = _tool_btn("✔", _finalize_tip)
|
|
335
|
+
self.finalize_header_btn = _tool_btn("✔", _finalize_tip)
|
|
336
|
+
for button in (self.finalize_btn, self.finalize_header_btn):
|
|
337
|
+
button.clicked.connect(self._on_finalize)
|
|
338
|
+
for button in (self.params_btn, self.active_btn, self.run_btn):
|
|
339
|
+
stage_header_action_button(button, "cell")
|
|
340
|
+
|
|
341
|
+
self.params_section = self._build_params_section()
|
|
342
|
+
self.params_section.set_header_visible(False)
|
|
343
|
+
self.params_section.collapse()
|
|
344
|
+
self.params_btn.toggled.connect(
|
|
345
|
+
lambda checked: self.params_section._toggle.setChecked(checked)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
root.addLayout(self._stage_row(
|
|
349
|
+
self._stage_label("Segmentation"),
|
|
350
|
+
self.params_btn, self.active_btn, self.run_btn,
|
|
351
|
+
))
|
|
352
|
+
root.addWidget(self.params_section)
|
|
353
|
+
|
|
354
|
+
self.pipeline_status_lbl = _make_status()
|
|
355
|
+
root.addWidget(self.pipeline_status_lbl)
|
|
356
|
+
self.pipeline_progress_bar = _make_progress()
|
|
357
|
+
root.addWidget(self.pipeline_progress_bar)
|
|
358
|
+
|
|
359
|
+
# ── Correction (delegated, unchanged) ─────────────────────────
|
|
360
|
+
self.cell_correction_widget = CellCorrectionWidget(
|
|
361
|
+
self.viewer,
|
|
362
|
+
pos_dir_provider=lambda: self._pos_dir,
|
|
363
|
+
files_widget_refresh_callback=lambda pd: self._files_widget.refresh(pd),
|
|
364
|
+
finalize_btn=self.finalize_btn,
|
|
365
|
+
finalize_header_btn=self.finalize_header_btn,
|
|
366
|
+
)
|
|
367
|
+
self._install_correction_aliases()
|
|
368
|
+
self.cell_correction_widget.hide()
|
|
369
|
+
root.addWidget(self.correction_header)
|
|
370
|
+
root.addWidget(self.correction_mode_section)
|
|
371
|
+
|
|
372
|
+
def _build_compute_row(self) -> QWidget:
|
|
373
|
+
"""The "Compute:" checkbox row — one box per preview layer group.
|
|
374
|
+
|
|
375
|
+
Each box gates whether its group is *computed* by the live preview (not
|
|
376
|
+
just shown). ``_compute_groups`` ties each box to its layers and the
|
|
377
|
+
single-frame compute level it requires; Labels (the slow geodesic walk)
|
|
378
|
+
is off by default.
|
|
379
|
+
"""
|
|
380
|
+
self.fg_check = QCheckBox("Foreground")
|
|
381
|
+
self.fg_check.setChecked(True)
|
|
382
|
+
self.fg_check.setToolTip(
|
|
383
|
+
"Compute + show the foreground layers (raw, cleaned, fill mask)."
|
|
384
|
+
)
|
|
385
|
+
self.contour_check = QCheckBox("Contours")
|
|
386
|
+
self.contour_check.setChecked(True)
|
|
387
|
+
self.contour_check.setToolTip(
|
|
388
|
+
"Compute + show the contour layers (raw, cleaned)."
|
|
389
|
+
)
|
|
390
|
+
self.cost_check = QCheckBox("Cost")
|
|
391
|
+
self.cost_check.setChecked(True)
|
|
392
|
+
self.cost_check.setToolTip("Compute + show the weighted cost field.")
|
|
393
|
+
self.labels_check = QCheckBox("Labels")
|
|
394
|
+
self.labels_check.setChecked(False)
|
|
395
|
+
self.labels_check.setToolTip(
|
|
396
|
+
"Compute the cell labels (the slow geodesic step) for the current "
|
|
397
|
+
"frame."
|
|
398
|
+
)
|
|
399
|
+
# (checkbox, owned layers, single-frame compute level it requires).
|
|
400
|
+
self._compute_groups = (
|
|
401
|
+
(self.fg_check, _FG_GROUP_LAYERS, _LEVEL_FG),
|
|
402
|
+
(self.contour_check, _CONTOUR_GROUP_LAYERS, _LEVEL_SEG),
|
|
403
|
+
(self.cost_check, _COST_GROUP_LAYERS, _LEVEL_SEG),
|
|
404
|
+
(self.labels_check, _LABELS_GROUP_LAYERS, _LEVEL_LABELS),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
row = QWidget()
|
|
408
|
+
lay = QHBoxLayout(row)
|
|
409
|
+
lay.setContentsMargins(8, 0, 4, 0)
|
|
410
|
+
lay.setSpacing(8)
|
|
411
|
+
lay.addWidget(QLabel("Compute:"))
|
|
412
|
+
for check, _layers, _level in self._compute_groups:
|
|
413
|
+
lay.addWidget(check)
|
|
414
|
+
lay.addStretch(1)
|
|
415
|
+
return row
|
|
416
|
+
|
|
417
|
+
def _build_params_section(self) -> CollapsibleSection:
|
|
418
|
+
"""One flat panel: the Compute checkboxes on top, then every knob.
|
|
419
|
+
|
|
420
|
+
The Compute row gates which layer groups the live preview computes; the
|
|
421
|
+
grid below holds the cleanup / smoothing / segmentation knobs.
|
|
422
|
+
"""
|
|
423
|
+
body = QWidget(self)
|
|
424
|
+
outer = QVBoxLayout(body)
|
|
425
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
426
|
+
outer.setSpacing(4)
|
|
427
|
+
outer.addWidget(self._build_compute_row())
|
|
428
|
+
|
|
429
|
+
grid_widget = QWidget()
|
|
430
|
+
grid = section_grid()
|
|
431
|
+
grid.setContentsMargins(8, 4, 4, 4)
|
|
432
|
+
grid_widget.setLayout(grid)
|
|
433
|
+
outer.addWidget(grid_widget)
|
|
434
|
+
|
|
435
|
+
max_workers = max(1, os.cpu_count() or 1)
|
|
436
|
+
|
|
437
|
+
# ── Map cleanup: foreground ──────────────────────────────────
|
|
438
|
+
self.fg_strength_spin = _dslider(
|
|
439
|
+
0, 1, 0.0, 0.05, 2,
|
|
440
|
+
"0 = raw sigmoid, 1 = full local-mean background subtraction.",
|
|
441
|
+
)
|
|
442
|
+
self.fg_threshold_spin = _dslider(
|
|
443
|
+
0, 1, 0.1, 0.001, 3,
|
|
444
|
+
"Cleaned-foreground cutoff → fill mask (sigmoid scale).",
|
|
445
|
+
)
|
|
446
|
+
self.fg_window_spin = _islider(
|
|
447
|
+
3, 201, 51, tooltip="Local-mean window for foreground residual (odd px)."
|
|
448
|
+
)
|
|
449
|
+
# ── Map cleanup: contours ────────────────────────────────────
|
|
450
|
+
self.contour_strength_spin = _dslider(
|
|
451
|
+
0, 1, 1.0, 0.05, 2, "0 = raw, 1 = full local-mean subtraction."
|
|
452
|
+
)
|
|
453
|
+
self.contour_threshold_spin = _dslider(
|
|
454
|
+
0, 1, 0.0, 0.001, 3, "Noise floor on normalized contour; below → 0."
|
|
455
|
+
)
|
|
456
|
+
self.contour_norm_pct_spin = _dslider(
|
|
457
|
+
90, 100, 99.0, 0.5, 1,
|
|
458
|
+
"Percentile mapped to 1.0 in the contour [0,1] normalize.",
|
|
459
|
+
)
|
|
460
|
+
self.contour_window_spin = _islider(
|
|
461
|
+
3, 201, 51, tooltip="Local-mean window for contour residual (odd px)."
|
|
462
|
+
)
|
|
463
|
+
# ── Temporal smoothing ───────────────────────────────────────
|
|
464
|
+
self.memory_tau_spin = _dslider(
|
|
465
|
+
0, 1, 0.0, 0.01, 3,
|
|
466
|
+
"Temporal EMA crossover (~the contour value you call weak). 0 = off.",
|
|
467
|
+
)
|
|
468
|
+
self.memory_floor_spin = _dslider(
|
|
469
|
+
0.001, 0.5, 0.01, 0.001, 3,
|
|
470
|
+
"Min per-frame alpha; ghost half-life (~69 frames @ 0.01).",
|
|
471
|
+
)
|
|
472
|
+
# ── Segmentation ─────────────────────────────────────────────
|
|
473
|
+
self.balance_spin = _dslider(
|
|
474
|
+
0.0, 1.0, 0.98, 0.01, 2,
|
|
475
|
+
"Contour↔foreground split r: 1 = pure contour, 0 = pure "
|
|
476
|
+
"foreground. cost = 1 + s·[r·contour + (1−r)·(1−fg)].",
|
|
477
|
+
)
|
|
478
|
+
self.feature_strength_spin = _dslider(
|
|
479
|
+
0, 1000, 100.0, 1.0, 1,
|
|
480
|
+
"Feature strength s: how hard contour/foreground bend the walk vs "
|
|
481
|
+
"a plain distance Voronoi. 0 = pure distance.",
|
|
482
|
+
)
|
|
483
|
+
self.n_workers_spin = _islider(
|
|
484
|
+
1, max_workers, min(4, max_workers),
|
|
485
|
+
tooltip="Parallel workers for geodesic computation (compute only).",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
row = 0
|
|
489
|
+
add_section_header(grid, row, _heading("Map cleanup")); row += 1
|
|
490
|
+
add_section_header(grid, row, _heading("Foreground")); row += 1
|
|
491
|
+
add_section_pair_row(
|
|
492
|
+
grid, row,
|
|
493
|
+
"Strength:", self.fg_strength_spin,
|
|
494
|
+
"Threshold:", self.fg_threshold_spin,
|
|
495
|
+
); row += 1
|
|
496
|
+
add_section_pair_row(grid, row, "Window:", self.fg_window_spin); row += 1
|
|
497
|
+
add_section_header(grid, row, _heading("Contours")); row += 1
|
|
498
|
+
add_section_pair_row(
|
|
499
|
+
grid, row,
|
|
500
|
+
"Strength:", self.contour_strength_spin,
|
|
501
|
+
"Floor:", self.contour_threshold_spin,
|
|
502
|
+
); row += 1
|
|
503
|
+
add_section_pair_row(
|
|
504
|
+
grid, row,
|
|
505
|
+
"Norm %:", self.contour_norm_pct_spin,
|
|
506
|
+
"Window:", self.contour_window_spin,
|
|
507
|
+
); row += 1
|
|
508
|
+
add_section_header(grid, row, _heading("Temporal smoothing")); row += 1
|
|
509
|
+
add_section_pair_row(
|
|
510
|
+
grid, row,
|
|
511
|
+
"Memory τ:", self.memory_tau_spin,
|
|
512
|
+
"Memory floor:", self.memory_floor_spin,
|
|
513
|
+
); row += 1
|
|
514
|
+
add_section_header(grid, row, _heading("Segmentation")); row += 1
|
|
515
|
+
add_section_pair_row(
|
|
516
|
+
grid, row,
|
|
517
|
+
"Balance (r):", self.balance_spin,
|
|
518
|
+
"Strength (s):", self.feature_strength_spin,
|
|
519
|
+
); row += 1
|
|
520
|
+
add_section_pair_row(grid, row, "Workers:", self.n_workers_spin); row += 1
|
|
521
|
+
|
|
522
|
+
return CollapsibleSection("Segmentation Parameters", body, expanded=False)
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _stage_label(text: str) -> QLabel:
|
|
526
|
+
return stage_header_label(QLabel(text), "cell")
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
|
|
530
|
+
row = QHBoxLayout()
|
|
531
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
532
|
+
row.setSpacing(4)
|
|
533
|
+
row.addWidget(label)
|
|
534
|
+
for widget in trailing:
|
|
535
|
+
row.addWidget(widget)
|
|
536
|
+
row.addStretch(1)
|
|
537
|
+
return row
|
|
538
|
+
|
|
539
|
+
def _install_correction_aliases(self) -> None:
|
|
540
|
+
"""Install compatibility aliases for controls owned by CellCorrectionWidget."""
|
|
541
|
+
c = self.cell_correction_widget
|
|
542
|
+
self.correction_header = c.header
|
|
543
|
+
self.correction_header_lbl = c.header_lbl
|
|
544
|
+
self.correction_shortcuts_btn = c.shortcuts_btn
|
|
545
|
+
self.correction_params_btn = c.params_btn
|
|
546
|
+
self.correction_active_btn = c.active_btn
|
|
547
|
+
self.correction_mode_section = c.section
|
|
548
|
+
self.correction_widget = c.correction_widget
|
|
549
|
+
self.correction_status_lbl = c.correction_status_lbl
|
|
550
|
+
self.correction_shortcuts_section = c.correction_shortcuts_section
|
|
551
|
+
self.save_labels_btn = c.save_labels_btn
|
|
552
|
+
self.fill_holes_btn = c.fill_holes_btn
|
|
553
|
+
self.cleanup_btn = c.cleanup_btn
|
|
554
|
+
self.correction_scope_combo = c.correction_scope_combo
|
|
555
|
+
self.hole_radius_spin = c.hole_radius_spin
|
|
556
|
+
|
|
557
|
+
# ================================================================
|
|
558
|
+
# Signals
|
|
559
|
+
# ================================================================
|
|
560
|
+
def _connect_signals(self) -> None:
|
|
561
|
+
for spin in (
|
|
562
|
+
self.fg_strength_spin, self.fg_threshold_spin, self.memory_tau_spin,
|
|
563
|
+
self.balance_spin, self.feature_strength_spin, self.fg_window_spin,
|
|
564
|
+
self.contour_window_spin, self.contour_strength_spin,
|
|
565
|
+
self.contour_threshold_spin, self.contour_norm_pct_spin,
|
|
566
|
+
self.memory_floor_spin, self.n_workers_spin,
|
|
567
|
+
):
|
|
568
|
+
spin.valueChanged.connect(self._on_param_changed)
|
|
569
|
+
self.active_btn.toggled.connect(self._on_activate)
|
|
570
|
+
self.run_btn.clicked.connect(self._on_run_clicked)
|
|
571
|
+
for check, layers, _level in self._compute_groups:
|
|
572
|
+
check.toggled.connect(
|
|
573
|
+
lambda checked, lyrs=layers: self._on_compute_toggled(lyrs, checked)
|
|
574
|
+
)
|
|
575
|
+
if hasattr(self.viewer, "dims") and hasattr(self.viewer.dims, "events"):
|
|
576
|
+
try:
|
|
577
|
+
self.viewer.dims.events.current_step.connect(self._on_time_changed)
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
|
|
581
|
+
def _register_gate_controls(self) -> None:
|
|
582
|
+
"""Register this section's controls with the app-wide UI gate.
|
|
583
|
+
|
|
584
|
+
Live preview and cell correction are mutually-exclusive viewer owners
|
|
585
|
+
(across sections too, since the gate is shared). The full-run button
|
|
586
|
+
rebuilds the data those owners view, so it is blocked while either is
|
|
587
|
+
active.
|
|
588
|
+
"""
|
|
589
|
+
g = self.gate
|
|
590
|
+
g.register_owner(
|
|
591
|
+
"cell_preview",
|
|
592
|
+
"cell live preview",
|
|
593
|
+
exit_fn=lambda: self.active_btn.setChecked(False),
|
|
594
|
+
)
|
|
595
|
+
g.register_owner(
|
|
596
|
+
"correction:cell",
|
|
597
|
+
"cell correction mode",
|
|
598
|
+
exit_fn=lambda: self.correction_active_btn.setChecked(False),
|
|
599
|
+
)
|
|
600
|
+
g.register(
|
|
601
|
+
self.active_btn,
|
|
602
|
+
ControlClass.VIEWER_OWNER,
|
|
603
|
+
owner_token="cell_preview",
|
|
604
|
+
when=lambda: not self._running,
|
|
605
|
+
)
|
|
606
|
+
g.register(
|
|
607
|
+
self.correction_active_btn,
|
|
608
|
+
ControlClass.VIEWER_OWNER,
|
|
609
|
+
owner_token="correction:cell",
|
|
610
|
+
when=lambda: not self._running,
|
|
611
|
+
)
|
|
612
|
+
g.register(self.run_btn, ControlClass.RUN_VIEWER)
|
|
613
|
+
self.correction_active_btn.toggled.connect(self._on_cell_correction_gate)
|
|
614
|
+
g.recompute()
|
|
615
|
+
|
|
616
|
+
def _on_cell_correction_gate(self, checked: bool) -> None:
|
|
617
|
+
if checked:
|
|
618
|
+
self.gate.claim_viewer("correction:cell")
|
|
619
|
+
else:
|
|
620
|
+
self.gate.release_viewer("correction:cell")
|
|
621
|
+
|
|
622
|
+
# ================================================================
|
|
623
|
+
# Params / paths
|
|
624
|
+
# ================================================================
|
|
625
|
+
def _params(self) -> CellDivergenceParams:
|
|
626
|
+
return CellDivergenceParams(
|
|
627
|
+
fg_window=int(self.fg_window_spin.value()),
|
|
628
|
+
fg_strength=float(self.fg_strength_spin.value()),
|
|
629
|
+
fg_threshold=float(self.fg_threshold_spin.value()),
|
|
630
|
+
contour_window=int(self.contour_window_spin.value()),
|
|
631
|
+
contour_strength=float(self.contour_strength_spin.value()),
|
|
632
|
+
contour_threshold=float(self.contour_threshold_spin.value()),
|
|
633
|
+
contour_norm_pct=float(self.contour_norm_pct_spin.value()),
|
|
634
|
+
memory_tau=float(self.memory_tau_spin.value()),
|
|
635
|
+
memory_floor=float(self.memory_floor_spin.value()),
|
|
636
|
+
balance=float(self.balance_spin.value()),
|
|
637
|
+
feature_strength=float(self.feature_strength_spin.value()),
|
|
638
|
+
n_workers=int(self.n_workers_spin.value()),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def _p(self, *parts: str) -> Path | None:
|
|
642
|
+
return self._pos_dir.joinpath(*parts) if self._pos_dir else None
|
|
643
|
+
|
|
644
|
+
def _contours_path(self):
|
|
645
|
+
return self._sa_contours if self._standalone else self._p("1_cellpose", "cell_contours.tif")
|
|
646
|
+
|
|
647
|
+
def _foreground_path(self):
|
|
648
|
+
return self._sa_foreground if self._standalone else self._p("1_cellpose", "cell_foreground.tif")
|
|
649
|
+
|
|
650
|
+
def _nuc_path(self):
|
|
651
|
+
# Staged: consume the *committed* nucleus labels in the position base
|
|
652
|
+
# folder, not the pre-commit 2_nucleus working labels (the commit gate
|
|
653
|
+
# means cell segmentation runs off the validated nucleus tracking).
|
|
654
|
+
return self._sa_nucleus if self._standalone else self._p("nucleus_labels.tif")
|
|
655
|
+
|
|
656
|
+
def _nuc_missing_msg(self) -> str:
|
|
657
|
+
if self._standalone:
|
|
658
|
+
return "Nucleus labels not found."
|
|
659
|
+
# Staged use reads the committed base-folder labels, so the fix is to
|
|
660
|
+
# commit the nucleus stage rather than just re-run its tracking.
|
|
661
|
+
return "Committed nucleus_labels.tif not found — commit the nucleus stage first."
|
|
662
|
+
|
|
663
|
+
def _output_path(self):
|
|
664
|
+
if self._standalone:
|
|
665
|
+
return self._sa_output_dir / "3_cell" / "tracked_labels.tif" if self._sa_output_dir else None
|
|
666
|
+
return self._p("3_cell", "tracked_labels.tif")
|
|
667
|
+
|
|
668
|
+
def _maps_present(self) -> bool:
|
|
669
|
+
ct, fg = self._contours_path(), self._foreground_path()
|
|
670
|
+
return ct is not None and ct.exists() and fg is not None and fg.exists()
|
|
671
|
+
|
|
672
|
+
# ================================================================
|
|
673
|
+
# Public API (consumed by the main widget)
|
|
674
|
+
# ================================================================
|
|
675
|
+
def refresh(self, pos_dir: Path | None) -> None:
|
|
676
|
+
self._pos_dir = pos_dir
|
|
677
|
+
self._files_widget.refresh(pos_dir)
|
|
678
|
+
if pos_dir is None:
|
|
679
|
+
self.correction_widget.deactivate()
|
|
680
|
+
self._refresh_finalize_btn()
|
|
681
|
+
|
|
682
|
+
# ── Commit contract: promote working labels → cell_labels.tif ──────
|
|
683
|
+
def _committed_labels_path(self) -> Path | None:
|
|
684
|
+
if self._standalone:
|
|
685
|
+
return self._sa_output_dir / "cell_labels.tif" if self._sa_output_dir else None
|
|
686
|
+
return NucleusArtifactPaths(self._pos_dir).cell_labels if self._pos_dir else None
|
|
687
|
+
|
|
688
|
+
def _on_finalize(self) -> None:
|
|
689
|
+
working = self._output_path()
|
|
690
|
+
committed = self._committed_labels_path()
|
|
691
|
+
if working is None or committed is None or not working.exists():
|
|
692
|
+
self._set_status("No tracked labels to commit — run the pipeline first.")
|
|
693
|
+
return
|
|
694
|
+
try:
|
|
695
|
+
promote_labels(working, committed)
|
|
696
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
697
|
+
self._set_status(f"Commit failed: {exc}")
|
|
698
|
+
return
|
|
699
|
+
self._set_status(f"Committed cell labels → {committed.name}.")
|
|
700
|
+
self._refresh_finalize_btn()
|
|
701
|
+
# Surface the committed file (and let the host repaint the stage dots).
|
|
702
|
+
self._files_widget.refresh(self._pos_dir)
|
|
703
|
+
|
|
704
|
+
def _finalize_btns(self) -> tuple:
|
|
705
|
+
return tuple(
|
|
706
|
+
b
|
|
707
|
+
for b in (
|
|
708
|
+
getattr(self, "finalize_btn", None),
|
|
709
|
+
getattr(self, "finalize_header_btn", None),
|
|
710
|
+
)
|
|
711
|
+
if b is not None
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _refresh_finalize_btn(self) -> None:
|
|
715
|
+
buttons = self._finalize_btns()
|
|
716
|
+
if not buttons:
|
|
717
|
+
return
|
|
718
|
+
working = self._output_path()
|
|
719
|
+
committed = self._committed_labels_path()
|
|
720
|
+
if working is None or committed is None:
|
|
721
|
+
for button in buttons:
|
|
722
|
+
button.setEnabled(False)
|
|
723
|
+
return
|
|
724
|
+
state = commit_state(working, committed)
|
|
725
|
+
tooltip = (
|
|
726
|
+
"Working labels are newer than the committed cell_labels.tif — "
|
|
727
|
+
"re-commit to update."
|
|
728
|
+
if state == "stale"
|
|
729
|
+
else "Finalize: promote tracked labels to cell_labels.tif (final, downstream-stable output)."
|
|
730
|
+
)
|
|
731
|
+
for button in buttons:
|
|
732
|
+
button.setEnabled(state != "missing")
|
|
733
|
+
button.setToolTip(tooltip)
|
|
734
|
+
|
|
735
|
+
# ================================================================
|
|
736
|
+
# Standalone input/output pickers (only built/used when standalone)
|
|
737
|
+
# ================================================================
|
|
738
|
+
# Row building, browse handlers, and QSettings persistence come from
|
|
739
|
+
# StandalonePathsMixin; this widget only declares its fields and the
|
|
740
|
+
# piece-specific step that pushes them into the workspace.
|
|
741
|
+
_SETTINGS_APP = "itasc_segmentation"
|
|
742
|
+
|
|
743
|
+
def _standalone_fields(self) -> dict[str, QLineEdit]:
|
|
744
|
+
return {
|
|
745
|
+
"foreground": self._foreground_edit,
|
|
746
|
+
"contours": self._contours_edit,
|
|
747
|
+
"nucleus": self._nucleus_edit,
|
|
748
|
+
"output_dir": self._output_dir_edit,
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
def _apply_standalone_paths(self) -> None:
|
|
752
|
+
"""Push the picker fields into the standalone inputs and re-wire paths.
|
|
753
|
+
|
|
754
|
+
``_pos_dir`` is set to the output dir so the ``_pos_dir is None`` guards
|
|
755
|
+
(run, preview, correction) pass once an output folder is chosen; the
|
|
756
|
+
input/output path methods resolve to the explicit files in standalone.
|
|
757
|
+
"""
|
|
758
|
+
def _val(edit: QLineEdit) -> Path | None:
|
|
759
|
+
text = edit.text().strip()
|
|
760
|
+
return Path(text) if text else None
|
|
761
|
+
|
|
762
|
+
self._sa_foreground = _val(self._foreground_edit)
|
|
763
|
+
self._sa_contours = _val(self._contours_edit)
|
|
764
|
+
self._sa_nucleus = _val(self._nucleus_edit)
|
|
765
|
+
self._sa_output_dir = _val(self._output_dir_edit)
|
|
766
|
+
self._save_standalone_settings()
|
|
767
|
+
self.refresh(self._sa_output_dir)
|
|
768
|
+
|
|
769
|
+
def _load_standalone_settings(self) -> None:
|
|
770
|
+
self._load_path_settings(self._SETTINGS_APP, self._standalone_fields())
|
|
771
|
+
self._apply_standalone_paths()
|
|
772
|
+
|
|
773
|
+
def _save_standalone_settings(self) -> None:
|
|
774
|
+
self._save_path_settings(self._SETTINGS_APP, self._standalone_fields())
|
|
775
|
+
|
|
776
|
+
def get_state(self) -> dict:
|
|
777
|
+
return {
|
|
778
|
+
"cleanup": {
|
|
779
|
+
"fg_window": self.fg_window_spin.value(),
|
|
780
|
+
"fg_strength": self.fg_strength_spin.value(),
|
|
781
|
+
"fg_threshold": self.fg_threshold_spin.value(),
|
|
782
|
+
"contour_window": self.contour_window_spin.value(),
|
|
783
|
+
"contour_strength": self.contour_strength_spin.value(),
|
|
784
|
+
"contour_threshold": self.contour_threshold_spin.value(),
|
|
785
|
+
"contour_norm_pct": self.contour_norm_pct_spin.value(),
|
|
786
|
+
},
|
|
787
|
+
"temporal": {
|
|
788
|
+
"memory_tau": self.memory_tau_spin.value(),
|
|
789
|
+
"memory_floor": self.memory_floor_spin.value(),
|
|
790
|
+
},
|
|
791
|
+
"segmentation": {
|
|
792
|
+
"balance": self.balance_spin.value(),
|
|
793
|
+
"feature_strength": self.feature_strength_spin.value(),
|
|
794
|
+
"n_workers": self.n_workers_spin.value(),
|
|
795
|
+
},
|
|
796
|
+
"preview": {
|
|
797
|
+
"foreground": self.fg_check.isChecked(),
|
|
798
|
+
"contours": self.contour_check.isChecked(),
|
|
799
|
+
"cost": self.cost_check.isChecked(),
|
|
800
|
+
"labels": self.labels_check.isChecked(),
|
|
801
|
+
},
|
|
802
|
+
"correction": {
|
|
803
|
+
"hole_radius": self.hole_radius_spin.value(),
|
|
804
|
+
"scope": self.correction_scope_combo.currentText(),
|
|
805
|
+
},
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
def set_state(self, state: dict) -> None:
|
|
809
|
+
if not isinstance(state, dict):
|
|
810
|
+
return
|
|
811
|
+
_map = {
|
|
812
|
+
"cleanup": {
|
|
813
|
+
"fg_window": self.fg_window_spin,
|
|
814
|
+
"fg_strength": self.fg_strength_spin,
|
|
815
|
+
"fg_threshold": self.fg_threshold_spin,
|
|
816
|
+
"contour_window": self.contour_window_spin,
|
|
817
|
+
"contour_strength": self.contour_strength_spin,
|
|
818
|
+
"contour_threshold": self.contour_threshold_spin,
|
|
819
|
+
"contour_norm_pct": self.contour_norm_pct_spin,
|
|
820
|
+
},
|
|
821
|
+
"temporal": {
|
|
822
|
+
"memory_tau": self.memory_tau_spin,
|
|
823
|
+
"memory_floor": self.memory_floor_spin,
|
|
824
|
+
},
|
|
825
|
+
"segmentation": {
|
|
826
|
+
"balance": self.balance_spin,
|
|
827
|
+
"feature_strength": self.feature_strength_spin,
|
|
828
|
+
"n_workers": self.n_workers_spin,
|
|
829
|
+
},
|
|
830
|
+
"correction": {
|
|
831
|
+
"hole_radius": self.hole_radius_spin,
|
|
832
|
+
},
|
|
833
|
+
}
|
|
834
|
+
for group_key, widgets in _map.items():
|
|
835
|
+
group = state.get(group_key, {})
|
|
836
|
+
if not isinstance(group, dict):
|
|
837
|
+
continue
|
|
838
|
+
for key, spin in widgets.items():
|
|
839
|
+
if key in group:
|
|
840
|
+
spin.setValue(group[key])
|
|
841
|
+
preview = state.get("preview", {})
|
|
842
|
+
if isinstance(preview, dict):
|
|
843
|
+
for key, check in (
|
|
844
|
+
("foreground", self.fg_check),
|
|
845
|
+
("contours", self.contour_check),
|
|
846
|
+
("cost", self.cost_check),
|
|
847
|
+
("labels", self.labels_check),
|
|
848
|
+
):
|
|
849
|
+
if key in preview:
|
|
850
|
+
check.setChecked(bool(preview[key]))
|
|
851
|
+
correction = state.get("correction", {})
|
|
852
|
+
if isinstance(correction, dict) and "scope" in correction:
|
|
853
|
+
idx = self.correction_scope_combo.findText(correction["scope"])
|
|
854
|
+
if idx >= 0:
|
|
855
|
+
self.correction_scope_combo.setCurrentIndex(idx)
|
|
856
|
+
|
|
857
|
+
def set_selection_callback(self, fn) -> None:
|
|
858
|
+
self.correction_widget.set_selection_callback(fn)
|
|
859
|
+
|
|
860
|
+
def select_matching_cell_label(
|
|
861
|
+
self, t: int, source_label: int,
|
|
862
|
+
*, source_labels: np.ndarray | None = None,
|
|
863
|
+
) -> None:
|
|
864
|
+
if "[Correction] Cell Labels" in self.viewer.layers:
|
|
865
|
+
target_layer = self.viewer.layers["[Correction] Cell Labels"]
|
|
866
|
+
elif _TRACKED_CELL_LAYER in self.viewer.layers:
|
|
867
|
+
target_layer = self.viewer.layers[_TRACKED_CELL_LAYER]
|
|
868
|
+
else:
|
|
869
|
+
return
|
|
870
|
+
if source_labels is None:
|
|
871
|
+
if "Tracked: Nucleus" not in self.viewer.layers:
|
|
872
|
+
return
|
|
873
|
+
source_labels = np.asarray(self.viewer.layers["Tracked: Nucleus"].data)
|
|
874
|
+
target = np.asarray(target_layer.data)
|
|
875
|
+
matched = best_overlapping_label(target, source_labels, t, source_label)
|
|
876
|
+
self.correction_widget.select_label(t, matched, notify=False)
|
|
877
|
+
|
|
878
|
+
# ================================================================
|
|
879
|
+
# Status / layer helpers
|
|
880
|
+
# ================================================================
|
|
881
|
+
def _set_status(self, msg: str) -> None:
|
|
882
|
+
self.pipeline_status_lbl.setText(msg)
|
|
883
|
+
self.pipeline_status_lbl.setVisible(bool(msg))
|
|
884
|
+
if msg:
|
|
885
|
+
logger.info(msg)
|
|
886
|
+
|
|
887
|
+
def _show_layer(self, name, data, kwargs, adder):
|
|
888
|
+
if name in self.viewer.layers:
|
|
889
|
+
try:
|
|
890
|
+
self.viewer.layers[name].data = data
|
|
891
|
+
except Exception:
|
|
892
|
+
self.viewer.layers.remove(self.viewer.layers[name])
|
|
893
|
+
adder(data, name=name, **kwargs)
|
|
894
|
+
else:
|
|
895
|
+
adder(data, name=name, **kwargs)
|
|
896
|
+
|
|
897
|
+
def _current_t(self) -> int:
|
|
898
|
+
step = getattr(getattr(self.viewer, "dims", None), "current_step", (0,))
|
|
899
|
+
return int(step[0]) if len(step) >= 1 else 0
|
|
900
|
+
|
|
901
|
+
def _read_frame(self, path, t: int, dtype=np.float32) -> np.ndarray:
|
|
902
|
+
return np.asarray(tifffile.imread(str(path), key=t), dtype=dtype)
|
|
903
|
+
|
|
904
|
+
@staticmethod
|
|
905
|
+
def _shape_of(path):
|
|
906
|
+
"""(T, Y, X) from a TIFF header (no pixel load); None if absent."""
|
|
907
|
+
if path is None or not path.exists():
|
|
908
|
+
return None
|
|
909
|
+
with tifffile.TiffFile(str(path)) as tf:
|
|
910
|
+
n_frames = len(tf.pages)
|
|
911
|
+
y, x = tf.pages[0].shape[-2], tf.pages[0].shape[-1]
|
|
912
|
+
return int(n_frames), int(y), int(x)
|
|
913
|
+
|
|
914
|
+
def _map_shape(self):
|
|
915
|
+
"""(T, Y, X) from the contours TIFF header (no pixel load)."""
|
|
916
|
+
return self._shape_of(self._contours_path())
|
|
917
|
+
|
|
918
|
+
# ================================================================
|
|
919
|
+
# Live preview (single frame, all intermediates)
|
|
920
|
+
# ================================================================
|
|
921
|
+
def _on_param_changed(self, *_args) -> None:
|
|
922
|
+
if self._preview_active:
|
|
923
|
+
self._preview_timer.start()
|
|
924
|
+
|
|
925
|
+
def _on_time_changed(self, *_args) -> None:
|
|
926
|
+
if self._preview_active:
|
|
927
|
+
self._preview_timer.start()
|
|
928
|
+
|
|
929
|
+
def _on_activate(self, checked: bool) -> None:
|
|
930
|
+
self._preview_active = bool(checked)
|
|
931
|
+
# The live preview owns the viewer; the gate derives cross-section
|
|
932
|
+
# exclusivity from this claim.
|
|
933
|
+
if checked:
|
|
934
|
+
self.gate.claim_viewer("cell_preview")
|
|
935
|
+
self._refresh_preview()
|
|
936
|
+
else:
|
|
937
|
+
self.gate.release_viewer("cell_preview")
|
|
938
|
+
self._preview_pending = False
|
|
939
|
+
for name in _ALL_PREVIEW_LAYERS:
|
|
940
|
+
if name in self.viewer.layers:
|
|
941
|
+
self.viewer.layers.remove(name)
|
|
942
|
+
self._image_needs_autocontrast.clear()
|
|
943
|
+
# Free the resident caches; the preview session is over.
|
|
944
|
+
self._smoothed_stack = None
|
|
945
|
+
self._smoothed_key = None
|
|
946
|
+
self._frame_cache = {}
|
|
947
|
+
self._frame_cache_key = None
|
|
948
|
+
self._set_status("")
|
|
949
|
+
|
|
950
|
+
def _on_compute_toggled(self, group_layers, checked: bool) -> None:
|
|
951
|
+
"""Handle a Compute-checkbox toggle: drop hidden groups, refresh shown.
|
|
952
|
+
|
|
953
|
+
Unticking a group removes its layers immediately. Then, while the
|
|
954
|
+
preview is active, a refresh creates/paints any newly-ticked group —
|
|
955
|
+
computing only if the cached frame doesn't already cover the new level,
|
|
956
|
+
so re-ticking a group that was just computed repaints instantly.
|
|
957
|
+
"""
|
|
958
|
+
if not checked:
|
|
959
|
+
for name in group_layers:
|
|
960
|
+
if name in self.viewer.layers:
|
|
961
|
+
self.viewer.layers.remove(name)
|
|
962
|
+
self._image_needs_autocontrast.discard(name)
|
|
963
|
+
if self._preview_active:
|
|
964
|
+
self._refresh_preview()
|
|
965
|
+
|
|
966
|
+
@staticmethod
|
|
967
|
+
def _smooth_key(params: CellDivergenceParams) -> tuple:
|
|
968
|
+
"""Cache key for the smoothed contour stack: the knobs that determine it.
|
|
969
|
+
|
|
970
|
+
The fg_*, balance and feature_strength knobs are deliberately absent —
|
|
971
|
+
they do not change the smoothed contours, so editing them reuses the
|
|
972
|
+
cached stack.
|
|
973
|
+
"""
|
|
974
|
+
return (
|
|
975
|
+
params.contour_window, params.contour_strength,
|
|
976
|
+
params.contour_threshold, params.contour_norm_pct,
|
|
977
|
+
params.memory_tau, params.memory_floor,
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
def _cached_stack_for(self, params: CellDivergenceParams):
|
|
981
|
+
"""Return the resident smoothed stack iff it matches ``params``, else None."""
|
|
982
|
+
if (
|
|
983
|
+
self._smoothed_stack is not None
|
|
984
|
+
and self._smoothed_key == self._smooth_key(params)
|
|
985
|
+
):
|
|
986
|
+
return self._smoothed_stack
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
def _desired_level(self) -> int:
|
|
990
|
+
"""The single-frame compute level needed to fill every ticked group."""
|
|
991
|
+
level = _LEVEL_NONE
|
|
992
|
+
for check, _layers, need in self._compute_groups:
|
|
993
|
+
if check.isChecked():
|
|
994
|
+
level = max(level, need)
|
|
995
|
+
return level
|
|
996
|
+
|
|
997
|
+
def _checked_layer_names(self) -> list[str]:
|
|
998
|
+
"""Layer names owned by the currently-ticked Compute checkboxes."""
|
|
999
|
+
names: list[str] = []
|
|
1000
|
+
for check, layers, _need in self._compute_groups:
|
|
1001
|
+
if check.isChecked():
|
|
1002
|
+
names.extend(layers)
|
|
1003
|
+
return names
|
|
1004
|
+
|
|
1005
|
+
def _preview_inputs(self):
|
|
1006
|
+
"""Validate the inputs a single-frame compute needs.
|
|
1007
|
+
|
|
1008
|
+
Sets a status message and returns ``None`` on any failure; otherwise
|
|
1009
|
+
returns ``(shape, contours_path, foreground_path, nuc_path)``. The
|
|
1010
|
+
foreground fill mask is derived in-process (no disk prerequisite).
|
|
1011
|
+
"""
|
|
1012
|
+
if not self._preview_active:
|
|
1013
|
+
return None
|
|
1014
|
+
if not self._maps_present():
|
|
1015
|
+
self._set_status("Cell divergence maps not found — run Divergence Maps first.")
|
|
1016
|
+
return None
|
|
1017
|
+
nuc_path = self._nuc_path()
|
|
1018
|
+
if nuc_path is None or not nuc_path.exists():
|
|
1019
|
+
self._set_status(self._nuc_missing_msg())
|
|
1020
|
+
return None
|
|
1021
|
+
shape = self._map_shape()
|
|
1022
|
+
if shape is None:
|
|
1023
|
+
self._set_status("Cell divergence maps not found — run Divergence Maps first.")
|
|
1024
|
+
return None
|
|
1025
|
+
return shape, self._contours_path(), self._foreground_path(), nuc_path
|
|
1026
|
+
|
|
1027
|
+
def _compute_frame(
|
|
1028
|
+
self, *, t, level, params, contours_path, foreground_path, nuc_path,
|
|
1029
|
+
cached_stack,
|
|
1030
|
+
):
|
|
1031
|
+
"""Worker-thread body: compute frame ``t`` up to ``level`` only.
|
|
1032
|
+
|
|
1033
|
+
``_LEVEL_FG`` runs the cheap foreground-only path (no contour read);
|
|
1034
|
+
``_LEVEL_SEG``/``_LEVEL_LABELS`` run the full divergence pipeline,
|
|
1035
|
+
deriving the fill mask in-process and (for the geodesic) the labels.
|
|
1036
|
+
When ``memory_tau > 0`` the cleaned + smoothed contour stack (whole
|
|
1037
|
+
movie) feeds the frame through ``contours_clean_override`` so the result
|
|
1038
|
+
matches the full run; the stack is taken from ``cached_stack`` when valid,
|
|
1039
|
+
otherwise computed here and returned for the caller to cache. Returns
|
|
1040
|
+
``(t, result, new_stack)`` where ``new_stack`` is non-``None`` only when
|
|
1041
|
+
it was (re)computed.
|
|
1042
|
+
"""
|
|
1043
|
+
fg = self._read_frame(foreground_path, t)[np.newaxis]
|
|
1044
|
+
nuc = self._read_frame(nuc_path, t, dtype=np.uint32)[np.newaxis]
|
|
1045
|
+
|
|
1046
|
+
if level <= _LEVEL_FG:
|
|
1047
|
+
result = compute_cell_foreground(fg, params, nuc, frame=0)
|
|
1048
|
+
return t, result, None
|
|
1049
|
+
|
|
1050
|
+
contour = self._read_frame(contours_path, t)[np.newaxis]
|
|
1051
|
+
new_stack = None
|
|
1052
|
+
override = None
|
|
1053
|
+
if params.memory_tau > 0.0:
|
|
1054
|
+
stack = cached_stack
|
|
1055
|
+
if stack is None:
|
|
1056
|
+
full = np.asarray(
|
|
1057
|
+
tifffile.imread(str(contours_path)), dtype=np.float32
|
|
1058
|
+
)
|
|
1059
|
+
stack = clean_and_smooth_contours(full, params)
|
|
1060
|
+
new_stack = stack
|
|
1061
|
+
override = stack[t]
|
|
1062
|
+
|
|
1063
|
+
result = segment_cells_divergence(
|
|
1064
|
+
contour, fg, nuc, params, frame=0,
|
|
1065
|
+
with_labels=level >= _LEVEL_LABELS,
|
|
1066
|
+
contours_clean_override=override,
|
|
1067
|
+
)
|
|
1068
|
+
return t, result, new_stack
|
|
1069
|
+
|
|
1070
|
+
def _refresh_preview(self):
|
|
1071
|
+
"""Repaint the current frame's preview, computing only what's missing.
|
|
1072
|
+
|
|
1073
|
+
Clears the per-frame cache when the params changed, then either repaints
|
|
1074
|
+
the current frame from the cache (when it already covers every ticked
|
|
1075
|
+
group's level) or starts a worker for the shortfall. While a pass is in
|
|
1076
|
+
flight, further edits arm ``_preview_pending`` so one fresh pass (latest
|
|
1077
|
+
params/frame) fires when the current one returns. Returns the started
|
|
1078
|
+
worker, or ``None`` when nothing needed computing.
|
|
1079
|
+
"""
|
|
1080
|
+
info = self._preview_inputs()
|
|
1081
|
+
if info is None:
|
|
1082
|
+
return None
|
|
1083
|
+
shape, contours_path, foreground_path, nuc_path = info
|
|
1084
|
+
|
|
1085
|
+
params = self._params()
|
|
1086
|
+
key = dataclasses.astuple(params)
|
|
1087
|
+
if key != self._frame_cache_key:
|
|
1088
|
+
self._frame_cache = {}
|
|
1089
|
+
self._frame_cache_key = key
|
|
1090
|
+
if params.memory_tau <= 0.0:
|
|
1091
|
+
# Smoothing off — release the resident whole-movie stack.
|
|
1092
|
+
self._smoothed_stack = None
|
|
1093
|
+
self._smoothed_key = None
|
|
1094
|
+
|
|
1095
|
+
level = self._desired_level()
|
|
1096
|
+
self._ensure_preview_layers(shape, self._checked_layer_names())
|
|
1097
|
+
if level == _LEVEL_NONE:
|
|
1098
|
+
self._set_status("")
|
|
1099
|
+
return None
|
|
1100
|
+
|
|
1101
|
+
n_frames = shape[0]
|
|
1102
|
+
t = max(0, min(self._current_t(), n_frames - 1))
|
|
1103
|
+
|
|
1104
|
+
cached = self._frame_cache.get(t)
|
|
1105
|
+
if cached is not None and cached[0] >= level:
|
|
1106
|
+
# Already computed to (at least) the needed level — instant repaint.
|
|
1107
|
+
self._apply_result(t, cached[1])
|
|
1108
|
+
self._set_status(self._frame_status(t, cached[1]))
|
|
1109
|
+
return None
|
|
1110
|
+
|
|
1111
|
+
if self._preview_worker is not None:
|
|
1112
|
+
self._preview_pending = True
|
|
1113
|
+
return self._preview_worker
|
|
1114
|
+
|
|
1115
|
+
need_stack = params.memory_tau > 0.0 and level >= _LEVEL_SEG
|
|
1116
|
+
cached_stack = self._cached_stack_for(params) if need_stack else None
|
|
1117
|
+
if need_stack and cached_stack is None:
|
|
1118
|
+
self._set_status(f"Temporal smoothing over {n_frames} frames…")
|
|
1119
|
+
else:
|
|
1120
|
+
self._set_status(f"Computing cell preview for frame {t}…")
|
|
1121
|
+
|
|
1122
|
+
@thread_worker(connect={
|
|
1123
|
+
"returned": self._on_preview_done,
|
|
1124
|
+
"errored": self._on_preview_error,
|
|
1125
|
+
})
|
|
1126
|
+
def _worker():
|
|
1127
|
+
t_, result, new_stack = self._compute_frame(
|
|
1128
|
+
t=t, level=level, params=params, contours_path=contours_path,
|
|
1129
|
+
foreground_path=foreground_path, nuc_path=nuc_path,
|
|
1130
|
+
cached_stack=cached_stack,
|
|
1131
|
+
)
|
|
1132
|
+
return t_, result, params, new_stack, level
|
|
1133
|
+
|
|
1134
|
+
self._preview_worker = _worker()
|
|
1135
|
+
return self._preview_worker
|
|
1136
|
+
|
|
1137
|
+
def _on_preview_done(self, payload) -> None:
|
|
1138
|
+
self._preview_worker = None
|
|
1139
|
+
t, result, params, new_stack, level = payload
|
|
1140
|
+
self._cache_stack(params, new_stack)
|
|
1141
|
+
# Only keep the result if the params still match (an edit mid-flight
|
|
1142
|
+
# cleared and re-keyed the cache; a stale result must not poison it).
|
|
1143
|
+
if dataclasses.astuple(params) == self._frame_cache_key:
|
|
1144
|
+
self._frame_cache[t] = (level, result)
|
|
1145
|
+
if self._preview_active:
|
|
1146
|
+
self._apply_result(t, result)
|
|
1147
|
+
self._set_status(self._frame_status(t, result))
|
|
1148
|
+
if self._preview_pending and self._preview_active:
|
|
1149
|
+
self._preview_pending = False
|
|
1150
|
+
self._refresh_preview()
|
|
1151
|
+
else:
|
|
1152
|
+
self._preview_pending = False
|
|
1153
|
+
|
|
1154
|
+
def _on_preview_error(self, exc: Exception) -> None:
|
|
1155
|
+
self._preview_worker = None
|
|
1156
|
+
self._preview_pending = False
|
|
1157
|
+
self._set_status(f"Cell preview failed: {exc}")
|
|
1158
|
+
logger.exception("Cell preview worker error", exc_info=exc)
|
|
1159
|
+
|
|
1160
|
+
def _cache_stack(self, params: CellDivergenceParams, new_stack) -> None:
|
|
1161
|
+
"""Hold a freshly computed smoothed stack resident for later frames/edits."""
|
|
1162
|
+
if new_stack is not None:
|
|
1163
|
+
self._smoothed_stack = new_stack
|
|
1164
|
+
self._smoothed_key = self._smooth_key(params)
|
|
1165
|
+
|
|
1166
|
+
def _apply_result(self, t: int, result) -> None:
|
|
1167
|
+
"""Paint frame ``t``'s slice for every ticked group the result supplies.
|
|
1168
|
+
|
|
1169
|
+
Works for both a ``CellForegroundResult`` (foreground only) and a full
|
|
1170
|
+
``CellDivergenceResult``; missing fields (e.g. contours on a foreground
|
|
1171
|
+
result) are simply skipped — they are never requested unless the deeper
|
|
1172
|
+
compute that produces them ran.
|
|
1173
|
+
"""
|
|
1174
|
+
if self.fg_check.isChecked():
|
|
1175
|
+
self._fill_image_layer(_FG_RAW_LAYER, t, result.foreground_raw)
|
|
1176
|
+
self._fill_image_layer(_FG_CLEAN_LAYER, t, result.foreground_clean)
|
|
1177
|
+
self._fill_labels_layer(
|
|
1178
|
+
_FG_MASK_LAYER, t, result.foreground_mask.astype(np.uint8)
|
|
1179
|
+
)
|
|
1180
|
+
if self.contour_check.isChecked() and hasattr(result, "contours_raw"):
|
|
1181
|
+
self._fill_image_layer(_CT_RAW_LAYER, t, result.contours_raw)
|
|
1182
|
+
self._fill_image_layer(_CT_CLEAN_LAYER, t, result.contours_clean)
|
|
1183
|
+
if self.cost_check.isChecked() and hasattr(result, "cost_field"):
|
|
1184
|
+
self._fill_image_layer(
|
|
1185
|
+
_COST_LAYER, t, self._cost_for_display(result.cost_field)
|
|
1186
|
+
)
|
|
1187
|
+
if self.labels_check.isChecked() and getattr(result, "labels", None) is not None:
|
|
1188
|
+
self._fill_labels_layer(_LABELS_LAYER, t, result.labels.astype(np.int32))
|
|
1189
|
+
|
|
1190
|
+
@staticmethod
|
|
1191
|
+
def _frame_status(t: int, result) -> str:
|
|
1192
|
+
coverage = 100.0 * float(result.foreground_mask.mean())
|
|
1193
|
+
msg = f"Frame {t}: {coverage:.0f}% fill coverage"
|
|
1194
|
+
labels = getattr(result, "labels", None)
|
|
1195
|
+
if labels is not None:
|
|
1196
|
+
n_labels = int(np.unique(labels[labels > 0]).size)
|
|
1197
|
+
msg += f", {n_labels} cell labels"
|
|
1198
|
+
return msg
|
|
1199
|
+
|
|
1200
|
+
@staticmethod
|
|
1201
|
+
def _cost_for_display(cost: np.ndarray) -> np.ndarray:
|
|
1202
|
+
"""Mask the geodesic-cost background (inf) to NaN for the colormap."""
|
|
1203
|
+
return np.where(np.isfinite(cost), cost, np.nan).astype(np.float32)
|
|
1204
|
+
|
|
1205
|
+
# ── preview layers (one full (T, Y, X) stack per intermediate) ────────
|
|
1206
|
+
# The preview only ever computes the current frame, but the layers are sized
|
|
1207
|
+
# to the whole input movie ``(T, Y, X)`` and painted one frame at a time.
|
|
1208
|
+
# Carrying the time axis is what gives the viewer a frame slider even when no
|
|
1209
|
+
# movie layer is open — otherwise ``current_step`` has no temporal entry and
|
|
1210
|
+
# the preview is stuck on (and mislabels) frame 0. A time scrub recomputes
|
|
1211
|
+
# the newly shown frame (``_on_time_changed``) and paints it into its slice;
|
|
1212
|
+
# previously computed frames stay painted in theirs. Only the layers for the
|
|
1213
|
+
# ticked Compute checkboxes are created.
|
|
1214
|
+
def _ensure_preview_layers(self, shape, names) -> None:
|
|
1215
|
+
for name in names:
|
|
1216
|
+
if name in _LABELS_KIND:
|
|
1217
|
+
self._ensure_labels_layer(name, shape)
|
|
1218
|
+
else:
|
|
1219
|
+
self._ensure_image_layer(name, shape, _IMAGE_COLORMAPS[name])
|
|
1220
|
+
|
|
1221
|
+
def _ensure_image_layer(self, name: str, shape, colormap: str) -> None:
|
|
1222
|
+
if name in self.viewer.layers:
|
|
1223
|
+
layer = self.viewer.layers[name]
|
|
1224
|
+
if tuple(layer.data.shape) == tuple(shape):
|
|
1225
|
+
return
|
|
1226
|
+
was_visible = layer.visible
|
|
1227
|
+
self.viewer.layers.remove(name)
|
|
1228
|
+
else:
|
|
1229
|
+
was_visible = True
|
|
1230
|
+
new_layer = self.viewer.add_image(
|
|
1231
|
+
np.zeros(shape, dtype=np.float32), name=name, colormap=colormap,
|
|
1232
|
+
)
|
|
1233
|
+
new_layer.visible = was_visible
|
|
1234
|
+
# Seed this layer's contrast from the first real frame it receives.
|
|
1235
|
+
self._image_needs_autocontrast.add(name)
|
|
1236
|
+
|
|
1237
|
+
def _ensure_labels_layer(self, name: str, shape) -> None:
|
|
1238
|
+
from napari.layers import Labels
|
|
1239
|
+
if name in self.viewer.layers:
|
|
1240
|
+
layer = self.viewer.layers[name]
|
|
1241
|
+
if isinstance(layer, Labels) and tuple(layer.data.shape) == tuple(shape):
|
|
1242
|
+
return
|
|
1243
|
+
was_visible = layer.visible
|
|
1244
|
+
self.viewer.layers.remove(name)
|
|
1245
|
+
else:
|
|
1246
|
+
was_visible = True
|
|
1247
|
+
new_layer = self.viewer.add_labels(
|
|
1248
|
+
np.zeros(shape, dtype=np.int32), name=name, opacity=0.55
|
|
1249
|
+
)
|
|
1250
|
+
new_layer.visible = was_visible
|
|
1251
|
+
|
|
1252
|
+
def _fill_image_layer(self, name: str, t: int, frame: np.ndarray) -> None:
|
|
1253
|
+
if name not in self.viewer.layers:
|
|
1254
|
+
return
|
|
1255
|
+
layer = self.viewer.layers[name]
|
|
1256
|
+
if layer.data.ndim != 3 or not 0 <= t < layer.data.shape[0]:
|
|
1257
|
+
return
|
|
1258
|
+
layer.data[t] = np.asarray(frame, dtype=layer.data.dtype)
|
|
1259
|
+
if name in self._image_needs_autocontrast:
|
|
1260
|
+
finite = frame[np.isfinite(frame)]
|
|
1261
|
+
if finite.size:
|
|
1262
|
+
lo, hi = float(finite.min()), float(finite.max())
|
|
1263
|
+
if hi > lo:
|
|
1264
|
+
layer.contrast_limits = (lo, hi)
|
|
1265
|
+
self._image_needs_autocontrast.discard(name)
|
|
1266
|
+
layer.refresh()
|
|
1267
|
+
|
|
1268
|
+
def _fill_labels_layer(self, name: str, t: int, frame: np.ndarray) -> None:
|
|
1269
|
+
if name not in self.viewer.layers:
|
|
1270
|
+
return
|
|
1271
|
+
layer = self.viewer.layers[name]
|
|
1272
|
+
if layer.data.ndim != 3 or not 0 <= t < layer.data.shape[0]:
|
|
1273
|
+
return
|
|
1274
|
+
layer.data[t] = np.asarray(frame, dtype=layer.data.dtype)
|
|
1275
|
+
layer.refresh()
|
|
1276
|
+
|
|
1277
|
+
# ================================================================
|
|
1278
|
+
# Full run (final output only)
|
|
1279
|
+
# ================================================================
|
|
1280
|
+
def _on_run_clicked(self) -> None:
|
|
1281
|
+
if self._running:
|
|
1282
|
+
self._on_cancel()
|
|
1283
|
+
else:
|
|
1284
|
+
self._on_run()
|
|
1285
|
+
|
|
1286
|
+
def _on_cancel(self) -> None:
|
|
1287
|
+
if self._run_worker is not None and hasattr(self._run_worker, "quit"):
|
|
1288
|
+
self._run_worker.quit()
|
|
1289
|
+
self._run_worker = None
|
|
1290
|
+
self._running = False
|
|
1291
|
+
self._set_run_idle()
|
|
1292
|
+
self._clear_progress()
|
|
1293
|
+
self._set_status("Cancelled.")
|
|
1294
|
+
|
|
1295
|
+
def _on_run(self) -> None:
|
|
1296
|
+
if self._pos_dir is None:
|
|
1297
|
+
self._set_status("No project open.")
|
|
1298
|
+
return
|
|
1299
|
+
if not self._maps_present():
|
|
1300
|
+
self._set_status("Cell divergence maps not found — run Divergence Maps first.")
|
|
1301
|
+
return
|
|
1302
|
+
nuc_path = self._nuc_path()
|
|
1303
|
+
if nuc_path is None or not nuc_path.exists():
|
|
1304
|
+
self._set_status(self._nuc_missing_msg())
|
|
1305
|
+
return
|
|
1306
|
+
|
|
1307
|
+
params = self._params()
|
|
1308
|
+
contours_path = self._contours_path()
|
|
1309
|
+
foreground_path = self._foreground_path()
|
|
1310
|
+
output_path = self._output_path()
|
|
1311
|
+
pos_dir = self._pos_dir
|
|
1312
|
+
|
|
1313
|
+
def _done(result):
|
|
1314
|
+
self._run_worker = None
|
|
1315
|
+
self._running = False
|
|
1316
|
+
self._set_run_idle()
|
|
1317
|
+
self._clear_progress()
|
|
1318
|
+
labels, n_labels = result
|
|
1319
|
+
self._show_layer(
|
|
1320
|
+
_TRACKED_CELL_LAYER, labels, {"visible": True}, self.viewer.add_labels
|
|
1321
|
+
)
|
|
1322
|
+
self._files_widget.refresh(pos_dir)
|
|
1323
|
+
self._refresh_finalize_btn()
|
|
1324
|
+
self._set_status(
|
|
1325
|
+
f"Segmentation complete — {n_labels} labels, "
|
|
1326
|
+
f"saved to {output_path.name}."
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
def _error(exc):
|
|
1330
|
+
self._run_worker = None
|
|
1331
|
+
self._running = False
|
|
1332
|
+
self._set_run_idle()
|
|
1333
|
+
self._clear_progress()
|
|
1334
|
+
if isinstance(exc, CancelledError):
|
|
1335
|
+
self._set_status("Cancelled.")
|
|
1336
|
+
return
|
|
1337
|
+
self._set_status(f"Error: {exc}")
|
|
1338
|
+
logger.exception("Cell segmentation run error", exc_info=exc)
|
|
1339
|
+
|
|
1340
|
+
@thread_worker(connect={"returned": _done, "errored": _error})
|
|
1341
|
+
def _worker():
|
|
1342
|
+
progress = self._run_progress
|
|
1343
|
+
|
|
1344
|
+
def _cb(msg: str) -> None:
|
|
1345
|
+
progress.emit(str(msg))
|
|
1346
|
+
|
|
1347
|
+
contours = tifffile.imread(str(contours_path))
|
|
1348
|
+
foreground = tifffile.imread(str(foreground_path))
|
|
1349
|
+
nuc = tifffile.imread(str(nuc_path))
|
|
1350
|
+
result = segment_cells_divergence(
|
|
1351
|
+
contours, foreground, nuc, params, progress_cb=_cb,
|
|
1352
|
+
)
|
|
1353
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1354
|
+
commit_labels(result.labels, output_path)
|
|
1355
|
+
n_labels = int(np.unique(result.labels[result.labels > 0]).size)
|
|
1356
|
+
return result.labels, n_labels
|
|
1357
|
+
|
|
1358
|
+
self._set_status("Segmenting all frames…")
|
|
1359
|
+
self.pipeline_progress_bar.setRange(0, 0)
|
|
1360
|
+
self.pipeline_progress_bar.setVisible(True)
|
|
1361
|
+
self._running = True
|
|
1362
|
+
self._set_run_running()
|
|
1363
|
+
self._run_worker = _worker()
|
|
1364
|
+
|
|
1365
|
+
def _set_run_running(self) -> None:
|
|
1366
|
+
self.run_btn.setText("✕")
|
|
1367
|
+
self.run_btn.setToolTip("Cancel.")
|
|
1368
|
+
# ``self._running`` is set by the caller before this runs; the gate
|
|
1369
|
+
# derives active_btn enablement from it.
|
|
1370
|
+
self.gate.set_task("cell_run", True)
|
|
1371
|
+
|
|
1372
|
+
def _set_run_idle(self) -> None:
|
|
1373
|
+
self.run_btn.setText("▶")
|
|
1374
|
+
self.run_btn.setToolTip(
|
|
1375
|
+
"Run the full pipeline over all frames and write tracked_labels.tif."
|
|
1376
|
+
)
|
|
1377
|
+
self.gate.set_task("cell_run", False)
|
|
1378
|
+
|
|
1379
|
+
def _clear_progress(self) -> None:
|
|
1380
|
+
self.pipeline_progress_bar.setRange(0, 100)
|
|
1381
|
+
self.pipeline_progress_bar.setValue(0)
|
|
1382
|
+
self.pipeline_progress_bar.setVisible(False)
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def make_cell_segmentation_widget(napari_viewer=None):
|
|
1386
|
+
"""napari plugin factory for the standalone cell-segmentation piece.
|
|
1387
|
+
|
|
1388
|
+
Used by the ``itasc-segmentation`` distribution's manifest. Patches the
|
|
1389
|
+
napari layer-controls delegate (best-effort, normally done by the
|
|
1390
|
+
orchestrator) and returns the workflow widget in standalone mode, with its
|
|
1391
|
+
own foreground/contours/nucleus input pickers and output-dir picker.
|
|
1392
|
+
"""
|
|
1393
|
+
try:
|
|
1394
|
+
from itasc.napari._napari_compat import patch_napari_layer_delegate
|
|
1395
|
+
|
|
1396
|
+
patch_napari_layer_delegate()
|
|
1397
|
+
except Exception: # pragma: no cover - patch is best-effort
|
|
1398
|
+
pass
|
|
1399
|
+
# napari does not inject the viewer into function-based widget factories
|
|
1400
|
+
# (only into class-based callables / magicgui types), so ``napari_viewer``
|
|
1401
|
+
# arrives as ``None``. The widget needs a live viewer; fall back to the
|
|
1402
|
+
# active one.
|
|
1403
|
+
if napari_viewer is None:
|
|
1404
|
+
napari_viewer = napari.current_viewer()
|
|
1405
|
+
return CellWorkflowWidget(viewer=napari_viewer, standalone=True)
|