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,1253 @@
|
|
|
1
|
+
"""Standalone Cellpose *segment + track* widget for the ``itasc-cellpose`` tool.
|
|
2
|
+
|
|
3
|
+
This is the independently-shipped distribution's own surface — distinct from the
|
|
4
|
+
app's :class:`~itasc.napari.cellpose_widget.CellposeWidget`, which stays
|
|
5
|
+
untouched and keeps emitting divergence maps for the integrated pipeline.
|
|
6
|
+
|
|
7
|
+
The tool works on **one or two channels**, with no nucleus/cell vocabulary:
|
|
8
|
+
|
|
9
|
+
* **Channel 1** is the *anchor*. On its own it is segmented (Cellpose **native
|
|
10
|
+
masks**, :mod:`itasc.cellpose.native_masks`) and tracked across time with
|
|
11
|
+
**laptrack** (:mod:`itasc.cellpose.track_laptrack`) — the single-channel
|
|
12
|
+
segment + track product.
|
|
13
|
+
* **Channel 2** is optional. When it is present the tool runs **joint** mode and
|
|
14
|
+
*only* joint mode: Channel 1 is segmented + tracked, then each Channel 2
|
|
15
|
+
foreground pixel is flowed along Cellpose's flow field onto the nearest
|
|
16
|
+
Channel-1 object (:mod:`itasc.cellpose.flow_following`). You get one
|
|
17
|
+
Channel-2 object per Channel-1 object, sharing its track id. Channel 2 is never
|
|
18
|
+
segmented independently — there is no separate-masks alternative.
|
|
19
|
+
|
|
20
|
+
Conventionally Channel 1 is the nucleus (a clean, separable anchor) and Channel 2
|
|
21
|
+
the cell, but nothing here assumes that.
|
|
22
|
+
|
|
23
|
+
Each channel's input is the **active image layer**: select an image in the viewer
|
|
24
|
+
and click the channel's source pill (``⧉``) to bind it — there is no file loading
|
|
25
|
+
and no path text field. That pill then doubles as a **live status light**: it
|
|
26
|
+
stays lit while its bound layer is present in the viewer and goes dark (releasing
|
|
27
|
+
the channel) the moment that layer is removed. **There is no output directory**:
|
|
28
|
+
every result is added straight to the napari viewer as a layer (tagged
|
|
29
|
+
``[Channel 1]`` / ``[Channel 2]``), and the user saves whichever layers they want
|
|
30
|
+
via napari's own *Save Selected Layers*.
|
|
31
|
+
The embedded corrector edits whichever Labels layer is active, with the full
|
|
32
|
+
DB-free toolkit — select / spawn / erase / merge / swap / split (mouse + Delete),
|
|
33
|
+
fill-holes / fragment cleanup, and a greedy retracker on Q/E.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
|
|
39
|
+
import napari
|
|
40
|
+
import numpy as np
|
|
41
|
+
import tifffile
|
|
42
|
+
from napari.layers import Image, Labels
|
|
43
|
+
from napari.qt.threading import thread_worker
|
|
44
|
+
from qtpy.QtCore import Signal
|
|
45
|
+
from qtpy.QtWidgets import (
|
|
46
|
+
QHBoxLayout,
|
|
47
|
+
QLabel,
|
|
48
|
+
QProgressBar,
|
|
49
|
+
QSizePolicy,
|
|
50
|
+
QVBoxLayout,
|
|
51
|
+
QWidget,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
from itasc.napari._widget_helpers import (
|
|
55
|
+
dslider as _dslider,
|
|
56
|
+
islider as _islider,
|
|
57
|
+
tool_btn as _tool_btn,
|
|
58
|
+
)
|
|
59
|
+
from itasc.napari.ui_gate import ControlClass, UiGate
|
|
60
|
+
from itasc.napari.ui_style import (
|
|
61
|
+
add_section_pair_row,
|
|
62
|
+
section_grid,
|
|
63
|
+
stage_header_action_button,
|
|
64
|
+
stage_header_label,
|
|
65
|
+
status_label,
|
|
66
|
+
)
|
|
67
|
+
from itasc.napari.widgets import CollapsibleSection
|
|
68
|
+
from itasc.napari.correction._correction_utils import frame_view_2d
|
|
69
|
+
from itasc.napari.correction.cell_correction_widget import CellCorrectionWidget
|
|
70
|
+
from itasc.cellpose import cellpose_runner, native_masks, track_laptrack
|
|
71
|
+
from itasc.cellpose import joint as joint_mod
|
|
72
|
+
from itasc.cellpose.flow_following import FlowFollowingParams
|
|
73
|
+
from itasc.cellpose.shape import to_canonical_tzyx
|
|
74
|
+
|
|
75
|
+
logger = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
# User-facing channel labels (no nucleus/cell vocabulary). Channel 1 is the
|
|
78
|
+
# anchor that is segmented + tracked; Channel 2 (optional) is flowed onto it.
|
|
79
|
+
_CH1_LABEL = "Channel 1"
|
|
80
|
+
_CH2_LABEL = "Channel 2"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Array-shape helpers (canonical compute shape is (T, Z, Y, X))
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
def _to_tzyx(arr: np.ndarray) -> np.ndarray:
|
|
87
|
+
"""Coerce a label/image array to canonical ``(T, Z, Y, X)``.
|
|
88
|
+
|
|
89
|
+
A 3-D array is read as ``(T, Y, X)`` and gains a singleton Z (the inverse of
|
|
90
|
+
:func:`_squeeze_z`, so a round-trip through a squeezed napari layer is exact);
|
|
91
|
+
a 2-D array becomes a single ``(1, 1, Y, X)`` frame.
|
|
92
|
+
"""
|
|
93
|
+
arr = np.asarray(arr)
|
|
94
|
+
if arr.ndim == 4:
|
|
95
|
+
return arr
|
|
96
|
+
if arr.ndim == 3:
|
|
97
|
+
return arr[:, np.newaxis]
|
|
98
|
+
if arr.ndim == 2:
|
|
99
|
+
return arr[np.newaxis, np.newaxis]
|
|
100
|
+
raise ValueError(f"expected a 2-D..4-D array, got shape {arr.shape}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _squeeze_z(arr: np.ndarray) -> np.ndarray:
|
|
104
|
+
"""Drop a singleton Z from ``(T, 1, Y, X)`` → ``(T, Y, X)`` for napari/corrector.
|
|
105
|
+
|
|
106
|
+
2D+t data (Z=1) displays without a spurious slider and matches the basic
|
|
107
|
+
corrector's 3-D ``(T, Y, X)`` expectation. True 3-D+t (Z>1) is left as-is. An
|
|
108
|
+
RGB map keeps its trailing channel axis: ``(T, 1, Y, X, 3)`` → ``(T, Y, X, 3)``.
|
|
109
|
+
"""
|
|
110
|
+
arr = np.asarray(arr)
|
|
111
|
+
if arr.ndim >= 4 and arr.shape[1] == 1:
|
|
112
|
+
return arr[:, 0]
|
|
113
|
+
return arr
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _prob_to_cellprob(p: float) -> float:
|
|
117
|
+
"""Reverse the sigmoid that produced the displayed prob map → ``cellprob_threshold``.
|
|
118
|
+
|
|
119
|
+
The prob-map layer shows ``sigmoid(cellprob)``; Cellpose thresholds the raw
|
|
120
|
+
pre-sigmoid ``cellprob``. So the ``[0, 1]`` cutoff ``p`` maps back by the inverse
|
|
121
|
+
sigmoid (logit), ``log(p / (1 - p))`` — the slider then reads in the same space
|
|
122
|
+
as the prob-map image. ``0.5 → 0.0`` (Cellpose's default); ``p`` is clamped off
|
|
123
|
+
the open ends to keep the logit finite.
|
|
124
|
+
"""
|
|
125
|
+
p = min(max(float(p), 1e-4), 1.0 - 1e-4)
|
|
126
|
+
return float(np.log(p / (1.0 - p)))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _layer_name(channel_label: str, kind: str) -> str:
|
|
130
|
+
"""Channel-tagged layer name, e.g. ``[Channel 1] masks`` / ``[Channel 2] tracked``."""
|
|
131
|
+
return f"[{channel_label}] {kind}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _segment_done_status(masks_name: str, prob_name: str, flow_name: str) -> str:
|
|
135
|
+
return (
|
|
136
|
+
f"Channel 1 → '{masks_name}', '{prob_name}', '{flow_name}'. "
|
|
137
|
+
"Save from the layers. Track to link masks across time."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _track_done_status(masks_name: str) -> str:
|
|
142
|
+
return (
|
|
143
|
+
f"Channel 1 tracked → '{masks_name}' updated in place. "
|
|
144
|
+
"Select it below to correct."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _coerce_stack(source) -> np.ndarray:
|
|
149
|
+
"""Canonical ``(T, Z, Y, X)`` from either source a channel can have.
|
|
150
|
+
|
|
151
|
+
``source`` is an in-memory array (a napari image layer's ``data``) or a
|
|
152
|
+
``.tif`` path — so a channel reads identically whether it came from disk or
|
|
153
|
+
from a layer already open in the viewer.
|
|
154
|
+
"""
|
|
155
|
+
if isinstance(source, np.ndarray):
|
|
156
|
+
return to_canonical_tzyx(source)
|
|
157
|
+
return to_canonical_tzyx(np.asarray(tifffile.imread(str(source))))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Qt-free compute steps (callable directly in tests; the worker just wraps them)
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
def segment_channel(
|
|
164
|
+
source,
|
|
165
|
+
channel: str,
|
|
166
|
+
params,
|
|
167
|
+
*,
|
|
168
|
+
progress_cb=None,
|
|
169
|
+
cancel_cb=None,
|
|
170
|
+
) -> np.ndarray:
|
|
171
|
+
"""Load a raw stack and return per-plane native masks ``(T, Z, Y, X)``.
|
|
172
|
+
|
|
173
|
+
``source`` is a ``.tif`` path or an in-memory array (a viewer layer). It is
|
|
174
|
+
canonicalised layout-free (:func:`to_canonical_tzyx`) — no 2D/2D+t/3D/3D+t
|
|
175
|
+
declaration — and every plane is segmented individually. ``channel`` selects
|
|
176
|
+
the backend mask routine (the anchor uses ``"nucleus"``).
|
|
177
|
+
"""
|
|
178
|
+
stack = _coerce_stack(source)
|
|
179
|
+
if channel == "nucleus":
|
|
180
|
+
return native_masks.run_nucleus_masks_stack(
|
|
181
|
+
stack, params, progress_cb=progress_cb, cancel_cb=cancel_cb
|
|
182
|
+
)
|
|
183
|
+
return native_masks.run_cell_masks_stack(
|
|
184
|
+
stack, params, progress_cb=progress_cb, cancel_cb=cancel_cb
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def track_channel(
|
|
189
|
+
masks_tzyx: np.ndarray,
|
|
190
|
+
*,
|
|
191
|
+
max_distance: float,
|
|
192
|
+
max_frame_gap: int,
|
|
193
|
+
) -> np.ndarray:
|
|
194
|
+
"""Axis-by-axis linking of an in-memory mask stack: stitch z, then track t.
|
|
195
|
+
|
|
196
|
+
See :func:`track_laptrack.track_axiswise`. Single-slice input reduces to plain
|
|
197
|
+
time tracking.
|
|
198
|
+
"""
|
|
199
|
+
masks = _to_tzyx(masks_tzyx)
|
|
200
|
+
return track_laptrack.track_axiswise(
|
|
201
|
+
masks, max_distance=max_distance, max_frame_gap=max_frame_gap
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def preview_channel_masks(
|
|
206
|
+
stack_tzyx: np.ndarray,
|
|
207
|
+
channel: str,
|
|
208
|
+
params,
|
|
209
|
+
t: int,
|
|
210
|
+
z: int,
|
|
211
|
+
) -> np.ndarray:
|
|
212
|
+
"""Native masks for a single current frame, embedded in a full ``(T, Z, Y, X)``.
|
|
213
|
+
|
|
214
|
+
All other frames are background, so the preview overlays exactly the frame
|
|
215
|
+
the user is looking at while keeping the layer's dims aligned with the input.
|
|
216
|
+
"""
|
|
217
|
+
stack = _to_tzyx(stack_tzyx)
|
|
218
|
+
out = np.zeros(stack.shape, dtype=np.int32)
|
|
219
|
+
frame = stack[t] # (Z, Y, X)
|
|
220
|
+
if channel == "nucleus" and getattr(params, "do_3d", False):
|
|
221
|
+
out[t] = native_masks.run_nucleus_masks_frame(frame, z=None, params=params)
|
|
222
|
+
elif channel == "nucleus":
|
|
223
|
+
out[t, z] = native_masks.run_nucleus_masks_frame(frame, z=z, params=params)
|
|
224
|
+
else:
|
|
225
|
+
out[t, z] = native_masks.run_cell_masks_frame(frame, z=z, params=params)
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def segment_track_joint(
|
|
230
|
+
ch1_source,
|
|
231
|
+
ch2_source,
|
|
232
|
+
ch1_params,
|
|
233
|
+
ch2_params,
|
|
234
|
+
flow_params: FlowFollowingParams,
|
|
235
|
+
*,
|
|
236
|
+
max_distance: float,
|
|
237
|
+
max_frame_gap: int,
|
|
238
|
+
progress_cb=None,
|
|
239
|
+
cancel_cb=None,
|
|
240
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
241
|
+
"""Load both channels (layout-free) and run the joint anchor path.
|
|
242
|
+
|
|
243
|
+
Each ``*_source`` is a ``.tif`` path or an in-memory array (a viewer layer).
|
|
244
|
+
Channel 1 is the anchor (segmented + tracked); Channel 2 is flowed onto it.
|
|
245
|
+
Returns ``(ch1_tracked, ch2_tracked)`` as ``(T, Z, Y, X)`` int32 stacks that
|
|
246
|
+
share label ids (one Channel-2 object per Channel-1 object). The Channel-2
|
|
247
|
+
stack is tracked by inheriting the Channel-1 tracks, so it needs no tracker.
|
|
248
|
+
"""
|
|
249
|
+
ch1_stack = _coerce_stack(ch1_source)
|
|
250
|
+
ch2_stack = _coerce_stack(ch2_source)
|
|
251
|
+
return joint_mod.joint_segment_track(
|
|
252
|
+
ch1_stack, ch2_stack, ch1_params, ch2_params, flow_params,
|
|
253
|
+
max_distance=max_distance, max_frame_gap=max_frame_gap,
|
|
254
|
+
progress_cb=progress_cb, cancel_cb=cancel_cb,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def preview_joint(
|
|
259
|
+
ch1_source,
|
|
260
|
+
ch2_source,
|
|
261
|
+
ch1_params,
|
|
262
|
+
ch2_params,
|
|
263
|
+
flow_params: FlowFollowingParams,
|
|
264
|
+
t: int,
|
|
265
|
+
*,
|
|
266
|
+
max_distance: float,
|
|
267
|
+
max_frame_gap: int,
|
|
268
|
+
progress_cb=None,
|
|
269
|
+
cancel_cb=None,
|
|
270
|
+
) -> np.ndarray:
|
|
271
|
+
"""Joint Channel-2 result for a single current frame, embedded full-size.
|
|
272
|
+
|
|
273
|
+
Runs the joint anchor path on just frame ``t`` of both channels (tracking one
|
|
274
|
+
frame is a no-op) and embeds the Channel-2 result into an otherwise-background
|
|
275
|
+
``(T, Z, Y, X)`` stack — so the preview overlays exactly the frame on screen,
|
|
276
|
+
letting the Channel-2 params be tuned before committing to the whole stack.
|
|
277
|
+
"""
|
|
278
|
+
ch1 = _coerce_stack(ch1_source)
|
|
279
|
+
ch2 = _coerce_stack(ch2_source)
|
|
280
|
+
_, ch2_frame = joint_mod.joint_segment_track(
|
|
281
|
+
ch1[t:t + 1], ch2[t:t + 1], ch1_params, ch2_params, flow_params,
|
|
282
|
+
max_distance=max_distance, max_frame_gap=max_frame_gap,
|
|
283
|
+
progress_cb=progress_cb, cancel_cb=cancel_cb,
|
|
284
|
+
)
|
|
285
|
+
out = np.zeros(ch1.shape, dtype=np.int32)
|
|
286
|
+
out[t] = ch2_frame[0]
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _make_status() -> QLabel:
|
|
291
|
+
lbl = QLabel("")
|
|
292
|
+
lbl.setWordWrap(True)
|
|
293
|
+
lbl.setVisible(False)
|
|
294
|
+
status_label(lbl)
|
|
295
|
+
return lbl
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _make_progress() -> QProgressBar:
|
|
299
|
+
bar = QProgressBar()
|
|
300
|
+
bar.setRange(0, 100)
|
|
301
|
+
bar.setValue(0)
|
|
302
|
+
bar.setTextVisible(True)
|
|
303
|
+
bar.setVisible(False)
|
|
304
|
+
return bar
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class CellposeSegmentTrackWidget(QWidget):
|
|
308
|
+
"""Standalone segment+track: Channel 1 anchors; a Channel 2 makes it joint."""
|
|
309
|
+
|
|
310
|
+
_progress_signal = Signal(int, int, str)
|
|
311
|
+
|
|
312
|
+
def __init__(self, viewer: napari.Viewer, parent: QWidget | None = None) -> None:
|
|
313
|
+
super().__init__(parent)
|
|
314
|
+
self.viewer = viewer
|
|
315
|
+
self.gate = UiGate(self)
|
|
316
|
+
# Each channel is bound to a live viewer image layer (its only source).
|
|
317
|
+
# The reference is what the source pill's status light tracks: when the
|
|
318
|
+
# layer leaves the viewer the binding is dropped and the pill goes dark.
|
|
319
|
+
self._ch1_layer = None
|
|
320
|
+
self._ch2_layer = None
|
|
321
|
+
self._running: str | None = None # e.g. "ch1_seg", "ch1_track", "ch2_run"
|
|
322
|
+
self._worker = None
|
|
323
|
+
self._cancel_requested = False
|
|
324
|
+
# Live accumulators for the streaming Channel-1 segmentation: each frame
|
|
325
|
+
# the worker yields is written here and pushed to the viewer (None = idle).
|
|
326
|
+
self._stream: dict | None = None
|
|
327
|
+
|
|
328
|
+
self._setup_ui()
|
|
329
|
+
self._connect_signals()
|
|
330
|
+
self._register_gate_controls()
|
|
331
|
+
self._progress_signal.connect(self._progress)
|
|
332
|
+
self._connect_layer_events()
|
|
333
|
+
self._status(
|
|
334
|
+
"Bind Channel 1 to begin. "
|
|
335
|
+
"(Optional: also bind Channel 2 for joint segmentation.)"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------ UI
|
|
339
|
+
def _setup_ui(self) -> None:
|
|
340
|
+
root = QVBoxLayout(self)
|
|
341
|
+
root.setContentsMargins(2, 2, 2, 2)
|
|
342
|
+
root.setSpacing(6)
|
|
343
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
|
344
|
+
|
|
345
|
+
# ── Channel 1 row (anchor: segment + track) ──
|
|
346
|
+
# One row: the "Channel 1" pill, then its source pill (load from the active
|
|
347
|
+
# image layer) and its action buttons (params, preview, segment, track).
|
|
348
|
+
# There is no file loading and no path text field — the source pill binds
|
|
349
|
+
# whatever image layer is active and then acts as a status light for it.
|
|
350
|
+
self.ch1_layer_btn = self._make_source_button(_CH1_LABEL, self._on_load_layer_ch1)
|
|
351
|
+
self.ch1_params_btn = _tool_btn("⚙", "Channel 1 parameters.", checkable=True)
|
|
352
|
+
self.ch1_preview_btn = _tool_btn(
|
|
353
|
+
"▷", "Segment the current frame into the masks/prob/flow layers."
|
|
354
|
+
)
|
|
355
|
+
self.ch1_seg_btn = _tool_btn("▶", "Segment Channel 1 (native masks).")
|
|
356
|
+
self.ch1_track_btn = _tool_btn("⊳", "Track Channel 1 masks (laptrack).")
|
|
357
|
+
for b in (
|
|
358
|
+
self.ch1_params_btn, self.ch1_preview_btn,
|
|
359
|
+
self.ch1_seg_btn, self.ch1_track_btn,
|
|
360
|
+
):
|
|
361
|
+
stage_header_action_button(b, "cellpose")
|
|
362
|
+
self.ch1_section = self._build_ch1_params_section()
|
|
363
|
+
self.ch1_section.set_header_visible(False)
|
|
364
|
+
self.ch1_section.collapse()
|
|
365
|
+
self.ch1_params_btn.toggled.connect(
|
|
366
|
+
lambda checked: self.ch1_section._toggle.setChecked(checked)
|
|
367
|
+
)
|
|
368
|
+
ch1_label = self._stage_label("Channel 1")
|
|
369
|
+
ch1_label.setToolTip(
|
|
370
|
+
"Channel 1 — the anchor: segmented and tracked. Typically the nucleus."
|
|
371
|
+
)
|
|
372
|
+
root.addLayout(self._stage_row(
|
|
373
|
+
ch1_label,
|
|
374
|
+
self.ch1_layer_btn,
|
|
375
|
+
self.ch1_params_btn, self.ch1_preview_btn,
|
|
376
|
+
self.ch1_seg_btn, self.ch1_track_btn,
|
|
377
|
+
))
|
|
378
|
+
root.addWidget(self.ch1_section)
|
|
379
|
+
|
|
380
|
+
# ── Channel 2 row (joint — the only mode for a second channel) ──
|
|
381
|
+
# Same single-row shape as Channel 1: the "Channel 2" pill, its source
|
|
382
|
+
# pickers, then its action buttons. A second channel is never segmented on
|
|
383
|
+
# its own — it is always flowed onto the tracked Channel 1 (one object per
|
|
384
|
+
# Channel-1 object, sharing its id). Preview (▷) runs that joint assignment
|
|
385
|
+
# on the current frame so the Channel-2 params can be tuned; Run (▶) commits
|
|
386
|
+
# it over the whole stack. Both need both inputs; Channel 1's own segment +
|
|
387
|
+
# track path is untouched.
|
|
388
|
+
self.ch2_layer_btn = self._make_source_button(_CH2_LABEL, self._on_load_layer_ch2)
|
|
389
|
+
self.ch2_params_btn = _tool_btn("⚙", "Channel 2 parameters.", checkable=True)
|
|
390
|
+
self.ch2_preview_btn = _tool_btn(
|
|
391
|
+
"▷",
|
|
392
|
+
"Preview the joint Channel-2 result on the current frame "
|
|
393
|
+
"(needs both channels).",
|
|
394
|
+
)
|
|
395
|
+
self.ch2_run_btn = _tool_btn(
|
|
396
|
+
"▶",
|
|
397
|
+
"Run joint: segment + track Channel 1, then flow Channel 2 onto it "
|
|
398
|
+
"(needs both channels).",
|
|
399
|
+
)
|
|
400
|
+
for b in (self.ch2_params_btn, self.ch2_preview_btn, self.ch2_run_btn):
|
|
401
|
+
stage_header_action_button(b, "cellpose")
|
|
402
|
+
self.ch2_section = self._build_ch2_params_section()
|
|
403
|
+
self.ch2_section.set_header_visible(False)
|
|
404
|
+
self.ch2_section.collapse()
|
|
405
|
+
self.ch2_params_btn.toggled.connect(
|
|
406
|
+
lambda checked: self.ch2_section._toggle.setChecked(checked)
|
|
407
|
+
)
|
|
408
|
+
ch2_label = self._stage_label("Channel 2")
|
|
409
|
+
ch2_label.setToolTip(
|
|
410
|
+
"Channel 2 — optional. When set, runs joint mode: this channel is "
|
|
411
|
+
"flowed onto Channel 1 (never segmented on its own). Typically the cell."
|
|
412
|
+
)
|
|
413
|
+
root.addLayout(self._stage_row(
|
|
414
|
+
ch2_label,
|
|
415
|
+
self.ch2_layer_btn,
|
|
416
|
+
self.ch2_params_btn, self.ch2_preview_btn, self.ch2_run_btn,
|
|
417
|
+
))
|
|
418
|
+
root.addWidget(self.ch2_section)
|
|
419
|
+
|
|
420
|
+
# ── Cell correction ──
|
|
421
|
+
# Reuse the app's cell corrector — the ultrack/OverlapDB-free one — bound
|
|
422
|
+
# to whatever Labels layer is active, so segment → track → correct is one
|
|
423
|
+
# surface with no on-disk handoff. ``full_editing`` unlocks the complete
|
|
424
|
+
# DB-free toolkit (spawn / erase / merge / swap / split + Q/E retrack) that
|
|
425
|
+
# the app keeps contour-only. The widget brings its own "Correction"
|
|
426
|
+
# header + ⏻ activate button; it edits the active layer in place and the
|
|
427
|
+
# user saves it via napari. ``intensity_frame_provider`` snaps a spawned
|
|
428
|
+
# cell to the prob layer sharing the corrected layer's ``[Channel N]``
|
|
429
|
+
# tag (see ``_spawn_intensity_frame``) instead of stamping a blind disk.
|
|
430
|
+
self.cell_correction = CellCorrectionWidget(
|
|
431
|
+
self.viewer,
|
|
432
|
+
active_labels_layer_provider=self._active_labels_layer,
|
|
433
|
+
intensity_frame_provider=self._spawn_intensity_frame,
|
|
434
|
+
full_editing=True,
|
|
435
|
+
parent=self,
|
|
436
|
+
)
|
|
437
|
+
root.addWidget(self.cell_correction)
|
|
438
|
+
|
|
439
|
+
# ── Status + progress ──
|
|
440
|
+
self.status_lbl = _make_status()
|
|
441
|
+
root.addWidget(self.status_lbl)
|
|
442
|
+
self.progress_bar = _make_progress()
|
|
443
|
+
root.addWidget(self.progress_bar)
|
|
444
|
+
|
|
445
|
+
def _make_source_button(self, label, on_load_layer):
|
|
446
|
+
"""The single source pill for a channel → ``layer_btn``.
|
|
447
|
+
|
|
448
|
+
There is no file loading and no path text field: a channel's source is the
|
|
449
|
+
**active image layer**, bound by clicking this pill (``⧉``). It is checkable
|
|
450
|
+
and doubles as a status light — checked while its bound layer is present in
|
|
451
|
+
the viewer, unchecked once that layer is gone. The glyph matches the stage
|
|
452
|
+
buttons' thin geometric family.
|
|
453
|
+
"""
|
|
454
|
+
layer_btn = _tool_btn(
|
|
455
|
+
"⧉", f"Load {label} from the active image layer.", checkable=True
|
|
456
|
+
)
|
|
457
|
+
stage_header_action_button(layer_btn, "cellpose")
|
|
458
|
+
layer_btn.clicked.connect(on_load_layer)
|
|
459
|
+
return layer_btn
|
|
460
|
+
|
|
461
|
+
def _build_ch1_params_section(self) -> CollapsibleSection:
|
|
462
|
+
# No input-layout / 3D-mode / anisotropy: segmentation is layout-free and
|
|
463
|
+
# per-plane (the shorter leading axis is treated as z for tracking).
|
|
464
|
+
body = QWidget(self)
|
|
465
|
+
grid = section_grid()
|
|
466
|
+
grid.setContentsMargins(8, 4, 4, 4)
|
|
467
|
+
body.setLayout(grid)
|
|
468
|
+
self.ch1_diameter_spin = _dslider(0.0, 500.0, 25.0, 1.0, 1)
|
|
469
|
+
self.ch1_min_size_spin = _islider(0, 100000, 15)
|
|
470
|
+
self.ch1_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
|
|
471
|
+
# Prob threshold: a [0, 1] cutoff read in the prob-map image's own space
|
|
472
|
+
# (0.5 == Cellpose's default), reversed through the inverse sigmoid to the
|
|
473
|
+
# raw cellprob Cellpose thresholds. See _prob_to_cellprob.
|
|
474
|
+
self.ch1_prob_thr_spin = _dslider(0.0, 1.0, 0.5, 0.05, 2)
|
|
475
|
+
# "Flow error tolerance" is Cellpose's flow_threshold, relabelled so its
|
|
476
|
+
# direction reads right: it is the per-mask flow-error budget, so HIGHER is
|
|
477
|
+
# more permissive (0.4 default; 0 disables the QC, keeping every mask).
|
|
478
|
+
# Iterations (niter): flow-dynamics steps, 0 = auto.
|
|
479
|
+
self.ch1_flow_thr_spin = _dslider(0.0, 3.0, 0.4, 0.1, 2)
|
|
480
|
+
self.ch1_niter_spin = _islider(0, 2000, 0)
|
|
481
|
+
add_section_pair_row(
|
|
482
|
+
grid, 0, "Diameter:", self.ch1_diameter_spin, "Min size:", self.ch1_min_size_spin
|
|
483
|
+
)
|
|
484
|
+
add_section_pair_row(
|
|
485
|
+
grid, 1, "Gamma:", self.ch1_gamma_spin, "Prob threshold:", self.ch1_prob_thr_spin
|
|
486
|
+
)
|
|
487
|
+
add_section_pair_row(
|
|
488
|
+
grid, 2,
|
|
489
|
+
"Flow error tolerance:", self.ch1_flow_thr_spin, "Iterations:", self.ch1_niter_spin
|
|
490
|
+
)
|
|
491
|
+
return CollapsibleSection("Channel 1 parameters", body, expanded=False)
|
|
492
|
+
|
|
493
|
+
def _build_ch2_params_section(self) -> CollapsibleSection:
|
|
494
|
+
# Channel 2 has no independent segmentation, but its Cellpose flow field
|
|
495
|
+
# (diameter / gamma) and the flow-following knobs both shape how its
|
|
496
|
+
# foreground is assigned to Channel-1 objects, so both live here. The
|
|
497
|
+
# tracking knobs live here too: they tune the Channel-1 tracker that the
|
|
498
|
+
# joint anchor (and Channel 1's own Track action) runs.
|
|
499
|
+
body = QWidget(self)
|
|
500
|
+
grid = section_grid()
|
|
501
|
+
grid.setContentsMargins(8, 4, 4, 4)
|
|
502
|
+
body.setLayout(grid)
|
|
503
|
+
self.ch2_diameter_spin = _dslider(0.0, 500.0, 0.0, 1.0, 1)
|
|
504
|
+
self.ch2_min_size_spin = _islider(0, 100000, 0)
|
|
505
|
+
self.ch2_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
|
|
506
|
+
self.ch2_fg_thr_spin = _dslider(0.0, 1.0, 0.5, 0.05, 2)
|
|
507
|
+
self.ch2_flow_weight_spin = _dslider(0.0, 1.0, 0.5, 0.05, 2)
|
|
508
|
+
self.ch2_radius_spin = _dslider(1.0, 200.0, 30.0, 1.0, 1)
|
|
509
|
+
self.track_max_dist_spin = _dslider(1.0, 200.0, 15.0, 1.0, 1)
|
|
510
|
+
self.track_gap_spin = _islider(0, 10, 0)
|
|
511
|
+
add_section_pair_row(
|
|
512
|
+
grid, 0, "Diameter:", self.ch2_diameter_spin, "Min size:", self.ch2_min_size_spin
|
|
513
|
+
)
|
|
514
|
+
add_section_pair_row(grid, 1, "Gamma:", self.ch2_gamma_spin)
|
|
515
|
+
add_section_pair_row(
|
|
516
|
+
grid, 2,
|
|
517
|
+
"FG threshold:", self.ch2_fg_thr_spin,
|
|
518
|
+
"Flow weight:", self.ch2_flow_weight_spin,
|
|
519
|
+
)
|
|
520
|
+
add_section_pair_row(grid, 3, "Max assign radius:", self.ch2_radius_spin)
|
|
521
|
+
add_section_pair_row(
|
|
522
|
+
grid, 4,
|
|
523
|
+
"Max distance:", self.track_max_dist_spin,
|
|
524
|
+
"Max frame gap:", self.track_gap_spin,
|
|
525
|
+
)
|
|
526
|
+
return CollapsibleSection("Channel 2 & tracking parameters", body, expanded=False)
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def _stage_label(text: str) -> QLabel:
|
|
530
|
+
return stage_header_label(QLabel(text), "cellpose")
|
|
531
|
+
|
|
532
|
+
@staticmethod
|
|
533
|
+
def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
|
|
534
|
+
row = QHBoxLayout()
|
|
535
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
536
|
+
row.setSpacing(4)
|
|
537
|
+
row.addWidget(label)
|
|
538
|
+
for w in trailing:
|
|
539
|
+
row.addWidget(w)
|
|
540
|
+
row.addStretch(1)
|
|
541
|
+
return row
|
|
542
|
+
|
|
543
|
+
# -------------------------------------------------------------- signals
|
|
544
|
+
def _connect_signals(self) -> None:
|
|
545
|
+
self.ch1_preview_btn.clicked.connect(lambda: self._on_ch1("preview"))
|
|
546
|
+
self.ch1_seg_btn.clicked.connect(lambda: self._on_ch1("seg"))
|
|
547
|
+
self.ch1_track_btn.clicked.connect(lambda: self._on_ch1("track"))
|
|
548
|
+
self.ch2_preview_btn.clicked.connect(lambda: self._on_ch2("preview"))
|
|
549
|
+
self.ch2_run_btn.clicked.connect(lambda: self._on_ch2("run"))
|
|
550
|
+
|
|
551
|
+
def _on_ch1(self, kind: str) -> None:
|
|
552
|
+
if self._running is not None:
|
|
553
|
+
self._on_cancel()
|
|
554
|
+
return
|
|
555
|
+
if kind == "seg":
|
|
556
|
+
self._run_segment()
|
|
557
|
+
elif kind == "track":
|
|
558
|
+
self._run_track()
|
|
559
|
+
else:
|
|
560
|
+
self._preview()
|
|
561
|
+
|
|
562
|
+
def _on_ch2(self, kind: str) -> None:
|
|
563
|
+
if self._running is not None:
|
|
564
|
+
self._on_cancel()
|
|
565
|
+
return
|
|
566
|
+
if kind == "preview":
|
|
567
|
+
self._preview_joint()
|
|
568
|
+
else:
|
|
569
|
+
self._run_joint()
|
|
570
|
+
|
|
571
|
+
def _on_cancel(self) -> None:
|
|
572
|
+
self._cancel_requested = True
|
|
573
|
+
worker = self._worker
|
|
574
|
+
if worker is not None and hasattr(worker, "quit"):
|
|
575
|
+
worker.quit()
|
|
576
|
+
|
|
577
|
+
# ------------------------------------------------------- source helpers
|
|
578
|
+
def _on_load_layer_ch1(self) -> None:
|
|
579
|
+
self._bind_active_layer(1)
|
|
580
|
+
|
|
581
|
+
def _on_load_layer_ch2(self) -> None:
|
|
582
|
+
self._bind_active_layer(2)
|
|
583
|
+
|
|
584
|
+
def _bind_active_layer(self, which: int) -> None:
|
|
585
|
+
"""Bind the viewer's active image layer as this channel's source.
|
|
586
|
+
|
|
587
|
+
The active layer must be an :class:`~napari.layers.Image`; anything else
|
|
588
|
+
(or nothing selected) leaves the channel untouched and just reports why.
|
|
589
|
+
"""
|
|
590
|
+
active = getattr(getattr(self.viewer.layers, "selection", None), "active", None)
|
|
591
|
+
if not isinstance(active, Image):
|
|
592
|
+
self._status("Select an image layer in the viewer, then click the pill.")
|
|
593
|
+
self._refresh_source_buttons(which)
|
|
594
|
+
return
|
|
595
|
+
self._set_channel_layer(which, active)
|
|
596
|
+
|
|
597
|
+
def _set_channel_layer(self, which: int, layer) -> None:
|
|
598
|
+
"""Make ``layer`` this channel's source (testable seam), then re-gate.
|
|
599
|
+
|
|
600
|
+
``None`` clears the channel. The pill's status light and the run buttons
|
|
601
|
+
follow from the binding via :meth:`_refresh_source_buttons` / the gate.
|
|
602
|
+
"""
|
|
603
|
+
if which == 1:
|
|
604
|
+
self._ch1_layer = layer
|
|
605
|
+
else:
|
|
606
|
+
self._ch2_layer = layer
|
|
607
|
+
self._refresh_source_buttons(which)
|
|
608
|
+
self.gate.recompute()
|
|
609
|
+
if layer is not None:
|
|
610
|
+
label = _CH1_LABEL if which == 1 else _CH2_LABEL
|
|
611
|
+
msg = f"{label} ← layer '{layer.name}'."
|
|
612
|
+
if which == 1:
|
|
613
|
+
msg += " Preview a frame or Segment the full stack."
|
|
614
|
+
self._status(msg)
|
|
615
|
+
|
|
616
|
+
def _refresh_source_buttons(self, which: int) -> None:
|
|
617
|
+
"""Mirror the channel's bound layer into its pill (status light + tooltip)."""
|
|
618
|
+
if which == 1:
|
|
619
|
+
layer_btn, layer, label = self.ch1_layer_btn, self._ch1_layer, _CH1_LABEL
|
|
620
|
+
else:
|
|
621
|
+
layer_btn, layer, label = self.ch2_layer_btn, self._ch2_layer, _CH2_LABEL
|
|
622
|
+
present = self._channel_present(which)
|
|
623
|
+
layer_btn.setChecked(present)
|
|
624
|
+
layer_btn.setToolTip(
|
|
625
|
+
f"{label} ← layer: {layer.name} (click to rebind; unlit if it is removed)"
|
|
626
|
+
if present else f"Load {label} from the active image layer."
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
def _connect_layer_events(self) -> None:
|
|
630
|
+
"""Track viewer layer add/remove so a pill darkens when its layer leaves."""
|
|
631
|
+
events = getattr(getattr(self.viewer.layers, "events", None), "removed", None)
|
|
632
|
+
if events is not None:
|
|
633
|
+
events.connect(self._on_layers_changed)
|
|
634
|
+
inserted = getattr(getattr(self.viewer.layers, "events", None), "inserted", None)
|
|
635
|
+
if inserted is not None:
|
|
636
|
+
inserted.connect(self._on_layers_changed)
|
|
637
|
+
# Reflect whatever is already bound (nothing, at construction).
|
|
638
|
+
self._on_layers_changed()
|
|
639
|
+
|
|
640
|
+
def _on_layers_changed(self, event=None) -> None:
|
|
641
|
+
"""Drop any binding whose layer has left the viewer, then refresh pills."""
|
|
642
|
+
for which in (1, 2):
|
|
643
|
+
layer = self._ch1_layer if which == 1 else self._ch2_layer
|
|
644
|
+
if layer is not None and not self._layer_in_viewer(layer):
|
|
645
|
+
if which == 1:
|
|
646
|
+
self._ch1_layer = None
|
|
647
|
+
else:
|
|
648
|
+
self._ch2_layer = None
|
|
649
|
+
self._refresh_source_buttons(which)
|
|
650
|
+
self.gate.recompute()
|
|
651
|
+
|
|
652
|
+
def _layer_in_viewer(self, layer) -> bool:
|
|
653
|
+
"""Whether ``layer`` is still present in the viewer (identity, not name)."""
|
|
654
|
+
if layer is None:
|
|
655
|
+
return False
|
|
656
|
+
layers = self.viewer.layers
|
|
657
|
+
try:
|
|
658
|
+
if layer in layers: # napari LayerList: identity membership
|
|
659
|
+
return True
|
|
660
|
+
except TypeError:
|
|
661
|
+
pass
|
|
662
|
+
try: # dict-like fakes are keyed by name
|
|
663
|
+
return layers[layer.name] is layer
|
|
664
|
+
except (KeyError, TypeError):
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
def _channel_source(self, which: int):
|
|
668
|
+
"""A channel's input as a canonical ``(T, Z, Y, X)`` array, else None."""
|
|
669
|
+
layer = self._ch1_layer if which == 1 else self._ch2_layer
|
|
670
|
+
if layer is not None and self._layer_in_viewer(layer):
|
|
671
|
+
return to_canonical_tzyx(np.asarray(layer.data))
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
def _channel_present(self, which: int) -> bool:
|
|
675
|
+
layer = self._ch1_layer if which == 1 else self._ch2_layer
|
|
676
|
+
return layer is not None and self._layer_in_viewer(layer)
|
|
677
|
+
|
|
678
|
+
# ------------------------------------------------------------- params
|
|
679
|
+
def _build_ch1_params(self) -> cellpose_runner.NucleusParams:
|
|
680
|
+
# The anchor uses the nucleus mask routine (clean, separable objects).
|
|
681
|
+
# Standalone segments every plane individually: do_3d is always off
|
|
682
|
+
# (anisotropy then unused) — true-3D segmentation is the app's domain.
|
|
683
|
+
return cellpose_runner.NucleusParams(
|
|
684
|
+
do_3d=False,
|
|
685
|
+
anisotropy=1.0,
|
|
686
|
+
diameter=float(self.ch1_diameter_spin.value()),
|
|
687
|
+
min_size=int(self.ch1_min_size_spin.value()),
|
|
688
|
+
gamma=float(self.ch1_gamma_spin.value()),
|
|
689
|
+
cellprob_threshold=_prob_to_cellprob(self.ch1_prob_thr_spin.value()),
|
|
690
|
+
flow_threshold=float(self.ch1_flow_thr_spin.value()),
|
|
691
|
+
niter=int(self.ch1_niter_spin.value()),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _build_ch2_params(self) -> cellpose_runner.CellParams:
|
|
695
|
+
return cellpose_runner.CellParams(
|
|
696
|
+
diameter=float(self.ch2_diameter_spin.value()),
|
|
697
|
+
min_size=int(self.ch2_min_size_spin.value()),
|
|
698
|
+
gamma=float(self.ch2_gamma_spin.value()),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def _build_flow_params(self) -> FlowFollowingParams:
|
|
702
|
+
return FlowFollowingParams(
|
|
703
|
+
fg_threshold=float(self.ch2_fg_thr_spin.value()),
|
|
704
|
+
flow_weight=float(self.ch2_flow_weight_spin.value()),
|
|
705
|
+
max_assign_radius=float(self.ch2_radius_spin.value()),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def _both_inputs(self) -> bool:
|
|
709
|
+
return self._channel_present(1) and self._channel_present(2)
|
|
710
|
+
|
|
711
|
+
# --------------------------------------------------------------- run: seg
|
|
712
|
+
def _run_segment(self) -> None:
|
|
713
|
+
"""Segment the whole Channel-1 stack, streaming each frame into the viewer.
|
|
714
|
+
|
|
715
|
+
Three layers — masks, the sigmoid prob map and the RGB flow — are created
|
|
716
|
+
up front (background) and filled frame-by-frame from a single eval per
|
|
717
|
+
plane, so the user watches results accrue instead of waiting for the end.
|
|
718
|
+
"""
|
|
719
|
+
source = self._channel_source(1)
|
|
720
|
+
if source is None:
|
|
721
|
+
self._status("Missing Channel 1 input.")
|
|
722
|
+
return
|
|
723
|
+
stack = np.asarray(source) # already canonical (T, Z, Y, X)
|
|
724
|
+
params = self._build_ch1_params()
|
|
725
|
+
self._cancel_requested = False
|
|
726
|
+
progress_signal = self._progress_signal
|
|
727
|
+
# Pre-allocate accumulators + show empty layers so frames stream into them.
|
|
728
|
+
self._init_stream(stack)
|
|
729
|
+
self._push_stream_layers()
|
|
730
|
+
names = (
|
|
731
|
+
self._stream["masks_name"],
|
|
732
|
+
self._stream["prob_name"],
|
|
733
|
+
self._stream["flow_name"],
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def _done(_result):
|
|
737
|
+
self._worker = None
|
|
738
|
+
self._set_running(None)
|
|
739
|
+
self._clear_progress()
|
|
740
|
+
self._stream = None
|
|
741
|
+
self._status(_segment_done_status(*names))
|
|
742
|
+
|
|
743
|
+
@thread_worker(connect={
|
|
744
|
+
"yielded": self._on_seg_frame, "returned": _done, "errored": self._errored,
|
|
745
|
+
"aborted": self._aborted,
|
|
746
|
+
})
|
|
747
|
+
def _worker():
|
|
748
|
+
yield from native_masks.iter_nucleus_maps_stack(
|
|
749
|
+
stack, params,
|
|
750
|
+
progress_cb=lambda d, tot, m: progress_signal.emit(int(d), int(tot), str(m)),
|
|
751
|
+
cancel_cb=lambda: self._cancel_requested,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
self._set_running("ch1_seg")
|
|
755
|
+
self._status(
|
|
756
|
+
f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
|
|
757
|
+
if not cellpose_runner.is_model_loaded()
|
|
758
|
+
else "Segmenting Channel 1..."
|
|
759
|
+
)
|
|
760
|
+
self._worker = _worker()
|
|
761
|
+
|
|
762
|
+
def _init_stream(self, stack: np.ndarray) -> None:
|
|
763
|
+
"""(Re)allocate the Channel 1 masks/prob/flow accumulators, sized to ``stack``.
|
|
764
|
+
|
|
765
|
+
Shared by :meth:`_run_segment` (fills every frame) and :meth:`_preview`
|
|
766
|
+
(fills one frame at a time on first use) so both write into — and
|
|
767
|
+
display — the very same persistent layers. If those layers already hold
|
|
768
|
+
data from a prior full **Segment** run (``self._stream`` is cleared on
|
|
769
|
+
completion, but the layers survive), that data seeds the new
|
|
770
|
+
accumulators instead of zeros — otherwise a lone "segment this frame"
|
|
771
|
+
after a full run would blank every other frame the instant it repaints
|
|
772
|
+
the layers, before its own single-frame result even comes back.
|
|
773
|
+
"""
|
|
774
|
+
T, Z, Y, X = (int(s) for s in stack.shape)
|
|
775
|
+
masks_name = _layer_name(_CH1_LABEL, "masks")
|
|
776
|
+
prob_name = _layer_name(_CH1_LABEL, "prob")
|
|
777
|
+
flow_name = _layer_name(_CH1_LABEL, "flow")
|
|
778
|
+
self._stream = {
|
|
779
|
+
"masks": self._existing_layer_array(masks_name, (T, Z, Y, X), np.int32),
|
|
780
|
+
"prob": self._existing_layer_array(prob_name, (T, Z, Y, X), np.float32),
|
|
781
|
+
"flow": self._existing_layer_array(flow_name, (T, Z, Y, X, 3), np.uint8),
|
|
782
|
+
"masks_name": masks_name,
|
|
783
|
+
"prob_name": prob_name,
|
|
784
|
+
"flow_name": flow_name,
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
def _existing_layer_array(
|
|
788
|
+
self, name: str, shape: tuple[int, ...], dtype: np.dtype
|
|
789
|
+
) -> np.ndarray:
|
|
790
|
+
"""Reuse ``name``'s current viewer data if it matches ``shape``, else zeros.
|
|
791
|
+
|
|
792
|
+
Layer data may have a singleton Z squeezed out for display (see
|
|
793
|
+
:func:`_squeeze_z`); that's undone before the shape check.
|
|
794
|
+
"""
|
|
795
|
+
layer = self.viewer.layers[name] if name in self.viewer.layers else None
|
|
796
|
+
if layer is not None:
|
|
797
|
+
arr = np.asarray(layer.data)
|
|
798
|
+
if arr.ndim == len(shape) - 1:
|
|
799
|
+
arr = arr[:, np.newaxis]
|
|
800
|
+
if arr.shape == shape:
|
|
801
|
+
return np.asarray(arr, dtype=dtype).copy()
|
|
802
|
+
return np.zeros(shape, dtype=dtype)
|
|
803
|
+
|
|
804
|
+
def _on_seg_frame(self, payload) -> None:
|
|
805
|
+
"""Write one streamed frame into the accumulators and refresh the layers."""
|
|
806
|
+
st = self._stream
|
|
807
|
+
if st is None or not isinstance(payload, tuple) or len(payload) != 4:
|
|
808
|
+
return
|
|
809
|
+
t, masks, prob, flow = payload
|
|
810
|
+
st["masks"][t] = masks
|
|
811
|
+
st["prob"][t] = prob
|
|
812
|
+
st["flow"][t] = flow
|
|
813
|
+
self._push_stream_layers()
|
|
814
|
+
self._show_frame(t)
|
|
815
|
+
|
|
816
|
+
def _show_frame(self, t: int) -> None:
|
|
817
|
+
"""Move the viewer's frame slider to ``t`` so the just-streamed frame shows.
|
|
818
|
+
|
|
819
|
+
Only the leading (time) axis is touched; any remaining slider positions
|
|
820
|
+
(e.g. Z) are preserved. napari clamps out-of-range values itself.
|
|
821
|
+
"""
|
|
822
|
+
dims = getattr(self.viewer, "dims", None)
|
|
823
|
+
step = list(getattr(dims, "current_step", ()) or ())
|
|
824
|
+
if not step:
|
|
825
|
+
return
|
|
826
|
+
step[0] = int(t)
|
|
827
|
+
dims.current_step = tuple(step)
|
|
828
|
+
|
|
829
|
+
def _push_stream_layers(self) -> None:
|
|
830
|
+
st = self._stream
|
|
831
|
+
if st is None:
|
|
832
|
+
return
|
|
833
|
+
# napari stacks each newly-added layer above the ones already present,
|
|
834
|
+
# so adding flow, then prob, then masks last leaves them ordered top to
|
|
835
|
+
# bottom as masks / prob / flow / input.
|
|
836
|
+
self._add_image(st["flow_name"], st["flow"])
|
|
837
|
+
self._add_image(st["prob_name"], st["prob"])
|
|
838
|
+
self._add_labels(st["masks_name"], st["masks"])
|
|
839
|
+
|
|
840
|
+
# ------------------------------------------------------------- run: track
|
|
841
|
+
def _run_track(self) -> None:
|
|
842
|
+
masks_name = _layer_name(_CH1_LABEL, "masks")
|
|
843
|
+
if masks_name not in self.viewer.layers:
|
|
844
|
+
self._status(f"No '{masks_name}' layer — segment first.")
|
|
845
|
+
return
|
|
846
|
+
masks = _to_tzyx(np.asarray(self.viewer.layers[masks_name].data))
|
|
847
|
+
max_distance = float(self.track_max_dist_spin.value())
|
|
848
|
+
max_frame_gap = int(self.track_gap_spin.value())
|
|
849
|
+
|
|
850
|
+
def _done(result):
|
|
851
|
+
self._worker = None
|
|
852
|
+
self._set_running(None)
|
|
853
|
+
self._clear_progress()
|
|
854
|
+
self._add_labels(masks_name, result)
|
|
855
|
+
self._status(_track_done_status(masks_name))
|
|
856
|
+
|
|
857
|
+
@thread_worker(connect={
|
|
858
|
+
"yielded": self._on_progress, "returned": _done, "errored": self._errored,
|
|
859
|
+
"aborted": self._aborted,
|
|
860
|
+
})
|
|
861
|
+
def _worker():
|
|
862
|
+
yield (0, 1, "Tracking Channel 1 masks...")
|
|
863
|
+
return track_channel(
|
|
864
|
+
masks, max_distance=max_distance, max_frame_gap=max_frame_gap,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
self._set_running("ch1_track")
|
|
868
|
+
self._status("Tracking Channel 1 masks (laptrack)...")
|
|
869
|
+
self._worker = _worker()
|
|
870
|
+
|
|
871
|
+
# --------------------------------------------------------------- run: joint
|
|
872
|
+
def _run_joint(self) -> None:
|
|
873
|
+
if not self._both_inputs():
|
|
874
|
+
self._status("Joint mode needs both Channel 1 and Channel 2 inputs.")
|
|
875
|
+
return
|
|
876
|
+
ch1_source = self._channel_source(1)
|
|
877
|
+
ch2_source = self._channel_source(2)
|
|
878
|
+
ch1_params = self._build_ch1_params()
|
|
879
|
+
ch2_params = self._build_ch2_params()
|
|
880
|
+
flow_params = self._build_flow_params()
|
|
881
|
+
max_distance = float(self.track_max_dist_spin.value())
|
|
882
|
+
max_frame_gap = int(self.track_gap_spin.value())
|
|
883
|
+
self._cancel_requested = False
|
|
884
|
+
progress_signal = self._progress_signal
|
|
885
|
+
ch1_name = _layer_name(_CH1_LABEL, "tracked")
|
|
886
|
+
ch2_name = _layer_name(_CH2_LABEL, "tracked")
|
|
887
|
+
|
|
888
|
+
def _done(result):
|
|
889
|
+
ch1_tracked, ch2_tracked = result
|
|
890
|
+
self._worker = None
|
|
891
|
+
self._set_running(None)
|
|
892
|
+
self._clear_progress()
|
|
893
|
+
self._add_labels(ch1_name, ch1_tracked)
|
|
894
|
+
self._add_labels(ch2_name, ch2_tracked)
|
|
895
|
+
self._status(
|
|
896
|
+
f"Joint → '{ch1_name}' + '{ch2_name}' (paired ids). Save from the layers."
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
@thread_worker(connect={
|
|
900
|
+
"yielded": self._on_progress, "returned": _done, "errored": self._errored,
|
|
901
|
+
"aborted": self._aborted,
|
|
902
|
+
})
|
|
903
|
+
def _worker():
|
|
904
|
+
yield (0, 4, "Loading inputs...")
|
|
905
|
+
return segment_track_joint(
|
|
906
|
+
ch1_source, ch2_source,
|
|
907
|
+
ch1_params, ch2_params, flow_params,
|
|
908
|
+
max_distance=max_distance, max_frame_gap=max_frame_gap,
|
|
909
|
+
progress_cb=lambda d, t, m: progress_signal.emit(int(d), int(t), str(m)),
|
|
910
|
+
cancel_cb=lambda: self._cancel_requested,
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
self._set_running("ch2_run")
|
|
914
|
+
self._status(
|
|
915
|
+
f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
|
|
916
|
+
if not cellpose_runner.is_model_loaded()
|
|
917
|
+
else "Running joint segmentation..."
|
|
918
|
+
)
|
|
919
|
+
self._worker = _worker()
|
|
920
|
+
|
|
921
|
+
# ------------------------------------------------------- preview: joint
|
|
922
|
+
def _preview_joint(self) -> None:
|
|
923
|
+
if not self._both_inputs():
|
|
924
|
+
self._status("Joint preview needs both Channel 1 and Channel 2 inputs.")
|
|
925
|
+
return
|
|
926
|
+
ch1_source = self._channel_source(1)
|
|
927
|
+
ch2_source = self._channel_source(2)
|
|
928
|
+
ch1_params = self._build_ch1_params()
|
|
929
|
+
ch2_params = self._build_ch2_params()
|
|
930
|
+
flow_params = self._build_flow_params()
|
|
931
|
+
max_distance = float(self.track_max_dist_spin.value())
|
|
932
|
+
max_frame_gap = int(self.track_gap_spin.value())
|
|
933
|
+
self._cancel_requested = False
|
|
934
|
+
progress_signal = self._progress_signal
|
|
935
|
+
try:
|
|
936
|
+
ch1_stack = to_canonical_tzyx(
|
|
937
|
+
ch1_source if isinstance(ch1_source, np.ndarray)
|
|
938
|
+
else np.asarray(tifffile.imread(str(ch1_source)))
|
|
939
|
+
)
|
|
940
|
+
ch2_stack = to_canonical_tzyx(
|
|
941
|
+
ch2_source if isinstance(ch2_source, np.ndarray)
|
|
942
|
+
else np.asarray(tifffile.imread(str(ch2_source)))
|
|
943
|
+
)
|
|
944
|
+
except Exception as exc:
|
|
945
|
+
self._status(f"Error: {exc}")
|
|
946
|
+
logger.exception("joint preview load error", exc_info=exc)
|
|
947
|
+
return
|
|
948
|
+
# Show Channel 2 underneath so the previewed assignment is visible.
|
|
949
|
+
self._add_image(_layer_name(_CH2_LABEL, "image"), ch2_stack)
|
|
950
|
+
t, _z = self._current_tz(int(ch1_stack.shape[0]), int(ch1_stack.shape[1]))
|
|
951
|
+
preview_name = _layer_name(_CH2_LABEL, "preview")
|
|
952
|
+
|
|
953
|
+
def _done(result):
|
|
954
|
+
self._worker = None
|
|
955
|
+
self._set_running(None)
|
|
956
|
+
self._clear_progress()
|
|
957
|
+
self._add_labels(preview_name, result)
|
|
958
|
+
self._status(
|
|
959
|
+
f"Channel 2 joint preview (frame t={t}) → '{preview_name}'."
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
@thread_worker(connect={
|
|
963
|
+
"yielded": self._on_progress, "returned": _done, "errored": self._errored,
|
|
964
|
+
"aborted": self._aborted,
|
|
965
|
+
})
|
|
966
|
+
def _worker():
|
|
967
|
+
yield (0, 4, "Previewing joint...")
|
|
968
|
+
return preview_joint(
|
|
969
|
+
ch1_stack, ch2_stack, ch1_params, ch2_params, flow_params, t,
|
|
970
|
+
max_distance=max_distance, max_frame_gap=max_frame_gap,
|
|
971
|
+
progress_cb=lambda d, tot, m: progress_signal.emit(int(d), int(tot), str(m)),
|
|
972
|
+
cancel_cb=lambda: self._cancel_requested,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
self._set_running("ch2_preview")
|
|
976
|
+
self._status(
|
|
977
|
+
f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
|
|
978
|
+
if not cellpose_runner.is_model_loaded()
|
|
979
|
+
else "Previewing joint..."
|
|
980
|
+
)
|
|
981
|
+
self._worker = _worker()
|
|
982
|
+
|
|
983
|
+
# ----------------------------------------------------------- run: preview
|
|
984
|
+
def _preview(self) -> None:
|
|
985
|
+
"""Segment Channel 1's current frame in place ("segment this frame").
|
|
986
|
+
|
|
987
|
+
Writes straight into the same masks / prob / flow layers **Segment**
|
|
988
|
+
fills — lazily initializing them (full-stack, background elsewhere) on
|
|
989
|
+
first use if no full run has happened yet — instead of spawning
|
|
990
|
+
disposable ``… preview`` layers. So the diameter / min-size / gamma /
|
|
991
|
+
prob-threshold can be tuned one frame at a time on the real stack
|
|
992
|
+
before (or while) committing the rest with **Segment**.
|
|
993
|
+
"""
|
|
994
|
+
source = self._channel_source(1)
|
|
995
|
+
if source is None:
|
|
996
|
+
self._status("Missing Channel 1 input.")
|
|
997
|
+
return
|
|
998
|
+
params = self._build_ch1_params()
|
|
999
|
+
self._cancel_requested = False
|
|
1000
|
+
stack = np.asarray(source) # already canonical (T, Z, Y, X)
|
|
1001
|
+
t, z = self._current_tz(int(stack.shape[0]), int(stack.shape[1]))
|
|
1002
|
+
if self._stream is None:
|
|
1003
|
+
self._init_stream(stack)
|
|
1004
|
+
self._push_stream_layers()
|
|
1005
|
+
st = self._stream
|
|
1006
|
+
names = (st["masks_name"], st["prob_name"], st["flow_name"])
|
|
1007
|
+
|
|
1008
|
+
def _done(result):
|
|
1009
|
+
masks, prob, flow = result
|
|
1010
|
+
self._worker = None
|
|
1011
|
+
self._set_running(None)
|
|
1012
|
+
self._clear_progress()
|
|
1013
|
+
st["masks"][t, z] = masks
|
|
1014
|
+
st["prob"][t, z] = prob
|
|
1015
|
+
st["flow"][t, z] = flow
|
|
1016
|
+
self._push_stream_layers()
|
|
1017
|
+
self._status(
|
|
1018
|
+
f"Channel 1 frame t={t} segmented → '{names[0]}', '{names[1]}', "
|
|
1019
|
+
f"'{names[2]}'."
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
@thread_worker(connect={
|
|
1023
|
+
"yielded": self._on_progress, "returned": _done, "errored": self._errored,
|
|
1024
|
+
"aborted": self._aborted,
|
|
1025
|
+
})
|
|
1026
|
+
def _worker():
|
|
1027
|
+
yield (0, 0, f"Segmenting frame on {cellpose_runner.device_label()}...")
|
|
1028
|
+
return native_masks.run_nucleus_maps_frame(stack[t], z=z, params=params)
|
|
1029
|
+
|
|
1030
|
+
self._set_running("ch1_preview")
|
|
1031
|
+
self._status(
|
|
1032
|
+
f"Loading Cellpose-SAM on {cellpose_runner.device_label()}..."
|
|
1033
|
+
if not cellpose_runner.is_model_loaded()
|
|
1034
|
+
else "Segmenting current frame..."
|
|
1035
|
+
)
|
|
1036
|
+
self._worker = _worker()
|
|
1037
|
+
|
|
1038
|
+
def _current_tz(self, n_t: int, n_z: int) -> tuple[int, int]:
|
|
1039
|
+
"""Current (t, z) from the viewer, clamped; z is 0 for single-slice data."""
|
|
1040
|
+
step = getattr(getattr(self.viewer, "dims", None), "current_step", (0, 0))
|
|
1041
|
+
t = int(step[0]) if len(step) >= 1 else 0
|
|
1042
|
+
z = 0 if n_z <= 1 else (int(step[1]) if len(step) >= 2 else 0)
|
|
1043
|
+
return min(max(t, 0), n_t - 1), min(max(z, 0), n_z - 1)
|
|
1044
|
+
|
|
1045
|
+
def _active_labels_layer(self):
|
|
1046
|
+
"""The viewer's active layer iff it is a Labels layer, else None.
|
|
1047
|
+
|
|
1048
|
+
The embedded corrector binds to this; returning None when the active
|
|
1049
|
+
layer is not labels is what enforces the "must be a Labels layer" scope.
|
|
1050
|
+
"""
|
|
1051
|
+
layer = getattr(getattr(self.viewer.layers, "selection", None), "active", None)
|
|
1052
|
+
return layer if isinstance(layer, Labels) else None
|
|
1053
|
+
|
|
1054
|
+
def _spawn_intensity_frame(self, t: int) -> np.ndarray | None:
|
|
1055
|
+
"""Signal frame for spawn-snapping: the prob layer sharing the corrected
|
|
1056
|
+
layer's ``[Tag]`` (e.g. Channel 1's masks/tracked layer → its own prob
|
|
1057
|
+
map). Channel 2 has no prob layer of its own (it is never segmented
|
|
1058
|
+
independently), so spawning there falls back to a plain disk.
|
|
1059
|
+
"""
|
|
1060
|
+
layer = self.cell_correction._correction_tracked_layer()
|
|
1061
|
+
if layer is None or not layer.name.startswith("["):
|
|
1062
|
+
return None
|
|
1063
|
+
tag = layer.name.split("]", 1)[0] + "]"
|
|
1064
|
+
prob_name = f"{tag} prob"
|
|
1065
|
+
if prob_name not in self.viewer.layers:
|
|
1066
|
+
return None
|
|
1067
|
+
data = np.asarray(self.viewer.layers[prob_name].data)
|
|
1068
|
+
return frame_view_2d(data, int(t))
|
|
1069
|
+
|
|
1070
|
+
def _errored(self, exc) -> None:
|
|
1071
|
+
self._worker = None
|
|
1072
|
+
self._set_running(None)
|
|
1073
|
+
self._clear_progress()
|
|
1074
|
+
# Keep whatever streamed so far visible, but stop accumulating into it.
|
|
1075
|
+
self._stream = None
|
|
1076
|
+
if isinstance(exc, cellpose_runner.CancelledError):
|
|
1077
|
+
self._status("Cancelled.")
|
|
1078
|
+
else:
|
|
1079
|
+
self._status(f"Error: {exc}")
|
|
1080
|
+
logger.exception("segment/track error", exc_info=exc)
|
|
1081
|
+
|
|
1082
|
+
def _aborted(self) -> None:
|
|
1083
|
+
"""Return the UI to idle when a run is cancelled mid-flight.
|
|
1084
|
+
|
|
1085
|
+
``_on_cancel`` calls ``worker.quit()``, and napari answers a quit with
|
|
1086
|
+
the ``aborted`` signal — not ``returned`` or ``errored`` — so without
|
|
1087
|
+
this handler the run buttons stay disabled and the progress bar stays
|
|
1088
|
+
frozen after Cancel. Mirrors the cancel branch of :meth:`_errored`.
|
|
1089
|
+
"""
|
|
1090
|
+
self._worker = None
|
|
1091
|
+
self._set_running(None)
|
|
1092
|
+
self._clear_progress()
|
|
1093
|
+
self._stream = None
|
|
1094
|
+
self._status("Cancelled.")
|
|
1095
|
+
|
|
1096
|
+
# ------------------------------------------------------ layer output
|
|
1097
|
+
def _add_labels(self, name: str, data) -> None:
|
|
1098
|
+
arr = _squeeze_z(np.asarray(data)).astype(np.int32, copy=False)
|
|
1099
|
+
self._show_in_viewer(name, arr, self.viewer.add_labels)
|
|
1100
|
+
|
|
1101
|
+
def _add_image(self, name: str, data) -> None:
|
|
1102
|
+
arr = _squeeze_z(np.asarray(data))
|
|
1103
|
+
self._show_in_viewer(name, arr, self.viewer.add_image)
|
|
1104
|
+
|
|
1105
|
+
def _show_in_viewer(self, name: str, data, adder) -> None:
|
|
1106
|
+
if name in self.viewer.layers:
|
|
1107
|
+
try:
|
|
1108
|
+
self.viewer.layers[name].data = data
|
|
1109
|
+
return
|
|
1110
|
+
except Exception:
|
|
1111
|
+
self.viewer.layers.remove(self.viewer.layers[name])
|
|
1112
|
+
adder(data, name=name)
|
|
1113
|
+
|
|
1114
|
+
# ---------------------------------------------------------- public API
|
|
1115
|
+
def get_state(self) -> dict:
|
|
1116
|
+
return {
|
|
1117
|
+
"channel1": {
|
|
1118
|
+
"diameter": self.ch1_diameter_spin.value(),
|
|
1119
|
+
"min_size": self.ch1_min_size_spin.value(),
|
|
1120
|
+
"gamma": self.ch1_gamma_spin.value(),
|
|
1121
|
+
"prob_threshold": self.ch1_prob_thr_spin.value(),
|
|
1122
|
+
"flow_threshold": self.ch1_flow_thr_spin.value(),
|
|
1123
|
+
"niter": self.ch1_niter_spin.value(),
|
|
1124
|
+
},
|
|
1125
|
+
"channel2": {
|
|
1126
|
+
"diameter": self.ch2_diameter_spin.value(),
|
|
1127
|
+
"min_size": self.ch2_min_size_spin.value(),
|
|
1128
|
+
"gamma": self.ch2_gamma_spin.value(),
|
|
1129
|
+
"fg_threshold": self.ch2_fg_thr_spin.value(),
|
|
1130
|
+
"flow_weight": self.ch2_flow_weight_spin.value(),
|
|
1131
|
+
"max_assign_radius": self.ch2_radius_spin.value(),
|
|
1132
|
+
},
|
|
1133
|
+
"tracking": {
|
|
1134
|
+
"max_distance": self.track_max_dist_spin.value(),
|
|
1135
|
+
"max_frame_gap": self.track_gap_spin.value(),
|
|
1136
|
+
},
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
# -------------------------------------------------------- state helpers
|
|
1140
|
+
def _status(self, msg: str) -> None:
|
|
1141
|
+
self.status_lbl.setText(msg)
|
|
1142
|
+
self.status_lbl.setVisible(bool(msg))
|
|
1143
|
+
if msg:
|
|
1144
|
+
logger.info(msg)
|
|
1145
|
+
|
|
1146
|
+
def _progress(self, done: int, total: int, msg: str) -> None:
|
|
1147
|
+
self.progress_bar.setVisible(True)
|
|
1148
|
+
self.progress_bar.setRange(0, total)
|
|
1149
|
+
self.progress_bar.setValue(done)
|
|
1150
|
+
self._status(msg)
|
|
1151
|
+
|
|
1152
|
+
def _on_progress(self, data) -> None:
|
|
1153
|
+
if isinstance(data, tuple):
|
|
1154
|
+
self._progress(*data)
|
|
1155
|
+
else:
|
|
1156
|
+
self._status(str(data))
|
|
1157
|
+
|
|
1158
|
+
def _clear_progress(self) -> None:
|
|
1159
|
+
self.progress_bar.setValue(0)
|
|
1160
|
+
self.progress_bar.setVisible(False)
|
|
1161
|
+
|
|
1162
|
+
def _joint_buttons(self):
|
|
1163
|
+
return (self.ch2_preview_btn, self.ch2_run_btn)
|
|
1164
|
+
|
|
1165
|
+
def _ch1_masks_available(self) -> bool:
|
|
1166
|
+
return _layer_name(_CH1_LABEL, "masks") in self.viewer.layers
|
|
1167
|
+
|
|
1168
|
+
def _ch1_track_reason(self) -> str:
|
|
1169
|
+
if not self._channel_present(1):
|
|
1170
|
+
return "Bind Channel 1 first — click ⧉."
|
|
1171
|
+
return "Segment Channel 1 first."
|
|
1172
|
+
|
|
1173
|
+
def _ch2_joint_reason(self) -> str:
|
|
1174
|
+
if not self._channel_present(1):
|
|
1175
|
+
return "Bind Channel 1 first."
|
|
1176
|
+
return "Bind Channel 2 for joint segmentation."
|
|
1177
|
+
|
|
1178
|
+
def _register_gate_controls(self) -> None:
|
|
1179
|
+
g = self.gate
|
|
1180
|
+
|
|
1181
|
+
def _own(btn) -> bool:
|
|
1182
|
+
return self._running is None or self._active_btn() is btn
|
|
1183
|
+
|
|
1184
|
+
g.register(self.ch1_params_btn, ControlClass.HARMLESS)
|
|
1185
|
+
g.register(self.ch2_params_btn, ControlClass.HARMLESS)
|
|
1186
|
+
g.register(
|
|
1187
|
+
self.ch1_preview_btn, ControlClass.RUN_VIEWER,
|
|
1188
|
+
when=lambda: _own(self.ch1_preview_btn) and self._channel_present(1),
|
|
1189
|
+
reason="Bind Channel 1 first — click ⧉.",
|
|
1190
|
+
)
|
|
1191
|
+
g.register(
|
|
1192
|
+
self.ch1_seg_btn, ControlClass.RUN_VIEWER,
|
|
1193
|
+
when=lambda: _own(self.ch1_seg_btn) and self._channel_present(1),
|
|
1194
|
+
reason="Bind Channel 1 first — click ⧉.",
|
|
1195
|
+
)
|
|
1196
|
+
g.register(
|
|
1197
|
+
self.ch1_track_btn, ControlClass.RUN_VIEWER,
|
|
1198
|
+
when=lambda: _own(self.ch1_track_btn) and self._ch1_masks_available(),
|
|
1199
|
+
reason=self._ch1_track_reason,
|
|
1200
|
+
)
|
|
1201
|
+
# Channel 2's actions (preview + run) are joint-only: both require both
|
|
1202
|
+
# inputs to be present.
|
|
1203
|
+
for btn in self._joint_buttons():
|
|
1204
|
+
g.register(
|
|
1205
|
+
btn, ControlClass.RUN_VIEWER,
|
|
1206
|
+
when=lambda b=btn: _own(b) and self._both_inputs(),
|
|
1207
|
+
reason=self._ch2_joint_reason,
|
|
1208
|
+
)
|
|
1209
|
+
g.recompute()
|
|
1210
|
+
|
|
1211
|
+
def _btn_for_key(self):
|
|
1212
|
+
return {
|
|
1213
|
+
"ch1_preview": self.ch1_preview_btn,
|
|
1214
|
+
"ch1_seg": self.ch1_seg_btn,
|
|
1215
|
+
"ch1_track": self.ch1_track_btn,
|
|
1216
|
+
"ch2_preview": self.ch2_preview_btn,
|
|
1217
|
+
"ch2_run": self.ch2_run_btn,
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
def _active_btn(self):
|
|
1221
|
+
return self._btn_for_key().get(self._running)
|
|
1222
|
+
|
|
1223
|
+
_DEFAULT_GLYPHS = {
|
|
1224
|
+
"ch1_preview": "▷", "ch1_seg": "▶", "ch1_track": "⊳",
|
|
1225
|
+
"ch2_preview": "▷", "ch2_run": "▶",
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
def _set_running(self, key: str | None) -> None:
|
|
1229
|
+
# restore all glyphs first
|
|
1230
|
+
for k, btn in self._btn_for_key().items():
|
|
1231
|
+
btn.setText(self._DEFAULT_GLYPHS[k])
|
|
1232
|
+
self._running = key
|
|
1233
|
+
if key is None:
|
|
1234
|
+
self._cancel_requested = False
|
|
1235
|
+
else:
|
|
1236
|
+
btn = self._active_btn()
|
|
1237
|
+
if btn is not None:
|
|
1238
|
+
btn.setText("✕")
|
|
1239
|
+
btn.setToolTip("Cancel.")
|
|
1240
|
+
self.gate.recompute()
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def make_cellpose_segment_track_widget(napari_viewer=None):
|
|
1244
|
+
"""napari plugin factory for the standalone Cellpose segment+track tool."""
|
|
1245
|
+
try:
|
|
1246
|
+
from itasc.napari._napari_compat import patch_napari_layer_delegate
|
|
1247
|
+
|
|
1248
|
+
patch_napari_layer_delegate()
|
|
1249
|
+
except Exception:
|
|
1250
|
+
pass
|
|
1251
|
+
if napari_viewer is None:
|
|
1252
|
+
napari_viewer = napari.current_viewer()
|
|
1253
|
+
return CellposeSegmentTrackWidget(napari_viewer)
|