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,648 @@
|
|
|
1
|
+
# src/itasc/napari/nucleus_atom_extraction_widget.py
|
|
2
|
+
"""Atom Extraction section for the nucleus workflow widget (stage ①)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import tifffile
|
|
10
|
+
from napari.qt.threading import thread_worker
|
|
11
|
+
from qtpy.QtCore import QTimer
|
|
12
|
+
from qtpy.QtWidgets import (
|
|
13
|
+
QCheckBox,
|
|
14
|
+
QHBoxLayout,
|
|
15
|
+
QLabel,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from itasc.napari._preview_cache import FramePreviewCache
|
|
21
|
+
from itasc.napari._widget_helpers import (
|
|
22
|
+
dslider as _dslider,
|
|
23
|
+
islider as _islider,
|
|
24
|
+
tool_btn as _tool_btn,
|
|
25
|
+
)
|
|
26
|
+
from itasc.napari.ui_style import (
|
|
27
|
+
add_section_pair_row,
|
|
28
|
+
section_grid,
|
|
29
|
+
stage_header_action_button as _stage_header_action_button,
|
|
30
|
+
stage_header_label as _stage_header_label,
|
|
31
|
+
)
|
|
32
|
+
from itasc.napari.ui_gate import ControlClass
|
|
33
|
+
from itasc.napari.widgets import CollapsibleSection
|
|
34
|
+
from itasc.tracking_ultrack.atoms import (
|
|
35
|
+
AtomParams,
|
|
36
|
+
extract_atoms_frame,
|
|
37
|
+
extract_atoms_stack_with_maps,
|
|
38
|
+
residual,
|
|
39
|
+
write_atoms_tif,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_ATOM_PREFIX = "[Atoms]"
|
|
45
|
+
_ATOM_PREVIEW_LAYER = f"{_ATOM_PREFIX} atoms"
|
|
46
|
+
_ATOM_TERRITORY_LAYER = f"{_ATOM_PREFIX} territory"
|
|
47
|
+
_ATOM_FG_RESIDUAL_LAYER = f"{_ATOM_PREFIX} residual_foreground"
|
|
48
|
+
_ATOM_CONTOUR_RESIDUAL_LAYER = f"{_ATOM_PREFIX} residual_contour"
|
|
49
|
+
_ATOM_RIDGE_LAYER = f"{_ATOM_PREFIX} ridge"
|
|
50
|
+
|
|
51
|
+
_ATOM_MASK_OPACITY = 0.7
|
|
52
|
+
|
|
53
|
+
# The two tuning stages, each with its own layers. Each group's visibility
|
|
54
|
+
# checkbox flips exactly the layers it owns. The Foreground residual
|
|
55
|
+
# (→ territory) and the Contour residual (→ ridge → atoms watershed).
|
|
56
|
+
_ATOM_FG_GROUP_LAYERS = (_ATOM_FG_RESIDUAL_LAYER, _ATOM_TERRITORY_LAYER)
|
|
57
|
+
_ATOM_CONTOUR_GROUP_LAYERS = (
|
|
58
|
+
_ATOM_CONTOUR_RESIDUAL_LAYER,
|
|
59
|
+
_ATOM_RIDGE_LAYER,
|
|
60
|
+
_ATOM_PREVIEW_LAYER,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Fixed stack order, bottom → top: each mask sits directly above the residual
|
|
64
|
+
# image it is judged against. Also the add order, so napari stacks them this
|
|
65
|
+
# way on first creation.
|
|
66
|
+
_ATOM_LAYERS = (
|
|
67
|
+
_ATOM_FG_RESIDUAL_LAYER,
|
|
68
|
+
_ATOM_TERRITORY_LAYER,
|
|
69
|
+
_ATOM_CONTOUR_RESIDUAL_LAYER,
|
|
70
|
+
_ATOM_RIDGE_LAYER,
|
|
71
|
+
_ATOM_PREVIEW_LAYER,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# The residual maps are napari Image layers; the rest are Labels.
|
|
75
|
+
_ATOM_IMAGE_LAYERS = (_ATOM_FG_RESIDUAL_LAYER, _ATOM_CONTOUR_RESIDUAL_LAYER)
|
|
76
|
+
|
|
77
|
+
# Single-frame compute levels, in increasing cost. The desired level is the max
|
|
78
|
+
# over the ticked Compute checkboxes; a cached frame is reused only when its
|
|
79
|
+
# stored level already covers the desired one. The contour stage's atom
|
|
80
|
+
# watershed runs on the foreground territory, so it implies the FG compute.
|
|
81
|
+
_ATOM_LEVEL_NONE = 0
|
|
82
|
+
_ATOM_LEVEL_FG = 1 # residual_foreground + territory (cheap)
|
|
83
|
+
_ATOM_LEVEL_CONTOUR = 2 # + residual_contour + ridge + atoms (heavier)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NucleusAtomExtractionWidget(QWidget):
|
|
87
|
+
"""Qt controls for tuning atom extraction with a live preview."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
90
|
+
super().__init__(parent)
|
|
91
|
+
|
|
92
|
+
self.header = QWidget(parent)
|
|
93
|
+
header_lay = QHBoxLayout(self.header)
|
|
94
|
+
header_lay.setContentsMargins(0, 0, 0, 0)
|
|
95
|
+
header_lay.setSpacing(4)
|
|
96
|
+
self.header_lbl = QLabel("Atom Extraction")
|
|
97
|
+
_stage_header_label(self.header_lbl, "nucleus")
|
|
98
|
+
self.params_btn = _tool_btn(
|
|
99
|
+
"⚙", "Toggle atom extraction parameters.", checkable=True
|
|
100
|
+
)
|
|
101
|
+
self.params_btn.setChecked(False)
|
|
102
|
+
_stage_header_action_button(self.params_btn, "nucleus")
|
|
103
|
+
self.active_btn = _tool_btn(
|
|
104
|
+
"◉", "Live atom preview (tune against the current frame).", checkable=True
|
|
105
|
+
)
|
|
106
|
+
self.active_btn.setChecked(False)
|
|
107
|
+
_stage_header_action_button(self.active_btn, "nucleus")
|
|
108
|
+
self.run_btn = _tool_btn(
|
|
109
|
+
"▶", "Compute atoms for all frames, show them, and write atoms.tif."
|
|
110
|
+
)
|
|
111
|
+
_stage_header_action_button(self.run_btn, "nucleus")
|
|
112
|
+
header_lay.addWidget(self.header_lbl)
|
|
113
|
+
header_lay.addWidget(self.params_btn)
|
|
114
|
+
header_lay.addWidget(self.active_btn)
|
|
115
|
+
header_lay.addWidget(self.run_btn)
|
|
116
|
+
header_lay.addStretch(1)
|
|
117
|
+
|
|
118
|
+
self.fg_window_spin = _islider(
|
|
119
|
+
3, 301, 51, tooltip="Foreground residual window (px, forced odd)."
|
|
120
|
+
)
|
|
121
|
+
self.fg_cutoff_spin = _dslider(
|
|
122
|
+
0, 1, 0.002, 0.001, 3, "Territory threshold on the fg residual."
|
|
123
|
+
)
|
|
124
|
+
self.fg_strength_spin = _dslider(
|
|
125
|
+
0, 1, 1.0, 0.05, 2,
|
|
126
|
+
"Background-subtraction strength: 1 = full fg residual, "
|
|
127
|
+
"0 = raw fg map (no flattening).",
|
|
128
|
+
)
|
|
129
|
+
self.contour_window_spin = _islider(
|
|
130
|
+
3, 301, 51, tooltip="Contour residual window (px, forced odd)."
|
|
131
|
+
)
|
|
132
|
+
self.contour_floor_spin = _dslider(
|
|
133
|
+
0, 1, 0.01, 0.001, 3, "Ridge noise floor on the contour residual."
|
|
134
|
+
)
|
|
135
|
+
self.contour_strength_spin = _dslider(
|
|
136
|
+
0, 1, 1.0, 0.05, 2,
|
|
137
|
+
"Background-subtraction strength: 1 = full contour residual, "
|
|
138
|
+
"0 = raw contour map (no flattening).",
|
|
139
|
+
)
|
|
140
|
+
self.atom_min_area_spin = _islider(
|
|
141
|
+
0, 5000, 100, tooltip="Atoms smaller than this merge into a neighbour."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.status_lbl = QLabel("")
|
|
145
|
+
self.status_lbl.setWordWrap(True)
|
|
146
|
+
self.status_lbl.setVisible(False)
|
|
147
|
+
|
|
148
|
+
# A "Compute:" checkbox row along the top — one box per tuning stage.
|
|
149
|
+
# Each box gates whether that stage's layers are *computed at all* by the
|
|
150
|
+
# live preview (not merely shown): the worker computes only the ticked
|
|
151
|
+
# stages and an unticked stage's layers are removed. The contour stage's
|
|
152
|
+
# atom watershed runs on the foreground territory, so ticking Contour
|
|
153
|
+
# implies the (cheap) FG compute even when FG itself is unticked.
|
|
154
|
+
self.fg_check = QCheckBox("Foreground")
|
|
155
|
+
self.fg_check.setChecked(True)
|
|
156
|
+
self.fg_check.setToolTip(
|
|
157
|
+
"Compute + show the foreground layers (residual_foreground + territory)."
|
|
158
|
+
)
|
|
159
|
+
self.contour_check = QCheckBox("Contour")
|
|
160
|
+
self.contour_check.setChecked(False)
|
|
161
|
+
self.contour_check.setToolTip(
|
|
162
|
+
"Compute + show the contour layers (residual_contour + ridge + atoms)."
|
|
163
|
+
)
|
|
164
|
+
compute_row = QWidget()
|
|
165
|
+
compute_lay = QHBoxLayout(compute_row)
|
|
166
|
+
compute_lay.setContentsMargins(8, 0, 4, 0)
|
|
167
|
+
compute_lay.setSpacing(8)
|
|
168
|
+
compute_lay.addWidget(QLabel("Compute:"))
|
|
169
|
+
compute_lay.addWidget(self.fg_check)
|
|
170
|
+
compute_lay.addWidget(self.contour_check)
|
|
171
|
+
compute_lay.addStretch(1)
|
|
172
|
+
|
|
173
|
+
# Foreground residual (→ territory) knobs.
|
|
174
|
+
fg_grid = section_grid()
|
|
175
|
+
fg_grid.setContentsMargins(0, 0, 0, 0)
|
|
176
|
+
add_section_pair_row(
|
|
177
|
+
fg_grid, 0,
|
|
178
|
+
"FG window:", self.fg_window_spin,
|
|
179
|
+
"FG cutoff:", self.fg_cutoff_spin,
|
|
180
|
+
)
|
|
181
|
+
add_section_pair_row(fg_grid, 1, "FG strength:", self.fg_strength_spin)
|
|
182
|
+
fg_grid_w = QWidget()
|
|
183
|
+
fg_grid_w.setLayout(fg_grid)
|
|
184
|
+
|
|
185
|
+
# Contour residual (→ ridge → atoms) knobs; atom_min_area only
|
|
186
|
+
# post-processes the atoms, so it lives here too.
|
|
187
|
+
contour_grid = section_grid()
|
|
188
|
+
contour_grid.setContentsMargins(0, 0, 0, 0)
|
|
189
|
+
add_section_pair_row(
|
|
190
|
+
contour_grid, 0,
|
|
191
|
+
"Contour window:", self.contour_window_spin,
|
|
192
|
+
"Contour floor:", self.contour_floor_spin,
|
|
193
|
+
)
|
|
194
|
+
add_section_pair_row(
|
|
195
|
+
contour_grid, 1,
|
|
196
|
+
"Contour strength:", self.contour_strength_spin,
|
|
197
|
+
"Min area:", self.atom_min_area_spin,
|
|
198
|
+
)
|
|
199
|
+
contour_grid_w = QWidget()
|
|
200
|
+
contour_grid_w.setLayout(contour_grid)
|
|
201
|
+
|
|
202
|
+
inner_body = QWidget()
|
|
203
|
+
inner_body_lay = QVBoxLayout(inner_body)
|
|
204
|
+
inner_body_lay.setContentsMargins(0, 0, 0, 0)
|
|
205
|
+
inner_body_lay.setSpacing(4)
|
|
206
|
+
inner_body_lay.addWidget(compute_row)
|
|
207
|
+
inner_body_lay.addWidget(fg_grid_w)
|
|
208
|
+
inner_body_lay.addWidget(contour_grid_w)
|
|
209
|
+
inner_body_lay.addWidget(self.status_lbl)
|
|
210
|
+
|
|
211
|
+
self.section = CollapsibleSection("Atom Extraction Params", inner_body)
|
|
212
|
+
self.section.set_header_visible(False)
|
|
213
|
+
self.section.collapse()
|
|
214
|
+
self.params_btn.toggled.connect(
|
|
215
|
+
lambda checked: self.section._toggle.setChecked(checked)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
lay = QVBoxLayout(self)
|
|
219
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
220
|
+
lay.setSpacing(0)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class NucleusAtomExtractionMixin:
|
|
224
|
+
"""Behavior for the Atom Extraction section.
|
|
225
|
+
|
|
226
|
+
Host must provide: ``self.viewer``, ``self._current_t()``,
|
|
227
|
+
``self._atom_fg_path()``, ``self._atom_contour_path()``,
|
|
228
|
+
``self._atom_output_path()``. Optionally ``self._files_widget`` +
|
|
229
|
+
``self._pos_dir`` — a run refreshes them so the atoms.tif status repaints.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def _init_atom_extraction_state(self) -> None:
|
|
233
|
+
self._atom_preview_active = False
|
|
234
|
+
# Image layers whose contrast limits have not yet been auto-set. We seed
|
|
235
|
+
# contrast once, on the first real data a freshly created layer receives,
|
|
236
|
+
# then leave it alone so the user's manual contrast survives refreshes.
|
|
237
|
+
self._atom_image_needs_autocontrast: set[str] = set()
|
|
238
|
+
# A compute is in flight (None when idle); rapid edits while one runs set
|
|
239
|
+
# _atom_preview_pending so exactly one fresh pass fires when it returns.
|
|
240
|
+
self._atom_preview_worker = None
|
|
241
|
+
self._atom_preview_pending = False
|
|
242
|
+
# Per-frame result cache (frame t → painted slices) keyed on the atom
|
|
243
|
+
# params, so scrubbing back to a computed frame repaints instantly and
|
|
244
|
+
# any param edit drops every cached frame. Mirrors the cell-segmentation
|
|
245
|
+
# preview's cache; freed when the preview deactivates.
|
|
246
|
+
self._atom_preview_cache = FramePreviewCache()
|
|
247
|
+
self._atom_refresh_timer = QTimer(self)
|
|
248
|
+
self._atom_refresh_timer.setSingleShot(True)
|
|
249
|
+
self._atom_refresh_timer.setInterval(150)
|
|
250
|
+
self._atom_refresh_timer.timeout.connect(self._refresh_atom_preview)
|
|
251
|
+
|
|
252
|
+
def _alias_atom_extraction_controls(self) -> None:
|
|
253
|
+
w = self.atom_extraction_widget
|
|
254
|
+
for spin in (w.fg_window_spin, w.fg_cutoff_spin, w.fg_strength_spin,
|
|
255
|
+
w.contour_window_spin, w.contour_floor_spin,
|
|
256
|
+
w.contour_strength_spin, w.atom_min_area_spin):
|
|
257
|
+
spin.valueChanged.connect(self._on_atom_param_changed)
|
|
258
|
+
w.active_btn.toggled.connect(self._on_atom_activate)
|
|
259
|
+
w.run_btn.clicked.connect(self._run_atom_extraction)
|
|
260
|
+
w.fg_check.toggled.connect(
|
|
261
|
+
lambda checked: self._on_atom_compute_toggled(_ATOM_FG_GROUP_LAYERS, checked)
|
|
262
|
+
)
|
|
263
|
+
w.contour_check.toggled.connect(
|
|
264
|
+
lambda checked: self._on_atom_compute_toggled(_ATOM_CONTOUR_GROUP_LAYERS, checked)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _register_atom_gate_controls(self) -> None:
|
|
268
|
+
"""Register the atom extraction section with the app-wide UI gate.
|
|
269
|
+
|
|
270
|
+
The ◉ live atom preview is a mutually-exclusive viewer owner, shared
|
|
271
|
+
across every section (correction, db browser, the cell/divergence live
|
|
272
|
+
previews): turning it on must disable the others and vice versa. ▶ run
|
|
273
|
+
rewrites the atom layers in the viewer, so it is blocked while any owner
|
|
274
|
+
is active; ⚙ params just toggles a parameter panel and stays available.
|
|
275
|
+
"""
|
|
276
|
+
g = self.gate
|
|
277
|
+
w = self.atom_extraction_widget
|
|
278
|
+
ready = self._atom_inputs_ready
|
|
279
|
+
g.register_owner(
|
|
280
|
+
"atom_preview",
|
|
281
|
+
"atom live preview",
|
|
282
|
+
exit_fn=lambda: w.active_btn.setChecked(False),
|
|
283
|
+
)
|
|
284
|
+
g.register(w.params_btn, ControlClass.HARMLESS)
|
|
285
|
+
g.register(
|
|
286
|
+
w.active_btn,
|
|
287
|
+
ControlClass.VIEWER_OWNER,
|
|
288
|
+
owner_token="atom_preview",
|
|
289
|
+
when=ready,
|
|
290
|
+
)
|
|
291
|
+
g.register(w.run_btn, ControlClass.RUN_VIEWER, when=ready)
|
|
292
|
+
|
|
293
|
+
def _atom_inputs_ready(self) -> bool:
|
|
294
|
+
"""True only when both input maps are set *and* present on disk.
|
|
295
|
+
|
|
296
|
+
The live preview and ▶ run both read the foreground/contour TIFFs, so
|
|
297
|
+
gating on path-not-None alone enables them for paths that don't exist
|
|
298
|
+
(e.g. a stale foreground/contour pair restored from QSettings), and the
|
|
299
|
+
first click then crashes in ``tifffile``. Requiring the files to exist
|
|
300
|
+
keeps the controls disabled until there is something real to read.
|
|
301
|
+
"""
|
|
302
|
+
fg = self._atom_fg_path()
|
|
303
|
+
contour = self._atom_contour_path()
|
|
304
|
+
return (
|
|
305
|
+
fg is not None and fg.exists()
|
|
306
|
+
and contour is not None and contour.exists()
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _atom_params(self) -> AtomParams:
|
|
310
|
+
w = self.atom_extraction_widget
|
|
311
|
+
return AtomParams(
|
|
312
|
+
fg_window=int(w.fg_window_spin.value()),
|
|
313
|
+
fg_cutoff=float(w.fg_cutoff_spin.value()),
|
|
314
|
+
fg_strength=float(w.fg_strength_spin.value()),
|
|
315
|
+
contour_window=int(w.contour_window_spin.value()),
|
|
316
|
+
contour_floor=float(w.contour_floor_spin.value()),
|
|
317
|
+
contour_strength=float(w.contour_strength_spin.value()),
|
|
318
|
+
atom_min_area=int(w.atom_min_area_spin.value()),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _set_atom_status(self, msg: str) -> None:
|
|
322
|
+
lbl = self.atom_extraction_widget.status_lbl
|
|
323
|
+
lbl.setText(msg)
|
|
324
|
+
lbl.setVisible(bool(msg))
|
|
325
|
+
if msg:
|
|
326
|
+
logger.info(msg)
|
|
327
|
+
|
|
328
|
+
def _on_atom_param_changed(self, *_args) -> None:
|
|
329
|
+
if self._atom_preview_active:
|
|
330
|
+
self._atom_refresh_timer.start()
|
|
331
|
+
|
|
332
|
+
def _on_atom_activate(self, checked: bool) -> None:
|
|
333
|
+
self._atom_preview_active = bool(checked)
|
|
334
|
+
# The live preview owns the viewer; the gate derives cross-section
|
|
335
|
+
# exclusivity (correction, db browser, other live previews) from this
|
|
336
|
+
# claim.
|
|
337
|
+
if checked:
|
|
338
|
+
self.gate.claim_viewer("atom_preview")
|
|
339
|
+
# Respect whatever the Compute checkboxes are set to — the refresh
|
|
340
|
+
# creates and computes only the ticked stages' layers.
|
|
341
|
+
self._refresh_atom_preview()
|
|
342
|
+
else:
|
|
343
|
+
self.gate.release_viewer("atom_preview")
|
|
344
|
+
self._atom_preview_pending = False
|
|
345
|
+
for name in _ATOM_LAYERS:
|
|
346
|
+
if name in self.viewer.layers:
|
|
347
|
+
self.viewer.layers.remove(name)
|
|
348
|
+
self._atom_image_needs_autocontrast.clear()
|
|
349
|
+
self._atom_preview_cache.clear()
|
|
350
|
+
self._set_atom_status("")
|
|
351
|
+
|
|
352
|
+
def _on_atom_compute_toggled(self, group_layers, checked: bool) -> None:
|
|
353
|
+
"""Handle a Compute-checkbox toggle: drop unticked groups, refresh ticked.
|
|
354
|
+
|
|
355
|
+
Unticking a stage removes its layers immediately. Then, while the preview
|
|
356
|
+
is active, a refresh creates/paints any newly-ticked stage — computing
|
|
357
|
+
only if the cached frame doesn't already cover the new level, so
|
|
358
|
+
re-ticking a stage that was just computed repaints instantly.
|
|
359
|
+
"""
|
|
360
|
+
if not checked:
|
|
361
|
+
for name in group_layers:
|
|
362
|
+
if name in self.viewer.layers:
|
|
363
|
+
self.viewer.layers.remove(name)
|
|
364
|
+
self._atom_image_needs_autocontrast.discard(name)
|
|
365
|
+
if self._atom_preview_active:
|
|
366
|
+
self._refresh_atom_preview()
|
|
367
|
+
|
|
368
|
+
def _atom_compute_groups(self):
|
|
369
|
+
"""(checkbox, owned layers, compute level) for each tuning stage."""
|
|
370
|
+
w = self.atom_extraction_widget
|
|
371
|
+
return (
|
|
372
|
+
(w.fg_check, _ATOM_FG_GROUP_LAYERS, _ATOM_LEVEL_FG),
|
|
373
|
+
(w.contour_check, _ATOM_CONTOUR_GROUP_LAYERS, _ATOM_LEVEL_CONTOUR),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _desired_atom_level(self) -> int:
|
|
377
|
+
"""The single-frame compute level needed to fill every ticked stage."""
|
|
378
|
+
level = _ATOM_LEVEL_NONE
|
|
379
|
+
for check, _layers, need in self._atom_compute_groups():
|
|
380
|
+
if check.isChecked():
|
|
381
|
+
level = max(level, need)
|
|
382
|
+
return level
|
|
383
|
+
|
|
384
|
+
def _checked_atom_layer_names(self) -> list[str]:
|
|
385
|
+
"""Layer names owned by the currently-ticked Compute checkboxes."""
|
|
386
|
+
names: list[str] = []
|
|
387
|
+
for check, layers, _need in self._atom_compute_groups():
|
|
388
|
+
if check.isChecked():
|
|
389
|
+
names.extend(layers)
|
|
390
|
+
return names
|
|
391
|
+
|
|
392
|
+
def _read_frame(self, path, t: int) -> np.ndarray:
|
|
393
|
+
return np.asarray(tifffile.imread(str(path), key=t), dtype=np.float32)
|
|
394
|
+
|
|
395
|
+
def _atom_map_shape(self):
|
|
396
|
+
"""(T, Y, X) of the foreground map, from the TIFF header (no pixel load)."""
|
|
397
|
+
fg_path = self._atom_fg_path()
|
|
398
|
+
if fg_path is None or not fg_path.exists():
|
|
399
|
+
return None
|
|
400
|
+
with tifffile.TiffFile(str(fg_path)) as tf:
|
|
401
|
+
n_frames = len(tf.pages)
|
|
402
|
+
y, x = tf.pages[0].shape[-2], tf.pages[0].shape[-1]
|
|
403
|
+
return int(n_frames), int(y), int(x)
|
|
404
|
+
|
|
405
|
+
def _refresh_atom_preview(self):
|
|
406
|
+
"""Recompute the current frame's preview off the GUI thread.
|
|
407
|
+
|
|
408
|
+
The residual + watershed pass is too heavy to run inline — doing so froze
|
|
409
|
+
the viewer on every slider tick. Instead we hand it to a ``thread_worker``
|
|
410
|
+
and paint the result back on the main thread. While a pass is in flight,
|
|
411
|
+
further edits just arm ``_atom_preview_pending`` so one fresh pass (with
|
|
412
|
+
the latest params/frame) fires when the current one returns, coalescing a
|
|
413
|
+
burst of slider moves into the minimum number of computes.
|
|
414
|
+
|
|
415
|
+
The five preview layers are full ``(T, Y, X)`` stacks sized from the input
|
|
416
|
+
maps and painted one frame at a time. Carrying the time axis gives the
|
|
417
|
+
viewer a frame slider even when no movie is open — otherwise ``current_step``
|
|
418
|
+
has no temporal entry and the preview is stuck on (and mislabels) frame 0.
|
|
419
|
+
|
|
420
|
+
Returns the started worker (or ``None``) so callers/tests can await it.
|
|
421
|
+
"""
|
|
422
|
+
if not self._atom_preview_active:
|
|
423
|
+
return None
|
|
424
|
+
fg_path = self._atom_fg_path()
|
|
425
|
+
contour_path = self._atom_contour_path()
|
|
426
|
+
if fg_path is None or contour_path is None:
|
|
427
|
+
self._set_atom_status("Foreground/contour maps not found.")
|
|
428
|
+
return None
|
|
429
|
+
shape = self._atom_map_shape()
|
|
430
|
+
if shape is None:
|
|
431
|
+
self._set_atom_status("Foreground/contour maps not found.")
|
|
432
|
+
return None
|
|
433
|
+
params = self._atom_params()
|
|
434
|
+
key = dataclasses.astuple(params)
|
|
435
|
+
self._atom_preview_cache.sync(key)
|
|
436
|
+
level = self._desired_atom_level()
|
|
437
|
+
self._ensure_atom_preview_stacks(shape, self._checked_atom_layer_names())
|
|
438
|
+
if level == _ATOM_LEVEL_NONE:
|
|
439
|
+
self._set_atom_status("")
|
|
440
|
+
return None
|
|
441
|
+
n_frames = shape[0]
|
|
442
|
+
t = max(0, min(self._current_t(), n_frames - 1))
|
|
443
|
+
|
|
444
|
+
cached = self._atom_preview_cache.get(t)
|
|
445
|
+
if cached is not None and cached[0] >= level:
|
|
446
|
+
# Already computed to (at least) the needed level for these params —
|
|
447
|
+
# instant repaint, even while a worker is busy on another frame.
|
|
448
|
+
self._paint_atom_preview(t, cached[1])
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
if self._atom_preview_worker is not None:
|
|
452
|
+
self._atom_preview_pending = True
|
|
453
|
+
return self._atom_preview_worker
|
|
454
|
+
self._set_atom_status(f"Computing atoms for frame {t}…")
|
|
455
|
+
|
|
456
|
+
@thread_worker(connect={
|
|
457
|
+
"returned": self._on_atom_preview_done,
|
|
458
|
+
"errored": self._on_atom_preview_error,
|
|
459
|
+
})
|
|
460
|
+
def _worker():
|
|
461
|
+
# The FG residual + territory is always cheap and the atom watershed
|
|
462
|
+
# needs the territory as its mask, so it runs at every level; the
|
|
463
|
+
# contour stage (residual + ridge + atoms) runs only when reached.
|
|
464
|
+
fg = self._read_frame(fg_path, t)
|
|
465
|
+
residual_foreground = residual(fg, params.fg_window, params.fg_strength)
|
|
466
|
+
territory = residual_foreground > params.fg_cutoff
|
|
467
|
+
atoms = residual_contour = ridge = None
|
|
468
|
+
if level >= _ATOM_LEVEL_CONTOUR:
|
|
469
|
+
contour = self._read_frame(contour_path, t)
|
|
470
|
+
residual_contour = residual(
|
|
471
|
+
contour, params.contour_window, params.contour_strength
|
|
472
|
+
)
|
|
473
|
+
atoms, ridge = extract_atoms_frame(
|
|
474
|
+
residual_contour, territory,
|
|
475
|
+
params.contour_floor, params.atom_min_area,
|
|
476
|
+
)
|
|
477
|
+
slices = (atoms, territory.astype(np.uint8),
|
|
478
|
+
residual_foreground, residual_contour, ridge)
|
|
479
|
+
return key, t, level, slices
|
|
480
|
+
|
|
481
|
+
self._atom_preview_worker = _worker()
|
|
482
|
+
return self._atom_preview_worker
|
|
483
|
+
|
|
484
|
+
def _on_atom_preview_done(self, result) -> None:
|
|
485
|
+
self._atom_preview_worker = None
|
|
486
|
+
key, t, level, slices = result
|
|
487
|
+
self._atom_preview_cache.put(key, t, (level, slices))
|
|
488
|
+
if self._atom_preview_active:
|
|
489
|
+
self._paint_atom_preview(t, slices)
|
|
490
|
+
if self._atom_preview_pending and self._atom_preview_active:
|
|
491
|
+
self._atom_preview_pending = False
|
|
492
|
+
self._refresh_atom_preview()
|
|
493
|
+
else:
|
|
494
|
+
self._atom_preview_pending = False
|
|
495
|
+
|
|
496
|
+
def _paint_atom_preview(self, t: int, slices) -> None:
|
|
497
|
+
"""Fill frame ``t``'s slice for every stage the result supplies.
|
|
498
|
+
|
|
499
|
+
The contour stage's slices are ``None`` when only the foreground level
|
|
500
|
+
was computed; those are skipped (their layers don't exist either). Each
|
|
501
|
+
``_fill_*`` is itself a no-op for an absent layer, so painting a value
|
|
502
|
+
whose stage is computed-but-unticked is harmless.
|
|
503
|
+
"""
|
|
504
|
+
atoms, territory, residual_foreground, residual_contour, ridge = slices
|
|
505
|
+
if residual_foreground is not None:
|
|
506
|
+
self._fill_atom_image_slice(_ATOM_FG_RESIDUAL_LAYER, t, residual_foreground)
|
|
507
|
+
if territory is not None:
|
|
508
|
+
self._fill_atom_labels_slice(_ATOM_TERRITORY_LAYER, t, territory)
|
|
509
|
+
if residual_contour is not None:
|
|
510
|
+
self._fill_atom_image_slice(_ATOM_CONTOUR_RESIDUAL_LAYER, t, residual_contour)
|
|
511
|
+
if ridge is not None:
|
|
512
|
+
self._fill_atom_labels_slice(_ATOM_RIDGE_LAYER, t, ridge)
|
|
513
|
+
if atoms is not None:
|
|
514
|
+
self._fill_atom_labels_slice(_ATOM_PREVIEW_LAYER, t, atoms)
|
|
515
|
+
self._set_atom_status(f"Frame {t}: {int(atoms.max())} atoms.")
|
|
516
|
+
else:
|
|
517
|
+
self._set_atom_status(f"Frame {t}: territory only.")
|
|
518
|
+
|
|
519
|
+
def _on_atom_preview_error(self, exc: Exception) -> None:
|
|
520
|
+
self._atom_preview_worker = None
|
|
521
|
+
self._atom_preview_pending = False
|
|
522
|
+
self._set_atom_status(f"Atom preview failed: {exc}")
|
|
523
|
+
logger.exception("Atom preview worker error", exc_info=exc)
|
|
524
|
+
|
|
525
|
+
# ── preview stacks (one zero-filled (T, Y, X) layer per map) ─────────────
|
|
526
|
+
|
|
527
|
+
def _ensure_atom_preview_stacks(self, shape, names) -> None:
|
|
528
|
+
# Walk the fixed bottom → top order and create only the requested layers,
|
|
529
|
+
# so napari stacks each mask directly above the residual image it is
|
|
530
|
+
# judged against (§ fixed stack order) even when a stage is unticked.
|
|
531
|
+
wanted = set(names)
|
|
532
|
+
for name in _ATOM_LAYERS:
|
|
533
|
+
if name not in wanted:
|
|
534
|
+
continue
|
|
535
|
+
if name in _ATOM_IMAGE_LAYERS:
|
|
536
|
+
self._ensure_atom_image_stack(name, shape)
|
|
537
|
+
else:
|
|
538
|
+
self._ensure_atom_labels_stack(name, shape)
|
|
539
|
+
|
|
540
|
+
def _ensure_atom_labels_stack(self, name: str, shape) -> None:
|
|
541
|
+
from napari.layers import Labels
|
|
542
|
+
if name in self.viewer.layers:
|
|
543
|
+
layer = self.viewer.layers[name]
|
|
544
|
+
if isinstance(layer, Labels) and tuple(layer.data.shape) == tuple(shape):
|
|
545
|
+
return
|
|
546
|
+
was_visible = layer.visible
|
|
547
|
+
self.viewer.layers.remove(name)
|
|
548
|
+
else:
|
|
549
|
+
was_visible = True
|
|
550
|
+
new_layer = self.viewer.add_labels(
|
|
551
|
+
np.zeros(shape, dtype=np.int32), name=name, opacity=_ATOM_MASK_OPACITY
|
|
552
|
+
)
|
|
553
|
+
new_layer.visible = was_visible
|
|
554
|
+
|
|
555
|
+
def _ensure_atom_image_stack(self, name: str, shape) -> None:
|
|
556
|
+
if name in self.viewer.layers:
|
|
557
|
+
layer = self.viewer.layers[name]
|
|
558
|
+
if tuple(layer.data.shape) == tuple(shape):
|
|
559
|
+
return
|
|
560
|
+
was_visible = layer.visible
|
|
561
|
+
self.viewer.layers.remove(name)
|
|
562
|
+
else:
|
|
563
|
+
was_visible = True
|
|
564
|
+
new_layer = self.viewer.add_image(
|
|
565
|
+
np.zeros(shape, dtype=np.float32), name=name,
|
|
566
|
+
colormap="magma", blending="additive",
|
|
567
|
+
)
|
|
568
|
+
new_layer.visible = was_visible
|
|
569
|
+
# A just-created image layer gets its contrast auto-set on the first real
|
|
570
|
+
# data it receives; afterwards the user's contrast is left untouched.
|
|
571
|
+
self._atom_image_needs_autocontrast.add(name)
|
|
572
|
+
|
|
573
|
+
def _fill_atom_labels_slice(self, name: str, t: int, frame: np.ndarray) -> None:
|
|
574
|
+
if name not in self.viewer.layers:
|
|
575
|
+
return
|
|
576
|
+
layer = self.viewer.layers[name]
|
|
577
|
+
if layer.data.ndim != 3 or not 0 <= t < layer.data.shape[0]:
|
|
578
|
+
return
|
|
579
|
+
layer.data[t] = frame.astype(layer.data.dtype, copy=False)
|
|
580
|
+
layer.refresh()
|
|
581
|
+
|
|
582
|
+
def _fill_atom_image_slice(self, name: str, t: int, frame: np.ndarray) -> None:
|
|
583
|
+
if name not in self.viewer.layers:
|
|
584
|
+
return
|
|
585
|
+
layer = self.viewer.layers[name]
|
|
586
|
+
if layer.data.ndim != 3 or not 0 <= t < layer.data.shape[0]:
|
|
587
|
+
return
|
|
588
|
+
layer.data[t] = frame.astype(layer.data.dtype, copy=False)
|
|
589
|
+
self._maybe_autocontrast(name, layer, frame)
|
|
590
|
+
layer.refresh()
|
|
591
|
+
|
|
592
|
+
def _maybe_autocontrast(self, name: str, layer, data: np.ndarray) -> None:
|
|
593
|
+
"""Seed a freshly created image layer's contrast once, from its first
|
|
594
|
+
real data — then never again, so manual contrast survives refreshes."""
|
|
595
|
+
if name not in self._atom_image_needs_autocontrast:
|
|
596
|
+
return
|
|
597
|
+
lo, hi = float(data.min()), float(data.max())
|
|
598
|
+
if hi > lo:
|
|
599
|
+
layer.contrast_limits = (lo, hi)
|
|
600
|
+
self._atom_image_needs_autocontrast.discard(name)
|
|
601
|
+
|
|
602
|
+
def _run_atom_extraction(self) -> None:
|
|
603
|
+
fg_path = self._atom_fg_path()
|
|
604
|
+
contour_path = self._atom_contour_path()
|
|
605
|
+
out_path = self._atom_output_path()
|
|
606
|
+
if fg_path is None or contour_path is None or out_path is None:
|
|
607
|
+
self._set_atom_status("Foreground/contour maps not found.")
|
|
608
|
+
return
|
|
609
|
+
params = self._atom_params()
|
|
610
|
+
self._set_atom_status("Computing atoms over all frames…")
|
|
611
|
+
try:
|
|
612
|
+
fg = np.asarray(tifffile.imread(str(fg_path)), dtype=np.float32)
|
|
613
|
+
contour = np.asarray(tifffile.imread(str(contour_path)), dtype=np.float32)
|
|
614
|
+
atoms, territory, residual_foreground, residual_contour, ridge = (
|
|
615
|
+
extract_atoms_stack_with_maps(fg, contour, params)
|
|
616
|
+
)
|
|
617
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
618
|
+
write_atoms_tif(out_path, atoms, params)
|
|
619
|
+
except Exception as exc:
|
|
620
|
+
self._set_atom_status(f"Atom computation failed: {exc}")
|
|
621
|
+
return
|
|
622
|
+
# The run computed every stage, so show them all: tick both Compute
|
|
623
|
+
# boxes (so their layers exist) and replace each with its full (T, Y, X)
|
|
624
|
+
# stack.
|
|
625
|
+
w = self.atom_extraction_widget
|
|
626
|
+
w.fg_check.setChecked(True)
|
|
627
|
+
w.contour_check.setChecked(True)
|
|
628
|
+
shape = atoms.shape
|
|
629
|
+
self._ensure_atom_preview_stacks(shape, _ATOM_LAYERS)
|
|
630
|
+
self.viewer.layers[_ATOM_PREVIEW_LAYER].data = atoms
|
|
631
|
+
self.viewer.layers[_ATOM_TERRITORY_LAYER].data = territory.astype(np.int32)
|
|
632
|
+
self.viewer.layers[_ATOM_RIDGE_LAYER].data = ridge.astype(np.int32)
|
|
633
|
+
self._set_atom_image_stack(_ATOM_FG_RESIDUAL_LAYER, residual_foreground)
|
|
634
|
+
self._set_atom_image_stack(_ATOM_CONTOUR_RESIDUAL_LAYER, residual_contour)
|
|
635
|
+
self._set_atom_status(f"Wrote {atoms.shape[0]} frames → atoms.tif.")
|
|
636
|
+
# atoms.tif is a tracked intermediate; refresh the host's Pipeline Files
|
|
637
|
+
# so the section dot + catalog rail pick it up (see the host's
|
|
638
|
+
# PipelineFilesWidget). Guarded because the mixin's host contract is loose.
|
|
639
|
+
files_widget = getattr(self, "_files_widget", None)
|
|
640
|
+
if files_widget is not None:
|
|
641
|
+
files_widget.refresh(getattr(self, "_pos_dir", None))
|
|
642
|
+
|
|
643
|
+
def _set_atom_image_stack(self, name: str, data: np.ndarray) -> None:
|
|
644
|
+
layer = self.viewer.layers[name]
|
|
645
|
+
data = np.asarray(data, dtype=np.float32)
|
|
646
|
+
layer.data = data
|
|
647
|
+
self._maybe_autocontrast(name, layer, data)
|
|
648
|
+
layer.refresh()
|