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,951 @@
|
|
|
1
|
+
"""Contact Analysis widget: per-position quantity view (currently contacts)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import tifffile
|
|
9
|
+
|
|
10
|
+
import napari
|
|
11
|
+
from napari.qt.threading import thread_worker
|
|
12
|
+
from qtpy.QtCore import QObject, QSettings, QTimer, Signal
|
|
13
|
+
from qtpy.QtWidgets import (
|
|
14
|
+
QCheckBox,
|
|
15
|
+
QFileDialog,
|
|
16
|
+
QGridLayout,
|
|
17
|
+
QHBoxLayout,
|
|
18
|
+
QLabel,
|
|
19
|
+
QLineEdit,
|
|
20
|
+
QListWidget,
|
|
21
|
+
QProgressBar,
|
|
22
|
+
QPushButton,
|
|
23
|
+
QVBoxLayout,
|
|
24
|
+
QWidget,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from itasc.contact_analysis import (
|
|
28
|
+
build_contacts, # noqa: F401 - re-exported for tests that build directly
|
|
29
|
+
discover_contact_batch_jobs,
|
|
30
|
+
ensure_contacts,
|
|
31
|
+
run_contact_batch,
|
|
32
|
+
)
|
|
33
|
+
from itasc.contact_analysis.catalog import CONTACT_ANALYSIS_RELPATH
|
|
34
|
+
from itasc.napari.ui_gate import ControlClass, UiGate
|
|
35
|
+
from itasc.napari.ui_style import action_button, status_label
|
|
36
|
+
from itasc.napari.widgets import (
|
|
37
|
+
CollapsibleSection,
|
|
38
|
+
PipelineFilesWidget,
|
|
39
|
+
make_pipeline_files_header,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try: # pragma: no cover - local branch compatibility
|
|
43
|
+
from itasc.contact_analysis.contacts.reader import read_position_contacts
|
|
44
|
+
except ImportError: # pragma: no cover - tests monkeypatch this when absent
|
|
45
|
+
def read_position_contacts(*_args, **_kwargs): # type: ignore[no-redef]
|
|
46
|
+
raise ImportError("itasc.contact_analysis.contacts.reader is unavailable")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
try: # pragma: no cover - local branch compatibility
|
|
50
|
+
from itasc.napari.contact_visualization import (
|
|
51
|
+
add_contact_analysis_layers,
|
|
52
|
+
_nucleus_centroids_by_track,
|
|
53
|
+
)
|
|
54
|
+
except ImportError: # pragma: no cover - tests monkeypatch this when absent
|
|
55
|
+
def add_contact_analysis_layers(*_args, **_kwargs): # type: ignore[no-redef]
|
|
56
|
+
raise ImportError("itasc.napari.contact_visualization is unavailable")
|
|
57
|
+
|
|
58
|
+
def _nucleus_centroids_by_track(*_args, **_kwargs): # type: ignore[no-redef]
|
|
59
|
+
raise ImportError("itasc.napari.contact_visualization is unavailable")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_contact_analysis_widget(napari_viewer=None):
|
|
63
|
+
"""napari plugin entry point: the per-position Contact Analysis dock widget.
|
|
64
|
+
|
|
65
|
+
Returns the bare per-position :class:`ContactAnalysisWidget` in standalone
|
|
66
|
+
mode (own file pickers + config). The cross-position aggregate role now lives
|
|
67
|
+
in the full app's Aggregate capstone (``itasc.napari.aggregate_widget``),
|
|
68
|
+
so there is no separate interactive studio to serve here. Runs the napari
|
|
69
|
+
layer-delegate patch (normally done by the orchestrator package).
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
from itasc.napari._napari_compat import patch_napari_layer_delegate
|
|
73
|
+
|
|
74
|
+
patch_napari_layer_delegate()
|
|
75
|
+
except Exception: # pragma: no cover - patch is best-effort
|
|
76
|
+
pass
|
|
77
|
+
if napari_viewer is None:
|
|
78
|
+
napari_viewer = napari.current_viewer()
|
|
79
|
+
return ContactAnalysisWidget(viewer=napari_viewer, standalone=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _ProgressEmitter(QObject):
|
|
83
|
+
progress = Signal(int, int, str)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ContactAnalysisWidget(QWidget):
|
|
87
|
+
"""Final contact analysis and export."""
|
|
88
|
+
|
|
89
|
+
_contact_analysis_layer_prefix = "[Contact Analysis] "
|
|
90
|
+
|
|
91
|
+
#: QSettings key used to persist display options in standalone mode.
|
|
92
|
+
_SETTINGS_KEY = "itasc_contact/state"
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
viewer: object | None = None,
|
|
97
|
+
parent: QWidget | None = None,
|
|
98
|
+
gate: UiGate | None = None,
|
|
99
|
+
standalone: bool = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__(parent)
|
|
102
|
+
self.viewer = viewer
|
|
103
|
+
#: App-wide UI gate; a private one is created for standalone use.
|
|
104
|
+
self.gate = gate if gate is not None else UiGate(self)
|
|
105
|
+
#: When standalone, the widget owns its own file pickers + config and
|
|
106
|
+
#: hides the orchestrator's staged "Pipeline Files" panel. When
|
|
107
|
+
#: orchestrated, the parent injects paths via :meth:`set_context`.
|
|
108
|
+
self._standalone = standalone
|
|
109
|
+
#: Explicit working context (set via :meth:`set_context` or the pickers).
|
|
110
|
+
self._cell_labels_path: Path | None = None
|
|
111
|
+
self._nucleus_labels_path: Path | None = None
|
|
112
|
+
self._out_path: Path | None = None
|
|
113
|
+
#: The position dir driving the Pipeline Files panel (orchestrated only).
|
|
114
|
+
self._status_root: Path | None = None
|
|
115
|
+
self._build_worker = None
|
|
116
|
+
self._batch_worker = None
|
|
117
|
+
self._build_completion_pending = False
|
|
118
|
+
self._build_error_pending = False
|
|
119
|
+
self._progress_emitter = _ProgressEmitter(self)
|
|
120
|
+
self._progress_emitter.progress.connect(self._on_build_progress)
|
|
121
|
+
self._batch_progress_emitter = _ProgressEmitter(self)
|
|
122
|
+
self._batch_progress_emitter.progress.connect(self._on_batch_progress)
|
|
123
|
+
self._batch_completion_pending = False
|
|
124
|
+
self._batch_cancel = False
|
|
125
|
+
self._cached_contact_analysis_path: Path | None = None
|
|
126
|
+
self._cached_contact_analysis: Any = None
|
|
127
|
+
self._cached_cell_labels: np.ndarray | None = None
|
|
128
|
+
self._cached_nucleus_labels: np.ndarray | None = None
|
|
129
|
+
self._cached_track_centroids: dict | None = None
|
|
130
|
+
#: (path, *display-option flags) of the contact-analysis overlay currently
|
|
131
|
+
#: on screen, so a re-Show of the same position+options can skip the
|
|
132
|
+
#: remove/re-add churn (see ``_show_from_disk``). ``None`` when nothing shown.
|
|
133
|
+
self._displayed_contact_analysis_signature: tuple | None = None
|
|
134
|
+
#: Pending deferred overlay-add timer, cancelled if a new Show supersedes it.
|
|
135
|
+
self._pending_show_timer: QTimer | None = None
|
|
136
|
+
#: Positions discovered under the standalone top folder (row index ↔ job).
|
|
137
|
+
self._discovered_jobs: list = []
|
|
138
|
+
|
|
139
|
+
layout = QVBoxLayout(self)
|
|
140
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
141
|
+
layout.setSpacing(6)
|
|
142
|
+
|
|
143
|
+
self._files_widget = PipelineFilesWidget(
|
|
144
|
+
[
|
|
145
|
+
("Inputs", [
|
|
146
|
+
("nucleus_labels.tif", "Nucleus labels (committed)"),
|
|
147
|
+
("cell_labels.tif", "Cell labels (committed)"),
|
|
148
|
+
]),
|
|
149
|
+
("Output", [
|
|
150
|
+
(CONTACT_ANALYSIS_RELPATH, "Contact analysis"),
|
|
151
|
+
]),
|
|
152
|
+
],
|
|
153
|
+
viewer=self.viewer,
|
|
154
|
+
)
|
|
155
|
+
self._pipeline_files_section = CollapsibleSection(
|
|
156
|
+
"Pipeline Files",
|
|
157
|
+
self._files_widget,
|
|
158
|
+
expanded=False,
|
|
159
|
+
)
|
|
160
|
+
(
|
|
161
|
+
self.pipeline_files_header,
|
|
162
|
+
self.pipeline_files_header_lbl,
|
|
163
|
+
self.pipeline_files_toggle_btn,
|
|
164
|
+
) = make_pipeline_files_header(
|
|
165
|
+
self._pipeline_files_section,
|
|
166
|
+
stage_key="contact_analysis",
|
|
167
|
+
parent=self,
|
|
168
|
+
)
|
|
169
|
+
# The staged "Pipeline Files" panel is an orchestrator concept; standalone
|
|
170
|
+
# use replaces it with the discovery panel below.
|
|
171
|
+
self.pipeline_files_header.setVisible(not self._standalone)
|
|
172
|
+
self._pipeline_files_section.setVisible(not self._standalone)
|
|
173
|
+
layout.addWidget(self.pipeline_files_header)
|
|
174
|
+
layout.addWidget(self._pipeline_files_section)
|
|
175
|
+
|
|
176
|
+
# Standalone: enter a top folder + file names → discovered positions list.
|
|
177
|
+
self._build_discovery_section(layout)
|
|
178
|
+
|
|
179
|
+
self.contact_analysis_status_lbl = QLabel("")
|
|
180
|
+
self.contact_analysis_status_lbl.setWordWrap(True)
|
|
181
|
+
status_label(self.contact_analysis_status_lbl)
|
|
182
|
+
layout.addWidget(self.contact_analysis_status_lbl)
|
|
183
|
+
|
|
184
|
+
self.contact_analysis_progress_bar = QProgressBar()
|
|
185
|
+
self.contact_analysis_progress_bar.setRange(0, 100)
|
|
186
|
+
self.contact_analysis_progress_bar.setValue(0)
|
|
187
|
+
self.contact_analysis_progress_bar.setVisible(False)
|
|
188
|
+
self.contact_analysis_progress_bar.setTextVisible(True)
|
|
189
|
+
layout.addWidget(self.contact_analysis_progress_bar)
|
|
190
|
+
|
|
191
|
+
# Display options, laid out as a two-column grid of toggles.
|
|
192
|
+
self.color_edges_by_id_cb = QCheckBox("Color edges by ID")
|
|
193
|
+
self.color_edges_by_label_cb = QCheckBox("Color edges by label")
|
|
194
|
+
self.hide_border_edges_cb = QCheckBox("Hide border edges")
|
|
195
|
+
options_grid = QGridLayout()
|
|
196
|
+
options_grid.setContentsMargins(0, 0, 0, 0)
|
|
197
|
+
options_grid.setHorizontalSpacing(12)
|
|
198
|
+
options_grid.addWidget(self.color_edges_by_id_cb, 0, 0)
|
|
199
|
+
options_grid.addWidget(self.color_edges_by_label_cb, 0, 1)
|
|
200
|
+
options_grid.addWidget(self.hide_border_edges_cb, 1, 0)
|
|
201
|
+
layout.addLayout(options_grid)
|
|
202
|
+
|
|
203
|
+
# Two build/show actions sit directly below the display options (no
|
|
204
|
+
# trailing stretch, so the panel hugs its content). Visualize computes the
|
|
205
|
+
# .h5 on demand only if it is missing, then shows the overlays; Recompute
|
|
206
|
+
# forces a rebuild before showing, for when the .h5 is stale relative to
|
|
207
|
+
# its inputs.
|
|
208
|
+
actions_row = QHBoxLayout()
|
|
209
|
+
actions_row.setContentsMargins(0, 0, 0, 0)
|
|
210
|
+
actions_row.setSpacing(6)
|
|
211
|
+
self.recompute_btn = QPushButton("Run Contact Analysis")
|
|
212
|
+
self.recompute_btn.setToolTip(
|
|
213
|
+
"Rebuild the contact-analysis .h5 from the current inputs, then show "
|
|
214
|
+
"the overlays. Use this when the result is stale."
|
|
215
|
+
)
|
|
216
|
+
action_button(self.recompute_btn, expand=True)
|
|
217
|
+
actions_row.addWidget(self.recompute_btn, 1)
|
|
218
|
+
|
|
219
|
+
self.visualize_btn = QPushButton("Visualize Contact Analysis")
|
|
220
|
+
self.visualize_btn.setToolTip(
|
|
221
|
+
"Show contact-analysis overlays for the current position. "
|
|
222
|
+
"If the analysis has not been computed yet, it is computed first; "
|
|
223
|
+
"an existing result is shown as-is."
|
|
224
|
+
)
|
|
225
|
+
action_button(self.visualize_btn, expand=True)
|
|
226
|
+
actions_row.addWidget(self.visualize_btn, 1)
|
|
227
|
+
layout.addLayout(actions_row)
|
|
228
|
+
|
|
229
|
+
self.visualize_btn.clicked.connect(lambda: self._on_visualize(overwrite=False))
|
|
230
|
+
self.recompute_btn.clicked.connect(lambda: self._on_visualize(overwrite=True))
|
|
231
|
+
self._register_gate_controls()
|
|
232
|
+
if self._standalone:
|
|
233
|
+
self._load_standalone_settings()
|
|
234
|
+
self._update_status()
|
|
235
|
+
|
|
236
|
+
# --------------------------------------------------------------- discovery UI
|
|
237
|
+
def _make_picker_row(
|
|
238
|
+
self, layout, label: str, on_browse, *, read_only: bool = True
|
|
239
|
+
) -> QLineEdit:
|
|
240
|
+
"""Add a ``label / line-edit / Browse`` row to *layout*; return the edit."""
|
|
241
|
+
row = QHBoxLayout()
|
|
242
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
243
|
+
row.setSpacing(2)
|
|
244
|
+
lbl = QLabel(label)
|
|
245
|
+
lbl.setFixedWidth(150)
|
|
246
|
+
edit = QLineEdit()
|
|
247
|
+
edit.setReadOnly(read_only)
|
|
248
|
+
browse = QPushButton("Browse...")
|
|
249
|
+
action_button(browse)
|
|
250
|
+
browse.clicked.connect(on_browse)
|
|
251
|
+
row.addWidget(lbl)
|
|
252
|
+
row.addWidget(edit, 1)
|
|
253
|
+
row.addWidget(browse)
|
|
254
|
+
layout.addLayout(row)
|
|
255
|
+
return edit
|
|
256
|
+
|
|
257
|
+
def _build_discovery_section(self, layout) -> None:
|
|
258
|
+
"""Standalone discovery panel: a top folder + file names → a list of
|
|
259
|
+
discovered positions to pick from. Hidden when embedded in the
|
|
260
|
+
orchestrator (which drives the widget per-position via ``set_context``).
|
|
261
|
+
"""
|
|
262
|
+
container = QWidget()
|
|
263
|
+
col = QVBoxLayout(container)
|
|
264
|
+
col.setContentsMargins(0, 0, 0, 0)
|
|
265
|
+
col.setSpacing(2)
|
|
266
|
+
|
|
267
|
+
self._batch_root_edit = self._make_picker_row(
|
|
268
|
+
col, "Top folder:", self._on_browse_batch_root, read_only=False
|
|
269
|
+
)
|
|
270
|
+
self._batch_root_edit.editingFinished.connect(self._rediscover)
|
|
271
|
+
self._batch_cell_name_edit = self._make_name_row(
|
|
272
|
+
col, "Cell labels name:", "cell_labels.tif"
|
|
273
|
+
)
|
|
274
|
+
self._batch_nucleus_name_edit = self._make_name_row(
|
|
275
|
+
col, "Nucleus name (optional):", "nucleus_labels.tif"
|
|
276
|
+
)
|
|
277
|
+
self._batch_h5_name_edit = self._make_name_row(
|
|
278
|
+
col, "Output .h5 name:", "contact_analysis.h5"
|
|
279
|
+
)
|
|
280
|
+
for edit in (
|
|
281
|
+
self._batch_cell_name_edit,
|
|
282
|
+
self._batch_nucleus_name_edit,
|
|
283
|
+
self._batch_h5_name_edit,
|
|
284
|
+
):
|
|
285
|
+
edit.editingFinished.connect(self._rediscover)
|
|
286
|
+
|
|
287
|
+
self._discovery_list = QListWidget()
|
|
288
|
+
self._discovery_list.itemSelectionChanged.connect(self._on_job_selected)
|
|
289
|
+
self._discovery_list.itemDoubleClicked.connect(self._on_job_activated)
|
|
290
|
+
col.addWidget(self._discovery_list, 1)
|
|
291
|
+
|
|
292
|
+
self.batch_overwrite_cb = QCheckBox("Overwrite existing")
|
|
293
|
+
col.addWidget(self.batch_overwrite_cb)
|
|
294
|
+
|
|
295
|
+
self.run_batch_btn = QPushButton("Process all")
|
|
296
|
+
action_button(self.run_batch_btn, expand=True)
|
|
297
|
+
col.addWidget(self.run_batch_btn)
|
|
298
|
+
|
|
299
|
+
self.cancel_batch_btn = QPushButton("Cancel batch")
|
|
300
|
+
action_button(self.cancel_batch_btn)
|
|
301
|
+
self.cancel_batch_btn.setEnabled(False)
|
|
302
|
+
col.addWidget(self.cancel_batch_btn)
|
|
303
|
+
|
|
304
|
+
self.batch_progress_bar = QProgressBar()
|
|
305
|
+
self.batch_progress_bar.setRange(0, 100)
|
|
306
|
+
self.batch_progress_bar.setValue(0)
|
|
307
|
+
self.batch_progress_bar.setVisible(False)
|
|
308
|
+
self.batch_progress_bar.setTextVisible(True)
|
|
309
|
+
col.addWidget(self.batch_progress_bar)
|
|
310
|
+
|
|
311
|
+
self.batch_status_lbl = QLabel("")
|
|
312
|
+
self.batch_status_lbl.setWordWrap(True)
|
|
313
|
+
status_label(self.batch_status_lbl)
|
|
314
|
+
col.addWidget(self.batch_status_lbl)
|
|
315
|
+
|
|
316
|
+
self._discovery_container = container
|
|
317
|
+
container.setVisible(self._standalone)
|
|
318
|
+
layout.addWidget(container)
|
|
319
|
+
|
|
320
|
+
self.run_batch_btn.clicked.connect(self._on_run_batch)
|
|
321
|
+
self.cancel_batch_btn.clicked.connect(self._on_cancel_batch)
|
|
322
|
+
|
|
323
|
+
def _make_name_row(self, layout, label: str, default: str) -> QLineEdit:
|
|
324
|
+
"""Add a ``label / editable filename`` row pre-filled with *default*."""
|
|
325
|
+
row = QHBoxLayout()
|
|
326
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
327
|
+
row.setSpacing(2)
|
|
328
|
+
lbl = QLabel(label)
|
|
329
|
+
lbl.setFixedWidth(150)
|
|
330
|
+
edit = QLineEdit()
|
|
331
|
+
edit.setText(default)
|
|
332
|
+
row.addWidget(lbl)
|
|
333
|
+
row.addWidget(edit, 1)
|
|
334
|
+
layout.addLayout(row)
|
|
335
|
+
return edit
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------ discovery logic
|
|
338
|
+
def _discovery_fields(self) -> tuple[str, str, str, str | None]:
|
|
339
|
+
return (
|
|
340
|
+
self._batch_root_edit.text().strip(),
|
|
341
|
+
self._batch_cell_name_edit.text().strip(),
|
|
342
|
+
self._batch_h5_name_edit.text().strip(),
|
|
343
|
+
self._batch_nucleus_name_edit.text().strip() or None,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _job_label(self, job) -> str:
|
|
347
|
+
kind = "cell+nucleus" if job.nucleus_labels else "cell only"
|
|
348
|
+
built = "built" if job.output.exists() else "missing"
|
|
349
|
+
return f"{job.group_dir.name} {kind} [{built}]"
|
|
350
|
+
|
|
351
|
+
def _populate_discovery_list(self) -> None:
|
|
352
|
+
self._discovery_list.clear()
|
|
353
|
+
for job in self._discovered_jobs:
|
|
354
|
+
self._discovery_list.addItem(self._job_label(job))
|
|
355
|
+
|
|
356
|
+
def _refresh_discovery_status(self) -> None:
|
|
357
|
+
"""Re-evaluate built/missing badges (outputs may have appeared)."""
|
|
358
|
+
for row, job in enumerate(self._discovered_jobs):
|
|
359
|
+
item = self._discovery_list.item(row)
|
|
360
|
+
if item is not None:
|
|
361
|
+
item.setText(self._job_label(job))
|
|
362
|
+
|
|
363
|
+
def _rediscover(self) -> None:
|
|
364
|
+
"""Re-scan the top folder and repopulate the discovered-positions list."""
|
|
365
|
+
root, cell_name, h5_name, nucleus_name = self._discovery_fields()
|
|
366
|
+
self._discovered_jobs = []
|
|
367
|
+
self._discovery_list.clear()
|
|
368
|
+
if not (root and cell_name and h5_name):
|
|
369
|
+
self.batch_status_lbl.setText(
|
|
370
|
+
"Enter a top folder and file names to discover positions."
|
|
371
|
+
)
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
jobs = discover_contact_batch_jobs(
|
|
375
|
+
root, cell_name=cell_name, h5_name=h5_name, nucleus_name=nucleus_name
|
|
376
|
+
)
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
self.batch_status_lbl.setText(f"Discovery error: {exc}")
|
|
379
|
+
return
|
|
380
|
+
self._discovered_jobs = jobs
|
|
381
|
+
self._populate_discovery_list()
|
|
382
|
+
self.batch_status_lbl.setText(
|
|
383
|
+
f"Discovered {len(jobs)} position(s); double-click one to visualize."
|
|
384
|
+
if jobs
|
|
385
|
+
else f"No '{cell_name}' files found under {root}."
|
|
386
|
+
)
|
|
387
|
+
self._save_standalone_settings()
|
|
388
|
+
|
|
389
|
+
def _selected_job(self):
|
|
390
|
+
row = self._discovery_list.currentRow()
|
|
391
|
+
if 0 <= row < len(self._discovered_jobs):
|
|
392
|
+
return self._discovered_jobs[row]
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
def _on_job_selected(self) -> None:
|
|
396
|
+
job = self._selected_job()
|
|
397
|
+
if job is None:
|
|
398
|
+
return
|
|
399
|
+
# Target the selected position so Visualize/Recompute act on it.
|
|
400
|
+
self.set_context(
|
|
401
|
+
cell_labels=job.cell_labels,
|
|
402
|
+
nucleus_labels=job.nucleus_labels,
|
|
403
|
+
out_path=job.output,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _on_job_activated(self, item) -> None:
|
|
407
|
+
row = self._discovery_list.row(item)
|
|
408
|
+
if not (0 <= row < len(self._discovered_jobs)):
|
|
409
|
+
return
|
|
410
|
+
job = self._discovered_jobs[row]
|
|
411
|
+
self.set_context(
|
|
412
|
+
cell_labels=job.cell_labels,
|
|
413
|
+
nucleus_labels=job.nucleus_labels,
|
|
414
|
+
out_path=job.output,
|
|
415
|
+
)
|
|
416
|
+
self._on_visualize(overwrite=False)
|
|
417
|
+
|
|
418
|
+
# ------------------------------------------------------------------- config
|
|
419
|
+
def get_state(self) -> dict:
|
|
420
|
+
"""Serialize display options (the seam shared by orchestrator + standalone)."""
|
|
421
|
+
return {
|
|
422
|
+
"color_edges_by_id": self.color_edges_by_id_cb.isChecked(),
|
|
423
|
+
"color_edges_by_label": self.color_edges_by_label_cb.isChecked(),
|
|
424
|
+
"hide_border_edges": self.hide_border_edges_cb.isChecked(),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
def set_state(self, state: dict) -> None:
|
|
428
|
+
if not isinstance(state, dict):
|
|
429
|
+
return
|
|
430
|
+
if "color_edges_by_id" in state:
|
|
431
|
+
self.color_edges_by_id_cb.setChecked(bool(state["color_edges_by_id"]))
|
|
432
|
+
if "color_edges_by_label" in state:
|
|
433
|
+
self.color_edges_by_label_cb.setChecked(bool(state["color_edges_by_label"]))
|
|
434
|
+
if "hide_border_edges" in state:
|
|
435
|
+
self.hide_border_edges_cb.setChecked(bool(state["hide_border_edges"]))
|
|
436
|
+
|
|
437
|
+
def _load_standalone_settings(self) -> None:
|
|
438
|
+
s = QSettings()
|
|
439
|
+
raw = s.value(self._SETTINGS_KEY)
|
|
440
|
+
if isinstance(raw, dict):
|
|
441
|
+
self.set_state(raw)
|
|
442
|
+
cell = s.value("itasc_contact/cell_name", "", type=str)
|
|
443
|
+
nucleus = s.value("itasc_contact/nucleus_name", "", type=str)
|
|
444
|
+
h5 = s.value("itasc_contact/h5_name", "", type=str)
|
|
445
|
+
root = s.value("itasc_contact/root", "", type=str)
|
|
446
|
+
if cell:
|
|
447
|
+
self._batch_cell_name_edit.setText(cell)
|
|
448
|
+
if nucleus:
|
|
449
|
+
self._batch_nucleus_name_edit.setText(nucleus)
|
|
450
|
+
if h5:
|
|
451
|
+
self._batch_h5_name_edit.setText(h5)
|
|
452
|
+
if root:
|
|
453
|
+
self._batch_root_edit.setText(root)
|
|
454
|
+
self._rediscover()
|
|
455
|
+
|
|
456
|
+
def _save_standalone_settings(self) -> None:
|
|
457
|
+
if not self._standalone:
|
|
458
|
+
return
|
|
459
|
+
s = QSettings()
|
|
460
|
+
s.setValue(self._SETTINGS_KEY, self.get_state())
|
|
461
|
+
root, cell_name, h5_name, nucleus_name = self._discovery_fields()
|
|
462
|
+
s.setValue("itasc_contact/root", root)
|
|
463
|
+
s.setValue("itasc_contact/cell_name", cell_name)
|
|
464
|
+
s.setValue("itasc_contact/nucleus_name", nucleus_name or "")
|
|
465
|
+
s.setValue("itasc_contact/h5_name", h5_name)
|
|
466
|
+
|
|
467
|
+
def _register_gate_controls(self) -> None:
|
|
468
|
+
"""Register contact-analysis actions with the app-wide UI gate.
|
|
469
|
+
|
|
470
|
+
Build/cancel are headless (disk only), so they run regardless of viewer
|
|
471
|
+
ownership. Visualize/Recompute write the viewer, so they are blocked
|
|
472
|
+
while a viewer owner (correction / live preview) is active.
|
|
473
|
+
"""
|
|
474
|
+
g = self.gate
|
|
475
|
+
running = lambda: self._build_worker is not None
|
|
476
|
+
batch_running = lambda: self._batch_worker is not None
|
|
477
|
+
# Visualize/Recompute write the viewer (they show overlays) and build
|
|
478
|
+
# first; both need inputs and no in-flight build. Recompute additionally
|
|
479
|
+
# forces a rebuild, but its enablement gates the same way.
|
|
480
|
+
g.register(
|
|
481
|
+
self.visualize_btn,
|
|
482
|
+
ControlClass.RUN_VIEWER,
|
|
483
|
+
when=lambda: self._inputs_ready() and not running(),
|
|
484
|
+
)
|
|
485
|
+
g.register(
|
|
486
|
+
self.recompute_btn,
|
|
487
|
+
ControlClass.RUN_VIEWER,
|
|
488
|
+
when=lambda: self._inputs_ready() and not running(),
|
|
489
|
+
)
|
|
490
|
+
# Batch is headless (disk only); it runs regardless of viewer ownership.
|
|
491
|
+
g.register(
|
|
492
|
+
self.run_batch_btn, ControlClass.RUN_HEADLESS, when=lambda: not batch_running()
|
|
493
|
+
)
|
|
494
|
+
g.register(self.cancel_batch_btn, ControlClass.RUN_HEADLESS, when=batch_running)
|
|
495
|
+
g.recompute()
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def cell_labels_path(self) -> Path | None:
|
|
499
|
+
return self._cell_labels_path
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def nucleus_labels_path(self) -> Path | None:
|
|
503
|
+
return self._nucleus_labels_path
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def contact_analysis_out_path(self) -> Path | None:
|
|
507
|
+
return self._out_path
|
|
508
|
+
|
|
509
|
+
def set_context(
|
|
510
|
+
self,
|
|
511
|
+
*,
|
|
512
|
+
cell_labels: Path | str | None,
|
|
513
|
+
nucleus_labels: Path | str | None = None,
|
|
514
|
+
out_path: Path | str | None = None,
|
|
515
|
+
status_root: Path | str | None = None,
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Set the working context for both orchestrated and standalone use.
|
|
518
|
+
|
|
519
|
+
The orchestrator supplies explicit staged paths plus ``status_root``
|
|
520
|
+
(the position directory) to drive the "Pipeline Files" panel. Standalone
|
|
521
|
+
use targets a position by selecting it in the discovered-positions list,
|
|
522
|
+
which calls this without ``status_root``.
|
|
523
|
+
"""
|
|
524
|
+
cell = Path(cell_labels) if cell_labels else None
|
|
525
|
+
nucleus = Path(nucleus_labels) if nucleus_labels else None
|
|
526
|
+
out = Path(out_path) if out_path else None
|
|
527
|
+
# The .h5 is a derived artifact; when no explicit output is given, default
|
|
528
|
+
# it next to the cell-labels file. Orchestrated callers always pass an
|
|
529
|
+
# explicit out_path, so this only fires for standalone selections.
|
|
530
|
+
if out is None and cell is not None:
|
|
531
|
+
out = cell.parent / "contact_analysis.h5"
|
|
532
|
+
if (cell, nucleus, out) != (
|
|
533
|
+
self._cell_labels_path,
|
|
534
|
+
self._nucleus_labels_path,
|
|
535
|
+
self._out_path,
|
|
536
|
+
):
|
|
537
|
+
self._invalidate_caches()
|
|
538
|
+
self._cell_labels_path = cell
|
|
539
|
+
self._nucleus_labels_path = nucleus
|
|
540
|
+
self._out_path = out
|
|
541
|
+
self._status_root = Path(status_root) if status_root else None
|
|
542
|
+
if not self._standalone:
|
|
543
|
+
self._files_widget.refresh(self._status_root)
|
|
544
|
+
self._update_status()
|
|
545
|
+
|
|
546
|
+
def _refresh_files_tracker(self) -> None:
|
|
547
|
+
"""Re-read the Pipeline Files panel after a run wrote a new ``.h5``.
|
|
548
|
+
|
|
549
|
+
This fires the tracker's ``refreshed`` signal, which is how the host
|
|
550
|
+
repaints the stage dots and catalog rail without a manual Refresh.
|
|
551
|
+
Standalone use has no such panel, so it is a no-op there.
|
|
552
|
+
"""
|
|
553
|
+
if not self._standalone:
|
|
554
|
+
self._files_widget.refresh(self._status_root)
|
|
555
|
+
|
|
556
|
+
def _invalidate_caches(self) -> None:
|
|
557
|
+
self._cached_contact_analysis_path = None
|
|
558
|
+
self._cached_contact_analysis = None
|
|
559
|
+
self._cached_cell_labels = None
|
|
560
|
+
self._cached_nucleus_labels = None
|
|
561
|
+
self._cached_track_centroids = None
|
|
562
|
+
|
|
563
|
+
def _update_status(self) -> None:
|
|
564
|
+
self._update_action_states()
|
|
565
|
+
if self._cell_labels_path is None:
|
|
566
|
+
self._set_contact_analysis_status(
|
|
567
|
+
"Status: pick a position from the list."
|
|
568
|
+
if self._standalone
|
|
569
|
+
else "Status: no project open."
|
|
570
|
+
)
|
|
571
|
+
elif not self.contact_analysis_status_lbl.text():
|
|
572
|
+
self._set_contact_analysis_status("Status: ready.")
|
|
573
|
+
|
|
574
|
+
def _effective_nucleus_path(self) -> Path | None:
|
|
575
|
+
"""Nucleus labels to use, or ``None`` if absent.
|
|
576
|
+
|
|
577
|
+
The orchestrator always wires a nucleus path, but the file may not exist
|
|
578
|
+
yet (e.g. only cell labels have been produced). Nucleus labels are
|
|
579
|
+
optional for contact analysis, so a missing file is treated as "no
|
|
580
|
+
nucleus" rather than a blocking error.
|
|
581
|
+
"""
|
|
582
|
+
nucleus = self._nucleus_labels_path
|
|
583
|
+
if nucleus is not None and nucleus.exists():
|
|
584
|
+
return nucleus
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
def _inputs_ready(self) -> bool:
|
|
588
|
+
# Cell labels are the only required input; nucleus is optional, so a
|
|
589
|
+
# set-but-missing nucleus path must not gate Visualize.
|
|
590
|
+
cell = self._cell_labels_path
|
|
591
|
+
if cell is None or not cell.exists():
|
|
592
|
+
return False
|
|
593
|
+
return self._out_path is not None
|
|
594
|
+
|
|
595
|
+
def _update_action_states(self) -> None:
|
|
596
|
+
# Enablement is owned by the UI gate; its ``when`` predicates read the
|
|
597
|
+
# readiness helpers and ``self._build_worker``.
|
|
598
|
+
self.gate.recompute()
|
|
599
|
+
|
|
600
|
+
def _set_build_running(self, running: bool) -> None:
|
|
601
|
+
self.contact_analysis_progress_bar.setVisible(running)
|
|
602
|
+
if running:
|
|
603
|
+
self.contact_analysis_progress_bar.setRange(0, 100)
|
|
604
|
+
self.contact_analysis_progress_bar.setValue(0)
|
|
605
|
+
else:
|
|
606
|
+
self.contact_analysis_progress_bar.setValue(0)
|
|
607
|
+
self.contact_analysis_progress_bar.setRange(0, 100)
|
|
608
|
+
self._update_action_states()
|
|
609
|
+
|
|
610
|
+
def _set_contact_analysis_status(self, message: str) -> None:
|
|
611
|
+
self.contact_analysis_status_lbl.setText(message)
|
|
612
|
+
|
|
613
|
+
def _on_build_progress(self, done: int, total: int, message: str) -> None:
|
|
614
|
+
if total > 0:
|
|
615
|
+
self.contact_analysis_progress_bar.setRange(0, total)
|
|
616
|
+
self.contact_analysis_progress_bar.setValue(done)
|
|
617
|
+
self._set_contact_analysis_status(f"Status: {message}")
|
|
618
|
+
|
|
619
|
+
def _on_compute_done(self, result: tuple[Path, bool]) -> None:
|
|
620
|
+
output_path, built = result
|
|
621
|
+
self._build_completion_pending = True
|
|
622
|
+
self._build_worker = None
|
|
623
|
+
self._set_build_running(False)
|
|
624
|
+
self._set_contact_analysis_status(
|
|
625
|
+
f"Status: {'computed' if built else 'using existing'} contact analysis "
|
|
626
|
+
f"({output_path.name})."
|
|
627
|
+
)
|
|
628
|
+
# The .h5 cache is keyed by path, but a build rewrites the file in place
|
|
629
|
+
# (same path), so a Recompute would otherwise serve the stale cached read.
|
|
630
|
+
# Drop just the cached analysis so _show_from_disk re-reads the fresh file;
|
|
631
|
+
# the label TIFFs are inputs the build does not touch, so they stay cached.
|
|
632
|
+
if built:
|
|
633
|
+
self._cached_contact_analysis = None
|
|
634
|
+
self._update_status()
|
|
635
|
+
self._refresh_discovery_status()
|
|
636
|
+
self._refresh_files_tracker()
|
|
637
|
+
self._show_from_disk()
|
|
638
|
+
|
|
639
|
+
def _on_build_error(self, exc: Exception) -> None:
|
|
640
|
+
self._build_error_pending = True
|
|
641
|
+
self._build_worker = None
|
|
642
|
+
self._set_build_running(False)
|
|
643
|
+
self._set_contact_analysis_status(f"Status: error: {exc}")
|
|
644
|
+
self._update_status()
|
|
645
|
+
|
|
646
|
+
def _on_visualize(self, *, overwrite: bool = False) -> None:
|
|
647
|
+
"""Visualize the contact analysis, computing the .h5 only if needed.
|
|
648
|
+
|
|
649
|
+
With ``overwrite=False`` (Visualize) an existing .h5 is shown as-is and a
|
|
650
|
+
missing one is built first. With ``overwrite=True`` (Recompute) the .h5 is
|
|
651
|
+
always rebuilt. The build runs in a worker; the show happens afterwards.
|
|
652
|
+
"""
|
|
653
|
+
cell = self._cell_labels_path
|
|
654
|
+
out = self._out_path
|
|
655
|
+
if cell is None or out is None:
|
|
656
|
+
self._set_contact_analysis_status(
|
|
657
|
+
"Status: pick a position from the list."
|
|
658
|
+
if self._standalone
|
|
659
|
+
else "Status: no project open."
|
|
660
|
+
)
|
|
661
|
+
self._update_action_states()
|
|
662
|
+
return
|
|
663
|
+
if not cell.exists():
|
|
664
|
+
self._set_contact_analysis_status(f"Status: missing cell labels: {cell}")
|
|
665
|
+
self._update_status()
|
|
666
|
+
return
|
|
667
|
+
# Nucleus is optional: if its file is not present yet, compute contacts
|
|
668
|
+
# from cell labels alone instead of refusing to run.
|
|
669
|
+
nucleus = self._effective_nucleus_path()
|
|
670
|
+
|
|
671
|
+
# Fast path: the artifact already exists and no rebuild was requested, so
|
|
672
|
+
# skip the worker and show immediately. ensure_contacts remains the
|
|
673
|
+
# authority on the missing-only policy for the build path below.
|
|
674
|
+
if out.exists() and not overwrite:
|
|
675
|
+
self._set_contact_analysis_status(
|
|
676
|
+
f"Status: showing existing contact analysis ({out.name})."
|
|
677
|
+
)
|
|
678
|
+
self._show_from_disk()
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
self._build_completion_pending = False
|
|
682
|
+
self._build_error_pending = False
|
|
683
|
+
self._set_contact_analysis_status(
|
|
684
|
+
"Status: computing contact analysis (not present yet)..."
|
|
685
|
+
if not overwrite
|
|
686
|
+
else "Status: recomputing contact analysis..."
|
|
687
|
+
)
|
|
688
|
+
self._set_build_running(True)
|
|
689
|
+
|
|
690
|
+
@thread_worker(
|
|
691
|
+
connect={
|
|
692
|
+
"returned": self._on_compute_done,
|
|
693
|
+
"errored": self._on_build_error,
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
def _worker():
|
|
697
|
+
return ensure_contacts(
|
|
698
|
+
cell_labels_path=cell,
|
|
699
|
+
output_path=out,
|
|
700
|
+
nucleus_labels_path=nucleus,
|
|
701
|
+
overwrite=overwrite,
|
|
702
|
+
progress_cb=self._progress_emitter.progress.emit,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
worker = _worker()
|
|
706
|
+
self._build_worker = worker
|
|
707
|
+
if self._build_completion_pending or self._build_error_pending:
|
|
708
|
+
self._build_worker = None
|
|
709
|
+
self._build_completion_pending = False
|
|
710
|
+
self._build_error_pending = False
|
|
711
|
+
self._update_action_states()
|
|
712
|
+
|
|
713
|
+
# ------------------------------------------------------------------- batch
|
|
714
|
+
def _on_browse_batch_root(self) -> None:
|
|
715
|
+
path = QFileDialog.getExistingDirectory(self, "Select top-level folder")
|
|
716
|
+
if path:
|
|
717
|
+
self._batch_root_edit.setText(path)
|
|
718
|
+
self._rediscover()
|
|
719
|
+
|
|
720
|
+
def _on_batch_progress(self, done: int, total: int, label: str) -> None:
|
|
721
|
+
if total > 0:
|
|
722
|
+
self.batch_progress_bar.setRange(0, total)
|
|
723
|
+
self.batch_progress_bar.setValue(done)
|
|
724
|
+
self.batch_status_lbl.setText(f"Batch: {done}/{total} {label}")
|
|
725
|
+
|
|
726
|
+
def _on_batch_done(self, results: list) -> None:
|
|
727
|
+
self._batch_completion_pending = True
|
|
728
|
+
self._batch_worker = None
|
|
729
|
+
self.batch_progress_bar.setVisible(False)
|
|
730
|
+
built = sum(1 for r in results if r.status == "built")
|
|
731
|
+
skipped = sum(1 for r in results if r.status == "skipped")
|
|
732
|
+
failed = sum(1 for r in results if r.status == "failed")
|
|
733
|
+
self.batch_status_lbl.setText(
|
|
734
|
+
f"Processed all: built {built} / skipped {skipped} / failed {failed}"
|
|
735
|
+
)
|
|
736
|
+
self._refresh_discovery_status()
|
|
737
|
+
self._refresh_files_tracker()
|
|
738
|
+
self._update_action_states()
|
|
739
|
+
|
|
740
|
+
def _on_batch_error(self, exc: Exception) -> None:
|
|
741
|
+
self._batch_completion_pending = True
|
|
742
|
+
self._batch_worker = None
|
|
743
|
+
self.batch_progress_bar.setVisible(False)
|
|
744
|
+
self.batch_status_lbl.setText(f"Batch error: {exc}")
|
|
745
|
+
self._update_action_states()
|
|
746
|
+
|
|
747
|
+
def _on_run_batch(self) -> None:
|
|
748
|
+
# Re-scan so the run matches the list the user is looking at.
|
|
749
|
+
self._rediscover()
|
|
750
|
+
jobs = self._discovered_jobs
|
|
751
|
+
if not jobs:
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
overwrite = self.batch_overwrite_cb.isChecked()
|
|
755
|
+
self._batch_cancel = False
|
|
756
|
+
self._batch_completion_pending = False
|
|
757
|
+
self.batch_progress_bar.setRange(0, len(jobs))
|
|
758
|
+
self.batch_progress_bar.setValue(0)
|
|
759
|
+
self.batch_progress_bar.setVisible(True)
|
|
760
|
+
self.batch_status_lbl.setText(f"Batch: {len(jobs)} positions...")
|
|
761
|
+
|
|
762
|
+
@thread_worker(
|
|
763
|
+
connect={
|
|
764
|
+
"returned": self._on_batch_done,
|
|
765
|
+
"errored": self._on_batch_error,
|
|
766
|
+
}
|
|
767
|
+
)
|
|
768
|
+
def _worker():
|
|
769
|
+
return run_contact_batch(
|
|
770
|
+
jobs,
|
|
771
|
+
overwrite=overwrite,
|
|
772
|
+
progress_cb=self._batch_progress_emitter.progress.emit,
|
|
773
|
+
cancel=lambda: self._batch_cancel,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
worker = _worker()
|
|
777
|
+
self._batch_worker = worker
|
|
778
|
+
if self._batch_completion_pending:
|
|
779
|
+
self._batch_worker = None
|
|
780
|
+
self._batch_completion_pending = False
|
|
781
|
+
self._update_action_states()
|
|
782
|
+
|
|
783
|
+
def _on_cancel_batch(self) -> None:
|
|
784
|
+
self._batch_cancel = True
|
|
785
|
+
worker = self._batch_worker
|
|
786
|
+
if worker is not None:
|
|
787
|
+
self._batch_worker = None
|
|
788
|
+
worker.quit()
|
|
789
|
+
self.batch_progress_bar.setVisible(False)
|
|
790
|
+
self.batch_status_lbl.setText("Batch: cancelled.")
|
|
791
|
+
self._update_action_states()
|
|
792
|
+
|
|
793
|
+
def _show_from_disk(self) -> None:
|
|
794
|
+
if self.viewer is None:
|
|
795
|
+
self._set_contact_analysis_status("Status: no viewer available.")
|
|
796
|
+
self._update_action_states()
|
|
797
|
+
return
|
|
798
|
+
contact_analysis_path = self.contact_analysis_out_path
|
|
799
|
+
if contact_analysis_path is None or not contact_analysis_path.exists():
|
|
800
|
+
self._set_contact_analysis_status("Status: contact analysis file not found.")
|
|
801
|
+
self._update_action_states()
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
# Cache contact analysis to avoid re-reading HDF5 on every Show click.
|
|
805
|
+
# Re-read when there is no cached analysis (first show, context change, or
|
|
806
|
+
# a fresh build dropped it) or when the output path changed. A path change
|
|
807
|
+
# means a different position, so its label/centroid caches are stale too; a
|
|
808
|
+
# same-path rebuild leaves those inputs untouched, so they stay cached.
|
|
809
|
+
if (
|
|
810
|
+
self._cached_contact_analysis is None
|
|
811
|
+
or self._cached_contact_analysis_path != contact_analysis_path
|
|
812
|
+
):
|
|
813
|
+
if self._cached_contact_analysis_path != contact_analysis_path:
|
|
814
|
+
self._cached_cell_labels = None
|
|
815
|
+
self._cached_nucleus_labels = None
|
|
816
|
+
self._cached_track_centroids = None
|
|
817
|
+
self._cached_contact_analysis = read_position_contacts(contact_analysis_path)
|
|
818
|
+
self._cached_contact_analysis_path = contact_analysis_path
|
|
819
|
+
|
|
820
|
+
# Cache label TIFFs — these are large files whose repeated reading blocks
|
|
821
|
+
# the Qt main thread and causes freezes + ghost layer artifacts
|
|
822
|
+
if self._cached_cell_labels is None:
|
|
823
|
+
if self.cell_labels_path is not None and self.cell_labels_path.exists():
|
|
824
|
+
try:
|
|
825
|
+
self._cached_cell_labels = np.asarray(tifffile.imread(self.cell_labels_path))
|
|
826
|
+
except Exception:
|
|
827
|
+
pass
|
|
828
|
+
if self._cached_nucleus_labels is None:
|
|
829
|
+
if self.nucleus_labels_path is not None and self.nucleus_labels_path.exists():
|
|
830
|
+
try:
|
|
831
|
+
self._cached_nucleus_labels = np.asarray(tifffile.imread(self.nucleus_labels_path))
|
|
832
|
+
except Exception:
|
|
833
|
+
pass
|
|
834
|
+
|
|
835
|
+
# Cache nucleus track centroids — O(T*W*H*N) pixel iteration, very expensive
|
|
836
|
+
if self._cached_track_centroids is None and self._cached_nucleus_labels is not None:
|
|
837
|
+
try:
|
|
838
|
+
self._cached_track_centroids = _nucleus_centroids_by_track(self._cached_nucleus_labels)
|
|
839
|
+
except Exception:
|
|
840
|
+
pass
|
|
841
|
+
|
|
842
|
+
options: dict[str, Any] = {
|
|
843
|
+
"color_edges_by_id": self.color_edges_by_id_cb.isChecked(),
|
|
844
|
+
"color_edges_by_label": self.color_edges_by_label_cb.isChecked(),
|
|
845
|
+
"hide_border_edges": self.hide_border_edges_cb.isChecked(),
|
|
846
|
+
}
|
|
847
|
+
signature = (contact_analysis_path, *options.values())
|
|
848
|
+
|
|
849
|
+
# Skip the remove/re-add when the same position is already shown with the
|
|
850
|
+
# same display options and its layers are still present. Re-adding is pure
|
|
851
|
+
# churn here: it flickers and, via napari's QtLayerList repaint race, can
|
|
852
|
+
# leave phantom (empty, unnamed) rows in the layer list.
|
|
853
|
+
if (
|
|
854
|
+
signature == self._displayed_contact_analysis_signature
|
|
855
|
+
and self._contact_analysis_layer_names()
|
|
856
|
+
):
|
|
857
|
+
self._set_contact_analysis_status(f"Status: showing {contact_analysis_path.name}")
|
|
858
|
+
self._update_action_states()
|
|
859
|
+
return
|
|
860
|
+
|
|
861
|
+
show_kwargs: dict[str, Any] = {
|
|
862
|
+
"prefix": self._contact_analysis_layer_prefix,
|
|
863
|
+
**options,
|
|
864
|
+
}
|
|
865
|
+
if self._cached_cell_labels is not None:
|
|
866
|
+
show_kwargs["cell_labels"] = self._cached_cell_labels
|
|
867
|
+
if self._cached_nucleus_labels is not None:
|
|
868
|
+
show_kwargs["nucleus_labels"] = self._cached_nucleus_labels
|
|
869
|
+
if self._cached_track_centroids is not None:
|
|
870
|
+
show_kwargs["nucleus_track_centroids"] = self._cached_track_centroids
|
|
871
|
+
|
|
872
|
+
# Clear now, but defer the re-add to the next event-loop tick. Removing the
|
|
873
|
+
# old layers and inserting the new ones in one synchronous slot races
|
|
874
|
+
# napari's QtLayerList repaint and can leave phantom (empty, unnamed) rows
|
|
875
|
+
# in the layer list; letting Qt process the removals first avoids it.
|
|
876
|
+
self._clear_contact_analysis_layers(set_status=False)
|
|
877
|
+
contact_analysis = self._cached_contact_analysis
|
|
878
|
+
|
|
879
|
+
def _deferred_add() -> None:
|
|
880
|
+
self._pending_show_timer = None
|
|
881
|
+
if self.viewer is None or contact_analysis is None:
|
|
882
|
+
return
|
|
883
|
+
add_contact_analysis_layers(self.viewer, contact_analysis, **show_kwargs)
|
|
884
|
+
self._displayed_contact_analysis_signature = signature
|
|
885
|
+
self._set_contact_analysis_status(f"Status: loaded {contact_analysis_path.name}")
|
|
886
|
+
self._update_action_states()
|
|
887
|
+
|
|
888
|
+
# Supersede any still-pending deferred add (e.g. rapid re-clicks) so a
|
|
889
|
+
# later Show can't stack a second set of overlays on top of an earlier one.
|
|
890
|
+
if self._pending_show_timer is not None:
|
|
891
|
+
self._pending_show_timer.stop()
|
|
892
|
+
# Parent the timer to ``self`` so it is destroyed with the widget and never
|
|
893
|
+
# fires its callback against an already-deleted viewer/widget.
|
|
894
|
+
timer = QTimer(self)
|
|
895
|
+
timer.setSingleShot(True)
|
|
896
|
+
timer.timeout.connect(_deferred_add)
|
|
897
|
+
timer.start(0)
|
|
898
|
+
self._pending_show_timer = timer
|
|
899
|
+
|
|
900
|
+
def _contact_analysis_layer_names(self) -> list[str]:
|
|
901
|
+
if self.viewer is None:
|
|
902
|
+
return []
|
|
903
|
+
names: list[str] = []
|
|
904
|
+
for layer in list(self.viewer.layers):
|
|
905
|
+
layer_name = getattr(layer, "name", layer)
|
|
906
|
+
if isinstance(layer_name, str) and layer_name.startswith(self._contact_analysis_layer_prefix):
|
|
907
|
+
names.append(layer_name)
|
|
908
|
+
return names
|
|
909
|
+
|
|
910
|
+
def _clear_contact_analysis_layers(self, *, set_status: bool) -> int:
|
|
911
|
+
# Any clear invalidates the "already shown" fast-path in _show_from_disk.
|
|
912
|
+
self._displayed_contact_analysis_signature = None
|
|
913
|
+
if self.viewer is None:
|
|
914
|
+
return 0
|
|
915
|
+
|
|
916
|
+
removed = 0
|
|
917
|
+
names = self._contact_analysis_layer_names()
|
|
918
|
+
layers = self.viewer.layers
|
|
919
|
+
for name in names:
|
|
920
|
+
layer = None
|
|
921
|
+
try:
|
|
922
|
+
layer = layers[name]
|
|
923
|
+
except Exception:
|
|
924
|
+
layer = name
|
|
925
|
+
cleanup = getattr(layer, "_itasc_frame_shape_cleanup", None)
|
|
926
|
+
if callable(cleanup):
|
|
927
|
+
try:
|
|
928
|
+
cleanup()
|
|
929
|
+
except Exception:
|
|
930
|
+
pass
|
|
931
|
+
# Hide before removal so napari clears the canvas visual first,
|
|
932
|
+
# preventing a ghost frame from persisting in the viewport
|
|
933
|
+
try:
|
|
934
|
+
layer.visible = False
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
try:
|
|
938
|
+
layers.remove(layer)
|
|
939
|
+
except Exception:
|
|
940
|
+
try:
|
|
941
|
+
del layers[name]
|
|
942
|
+
except Exception:
|
|
943
|
+
pass
|
|
944
|
+
removed += 1
|
|
945
|
+
|
|
946
|
+
if set_status:
|
|
947
|
+
if removed:
|
|
948
|
+
self._set_contact_analysis_status(f"Status: cleared {removed} contact analysis layers.")
|
|
949
|
+
else:
|
|
950
|
+
self._set_contact_analysis_status("Status: no contact analysis layers to clear.")
|
|
951
|
+
return removed
|