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.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. 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()