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
itasc/napari/widgets.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Shared reusable Qt widgets for the ITASC napari plugin."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from qtpy.QtCore import Qt, QTimer, Signal
|
|
7
|
+
from qtpy.QtWidgets import (
|
|
8
|
+
QFrame,
|
|
9
|
+
QHBoxLayout,
|
|
10
|
+
QLabel,
|
|
11
|
+
QPushButton,
|
|
12
|
+
QSizePolicy,
|
|
13
|
+
QToolButton,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .ui_style import (
|
|
19
|
+
SECTION_MARGIN,
|
|
20
|
+
TIGHT_SPACING,
|
|
21
|
+
TINY_MARGIN,
|
|
22
|
+
icon_button,
|
|
23
|
+
muted_accent,
|
|
24
|
+
muted_label,
|
|
25
|
+
stage_header_action_button,
|
|
26
|
+
stage_header_label,
|
|
27
|
+
status_label,
|
|
28
|
+
)
|
|
29
|
+
from ._widget_helpers import tool_btn
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CollapsibleSection(QWidget):
|
|
33
|
+
"""A labelled section with a toggle button that shows/hides its inner widget."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
title: str,
|
|
38
|
+
inner: QWidget,
|
|
39
|
+
expanded: bool = False,
|
|
40
|
+
parent: QWidget | None = None,
|
|
41
|
+
title_color: str | None = None,
|
|
42
|
+
accent_color: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(parent)
|
|
45
|
+
self._inner = inner
|
|
46
|
+
self._base_title = title
|
|
47
|
+
self._default_title_color: str | None = title_color
|
|
48
|
+
# An explicit accent_color marks this as the OUTER stage anchor: stripe
|
|
49
|
+
# is thicker and the header text uses the full accent hue. Inner sections
|
|
50
|
+
# leave accent_color=None and inherit a muted variant via parent walk.
|
|
51
|
+
self._explicit_accent: str | None = accent_color
|
|
52
|
+
self._effective_accent: str | None = accent_color
|
|
53
|
+
self._is_outer_accent: bool = accent_color is not None
|
|
54
|
+
|
|
55
|
+
layout = QVBoxLayout(self)
|
|
56
|
+
layout.setContentsMargins(0, TINY_MARGIN, 0, TINY_MARGIN)
|
|
57
|
+
layout.setSpacing(0)
|
|
58
|
+
|
|
59
|
+
# Header toggle button
|
|
60
|
+
self._toggle = QToolButton()
|
|
61
|
+
self._toggle.setObjectName("collapsible_toggle")
|
|
62
|
+
self._toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
|
63
|
+
self._toggle.setCheckable(True)
|
|
64
|
+
self._toggle.setChecked(expanded)
|
|
65
|
+
self._toggle.setText(self._qt_display_text(title))
|
|
66
|
+
self._toggle.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
|
|
67
|
+
self._toggle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
68
|
+
self._toggle.toggled.connect(self._on_toggled)
|
|
69
|
+
|
|
70
|
+
self._status: str | None = None
|
|
71
|
+
|
|
72
|
+
header_row = QHBoxLayout()
|
|
73
|
+
header_row.setContentsMargins(0, 0, 0, 0)
|
|
74
|
+
header_row.setSpacing(4)
|
|
75
|
+
header_row.addWidget(self._toggle, 1)
|
|
76
|
+
layout.addLayout(header_row)
|
|
77
|
+
|
|
78
|
+
self._content_frame = QFrame()
|
|
79
|
+
self._content_frame.setObjectName("collapsible_content")
|
|
80
|
+
self._content_frame.setFrameShape(QFrame.NoFrame)
|
|
81
|
+
frame_layout = QVBoxLayout(self._content_frame)
|
|
82
|
+
frame_layout.setContentsMargins(
|
|
83
|
+
SECTION_MARGIN, SECTION_MARGIN, SECTION_MARGIN, SECTION_MARGIN
|
|
84
|
+
)
|
|
85
|
+
frame_layout.setSpacing(TINY_MARGIN)
|
|
86
|
+
frame_layout.addWidget(inner)
|
|
87
|
+
|
|
88
|
+
self._content_frame.setVisible(expanded)
|
|
89
|
+
layout.addWidget(self._content_frame)
|
|
90
|
+
|
|
91
|
+
# Always Preferred policy — height is driven by scroll area's minimumHeight
|
|
92
|
+
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
|
93
|
+
|
|
94
|
+
self._apply_accent_styles()
|
|
95
|
+
# If no explicit accent, defer a parent walk to inherit one if present.
|
|
96
|
+
if self._explicit_accent is None:
|
|
97
|
+
QTimer.singleShot(0, self._maybe_inherit_accent)
|
|
98
|
+
|
|
99
|
+
if expanded:
|
|
100
|
+
QTimer.singleShot(0, self._notify_layout_change)
|
|
101
|
+
|
|
102
|
+
def _apply_accent_styles(self) -> None:
|
|
103
|
+
"""(Re)apply header + content-frame stylesheets from current accent state."""
|
|
104
|
+
accent = self._effective_accent
|
|
105
|
+
if accent is None:
|
|
106
|
+
title_color = self._default_title_color
|
|
107
|
+
font_size_pt = 10
|
|
108
|
+
frame_qss = (
|
|
109
|
+
"QFrame#collapsible_content { border: 1px solid #666666; "
|
|
110
|
+
"border-radius: 4px; margin: 0px 2px 2px 2px; }"
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
if self._is_outer_accent:
|
|
114
|
+
title_color = accent
|
|
115
|
+
font_size_pt = 11
|
|
116
|
+
else:
|
|
117
|
+
title_color = self._muted_accent(accent)
|
|
118
|
+
font_size_pt = 9
|
|
119
|
+
# Only the coloured left stripe — no gray frame around the content,
|
|
120
|
+
# since the stripe alone is enough to delimit the section.
|
|
121
|
+
frame_qss = (
|
|
122
|
+
"QFrame#collapsible_content { "
|
|
123
|
+
"border: none; "
|
|
124
|
+
f"border-left: 2px solid {title_color}; "
|
|
125
|
+
"border-radius: 0px; "
|
|
126
|
+
"margin: 0px 2px 2px 2px; "
|
|
127
|
+
"}"
|
|
128
|
+
)
|
|
129
|
+
color_rule = f"color: {title_color}; " if title_color else ""
|
|
130
|
+
self._toggle.setStyleSheet(
|
|
131
|
+
"QToolButton#collapsible_toggle { "
|
|
132
|
+
f"font-weight: bold; font-size: {font_size_pt}pt; border: none; "
|
|
133
|
+
f"padding: 2px; {color_rule}"
|
|
134
|
+
"}"
|
|
135
|
+
)
|
|
136
|
+
self._content_frame.setStyleSheet(frame_qss)
|
|
137
|
+
|
|
138
|
+
def _maybe_inherit_accent(self) -> None:
|
|
139
|
+
"""Walk up the parent chain and pick up the nearest ancestor's accent."""
|
|
140
|
+
if self._explicit_accent is not None:
|
|
141
|
+
return
|
|
142
|
+
ancestor_color = self._find_ancestor_accent_color()
|
|
143
|
+
if ancestor_color is None or ancestor_color == self._effective_accent:
|
|
144
|
+
return
|
|
145
|
+
self._effective_accent = ancestor_color
|
|
146
|
+
self._is_outer_accent = False
|
|
147
|
+
self._apply_accent_styles()
|
|
148
|
+
|
|
149
|
+
def set_accent_color(self, accent_color: str | None) -> None:
|
|
150
|
+
"""Set this section's explicit accent and refresh inherited child accents."""
|
|
151
|
+
self._explicit_accent = accent_color
|
|
152
|
+
self._effective_accent = accent_color
|
|
153
|
+
self._is_outer_accent = accent_color is not None
|
|
154
|
+
self._apply_accent_styles()
|
|
155
|
+
self._refresh_descendant_inherited_accents()
|
|
156
|
+
|
|
157
|
+
def _refresh_descendant_inherited_accents(self) -> None:
|
|
158
|
+
for child in self.findChildren(CollapsibleSection):
|
|
159
|
+
if child._explicit_accent is not None:
|
|
160
|
+
continue
|
|
161
|
+
ancestor_color = child._find_ancestor_accent_color()
|
|
162
|
+
child._effective_accent = ancestor_color
|
|
163
|
+
child._is_outer_accent = False
|
|
164
|
+
child._apply_accent_styles()
|
|
165
|
+
|
|
166
|
+
def _find_ancestor_accent_color(self) -> str | None:
|
|
167
|
+
parent = self.parent()
|
|
168
|
+
while parent is not None:
|
|
169
|
+
if isinstance(parent, CollapsibleSection):
|
|
170
|
+
if parent._effective_accent is not None:
|
|
171
|
+
return parent._effective_accent
|
|
172
|
+
parent = parent.parent()
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _muted_accent(hex_str: str) -> str:
|
|
177
|
+
"""Desaturate and flatten lightness so inner headers read as 'same hue,
|
|
178
|
+
quieter' rather than as a separate color."""
|
|
179
|
+
return muted_accent(hex_str)
|
|
180
|
+
|
|
181
|
+
def set_header_visible(self, visible: bool) -> None:
|
|
182
|
+
"""Show or hide the built-in toggle header row."""
|
|
183
|
+
self._toggle.setVisible(visible)
|
|
184
|
+
|
|
185
|
+
def set_title(self, title: str) -> None:
|
|
186
|
+
"""Update the header text."""
|
|
187
|
+
self._base_title = title
|
|
188
|
+
self._toggle.setText(self._qt_display_text(title))
|
|
189
|
+
|
|
190
|
+
def set_status(self, status: str | None) -> None:
|
|
191
|
+
"""Store section status for callers without rendering a header indicator."""
|
|
192
|
+
self._status = status
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def status(self) -> str | None:
|
|
196
|
+
return self._status
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def title(self) -> str:
|
|
200
|
+
return self._base_title
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_expanded(self) -> bool:
|
|
204
|
+
return self._toggle.isChecked()
|
|
205
|
+
|
|
206
|
+
def expand(self) -> None:
|
|
207
|
+
self._toggle.setChecked(True)
|
|
208
|
+
|
|
209
|
+
def collapse(self) -> None:
|
|
210
|
+
self._toggle.setChecked(False)
|
|
211
|
+
|
|
212
|
+
def _on_toggled(self, checked: bool) -> None:
|
|
213
|
+
self._toggle.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
|
214
|
+
self._content_frame.setVisible(checked)
|
|
215
|
+
QTimer.singleShot(0, self._notify_layout_change)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _qt_display_text(title: str) -> str:
|
|
219
|
+
"""Escape mnemonic markers so literal ampersands render correctly."""
|
|
220
|
+
return title.replace("&", "&&")
|
|
221
|
+
|
|
222
|
+
def _notify_layout_change(self) -> None:
|
|
223
|
+
"""Propagate geometry changes up the nested collapsible chain."""
|
|
224
|
+
self.updateGeometry()
|
|
225
|
+
parent = self.parent()
|
|
226
|
+
while parent is not None:
|
|
227
|
+
if isinstance(parent, CollapsibleSection) and parent.is_expanded:
|
|
228
|
+
parent.updateGeometry()
|
|
229
|
+
QTimer.singleShot(0, parent._notify_layout_change)
|
|
230
|
+
return
|
|
231
|
+
parent.updateGeometry()
|
|
232
|
+
parent = parent.parent()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def make_pipeline_files_header(
|
|
236
|
+
section: CollapsibleSection,
|
|
237
|
+
*,
|
|
238
|
+
stage_key: str,
|
|
239
|
+
parent: QWidget | None = None,
|
|
240
|
+
) -> tuple[QWidget, QLabel, QToolButton]:
|
|
241
|
+
"""Create a compact external header for a pipeline files section."""
|
|
242
|
+
section.set_header_visible(False)
|
|
243
|
+
|
|
244
|
+
header = QWidget(parent)
|
|
245
|
+
layout = QHBoxLayout(header)
|
|
246
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
247
|
+
layout.setSpacing(4)
|
|
248
|
+
|
|
249
|
+
label = QLabel("Pipeline Files")
|
|
250
|
+
stage_header_label(label, stage_key)
|
|
251
|
+
|
|
252
|
+
button = tool_btn("🔍", "Show pipeline files.", checkable=True)
|
|
253
|
+
stage_header_action_button(button, stage_key)
|
|
254
|
+
button.setChecked(section.is_expanded)
|
|
255
|
+
|
|
256
|
+
def _set_expanded(checked: bool) -> None:
|
|
257
|
+
if checked:
|
|
258
|
+
section.expand()
|
|
259
|
+
else:
|
|
260
|
+
section.collapse()
|
|
261
|
+
button.setToolTip(
|
|
262
|
+
"Hide pipeline files." if checked else "Show pipeline files."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
button.toggled.connect(_set_expanded)
|
|
266
|
+
section._toggle.toggled.connect(button.setChecked)
|
|
267
|
+
|
|
268
|
+
layout.addWidget(label)
|
|
269
|
+
layout.addWidget(button)
|
|
270
|
+
layout.addStretch(1)
|
|
271
|
+
return header, label, button
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Pipeline file status rows
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
class _PipelineFileRow(QWidget):
|
|
279
|
+
"""One pipeline file status row: icon | rel-path | info | [load btn]"""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
rel_path: str,
|
|
284
|
+
display_name: str,
|
|
285
|
+
loadable: str | None = None,
|
|
286
|
+
viewer=None,
|
|
287
|
+
legacy_rel_path: str | None = None,
|
|
288
|
+
):
|
|
289
|
+
super().__init__()
|
|
290
|
+
self._rel_path = rel_path
|
|
291
|
+
self._legacy_rel_path = legacy_rel_path
|
|
292
|
+
self._loadable = loadable or self._infer_load_kind(rel_path)
|
|
293
|
+
self._full_path: Path | None = None
|
|
294
|
+
self._viewer = viewer
|
|
295
|
+
|
|
296
|
+
lay = QHBoxLayout(self)
|
|
297
|
+
lay.setContentsMargins(
|
|
298
|
+
SECTION_MARGIN, TINY_MARGIN, SECTION_MARGIN, TINY_MARGIN
|
|
299
|
+
)
|
|
300
|
+
lay.setSpacing(TIGHT_SPACING)
|
|
301
|
+
|
|
302
|
+
self._icon_lbl = QLabel("○")
|
|
303
|
+
self._icon_lbl.setFixedWidth(14)
|
|
304
|
+
self._icon_lbl.setAlignment(Qt.AlignCenter)
|
|
305
|
+
muted_label(self._icon_lbl, size_pt=9)
|
|
306
|
+
lay.addWidget(self._icon_lbl)
|
|
307
|
+
|
|
308
|
+
self._display_name = display_name
|
|
309
|
+
self._name_lbl = QLabel(rel_path)
|
|
310
|
+
self._name_lbl.setFixedWidth(200)
|
|
311
|
+
status_label(self._name_lbl)
|
|
312
|
+
self._name_lbl.setToolTip(display_name)
|
|
313
|
+
lay.addWidget(self._name_lbl)
|
|
314
|
+
|
|
315
|
+
self._info_lbl = QLabel("—")
|
|
316
|
+
self._info_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
317
|
+
status_label(self._info_lbl)
|
|
318
|
+
lay.addWidget(self._info_lbl)
|
|
319
|
+
|
|
320
|
+
self._load_btn = QPushButton("↑")
|
|
321
|
+
icon_button(self._load_btn, width=18, height=18)
|
|
322
|
+
self._load_btn.clicked.connect(self._on_load_clicked)
|
|
323
|
+
self._load_btn.setEnabled(False)
|
|
324
|
+
self._load_btn.setToolTip(self._load_tooltip())
|
|
325
|
+
# Hide the button entirely when no viewer is wired in or file is not napari-loadable.
|
|
326
|
+
self._load_btn.setVisible(viewer is not None and self._loadable is not None)
|
|
327
|
+
lay.addWidget(self._load_btn)
|
|
328
|
+
|
|
329
|
+
def set_present(self, info_text: str) -> None:
|
|
330
|
+
self._icon_lbl.setText("✓")
|
|
331
|
+
self._icon_lbl.setStyleSheet("font-size: 9pt; font-weight: bold; color: #4CAF50;")
|
|
332
|
+
self._info_lbl.setText(info_text)
|
|
333
|
+
status_label(self._info_lbl)
|
|
334
|
+
self._update_load_button()
|
|
335
|
+
|
|
336
|
+
def set_missing(self) -> None:
|
|
337
|
+
self._icon_lbl.setText("✗")
|
|
338
|
+
muted_label(self._icon_lbl, size_pt=9)
|
|
339
|
+
self._info_lbl.setText("missing")
|
|
340
|
+
muted_label(self._info_lbl)
|
|
341
|
+
self._full_path = None
|
|
342
|
+
self._load_btn.setEnabled(False)
|
|
343
|
+
self._load_btn.setToolTip(self._load_tooltip(missing=True))
|
|
344
|
+
|
|
345
|
+
def set_no_project(self) -> None:
|
|
346
|
+
self._icon_lbl.setText("○")
|
|
347
|
+
muted_label(self._icon_lbl, size_pt=9)
|
|
348
|
+
self._info_lbl.setText("—")
|
|
349
|
+
muted_label(self._info_lbl)
|
|
350
|
+
self._full_path = None
|
|
351
|
+
self._load_btn.setEnabled(False)
|
|
352
|
+
self._load_btn.setToolTip(self._load_tooltip(no_project=True))
|
|
353
|
+
|
|
354
|
+
def apply_override(self, path: Path | None, pos_dir: Path) -> None:
|
|
355
|
+
"""Point this row at a live-resolved ``path`` instead of ``pos_dir / rel_path``.
|
|
356
|
+
|
|
357
|
+
Used for rows whose real location is configurable and need not match the
|
|
358
|
+
canonical declared rel-path (e.g. a cellpose input the user repointed away
|
|
359
|
+
from ``0_input/*.tif``). The displayed path label is updated to the
|
|
360
|
+
resolved location so status and label track the file the stage will read.
|
|
361
|
+
"""
|
|
362
|
+
if path is None:
|
|
363
|
+
self._reset_display_path()
|
|
364
|
+
self.set_missing()
|
|
365
|
+
return
|
|
366
|
+
self._name_lbl.setText(self._display_for(path, pos_dir))
|
|
367
|
+
self._name_lbl.setToolTip(str(path))
|
|
368
|
+
if path.exists():
|
|
369
|
+
self._full_path = path
|
|
370
|
+
self.set_present(_file_info(path))
|
|
371
|
+
else:
|
|
372
|
+
self.set_missing()
|
|
373
|
+
|
|
374
|
+
def _reset_display_path(self) -> None:
|
|
375
|
+
self._name_lbl.setText(self._rel_path)
|
|
376
|
+
self._name_lbl.setToolTip(self._display_name)
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _display_for(path: Path, pos_dir: Path) -> str:
|
|
380
|
+
"""Path relative to the position dir when nested under it, else the name."""
|
|
381
|
+
try:
|
|
382
|
+
return path.relative_to(pos_dir).as_posix()
|
|
383
|
+
except ValueError:
|
|
384
|
+
return path.name
|
|
385
|
+
|
|
386
|
+
def _update_load_button(self) -> None:
|
|
387
|
+
if self._full_path is None or not self._full_path.exists():
|
|
388
|
+
self._load_btn.setEnabled(False)
|
|
389
|
+
self._load_btn.setToolTip(self._load_tooltip(missing=True))
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if self._loadable in {"tracked", "labels", "tiff"}:
|
|
393
|
+
self._load_btn.setEnabled(True)
|
|
394
|
+
self._load_btn.setToolTip(self._load_tooltip())
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
self._load_btn.setEnabled(False)
|
|
398
|
+
self._load_btn.setToolTip("No direct napari load action for this file.")
|
|
399
|
+
|
|
400
|
+
def _on_load_clicked(self) -> None:
|
|
401
|
+
if self._full_path is None or not self._full_path.exists():
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
self._load_file_into_viewer()
|
|
405
|
+
|
|
406
|
+
def _load_file_into_viewer(self) -> None:
|
|
407
|
+
viewer = self._viewer if self._viewer is not None else self._find_viewer()
|
|
408
|
+
if viewer is None:
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
import tifffile
|
|
412
|
+
|
|
413
|
+
data = tifffile.imread(str(self._full_path))
|
|
414
|
+
layer_name = self._layer_name()
|
|
415
|
+
use_labels = self._loadable in {"tracked", "labels"}
|
|
416
|
+
|
|
417
|
+
if use_labels:
|
|
418
|
+
if layer_name in viewer.layers:
|
|
419
|
+
try:
|
|
420
|
+
viewer.layers[layer_name].data = data
|
|
421
|
+
return
|
|
422
|
+
except Exception:
|
|
423
|
+
viewer.layers.remove(viewer.layers[layer_name])
|
|
424
|
+
viewer.add_labels(data, name=layer_name)
|
|
425
|
+
else:
|
|
426
|
+
colormap = self._pick_colormap()
|
|
427
|
+
if layer_name in viewer.layers:
|
|
428
|
+
try:
|
|
429
|
+
viewer.layers[layer_name].data = data
|
|
430
|
+
return
|
|
431
|
+
except Exception:
|
|
432
|
+
viewer.layers.remove(viewer.layers[layer_name])
|
|
433
|
+
viewer.add_image(data, name=layer_name, colormap=colormap)
|
|
434
|
+
|
|
435
|
+
def _pick_colormap(self) -> str:
|
|
436
|
+
"""Colormap for non-label intensity TIFFs.
|
|
437
|
+
|
|
438
|
+
Mirrors the dedicated cellpose/divergence viewers so a file looks the
|
|
439
|
+
same however it's loaded: contour/divergence maps → ``magma``, cellpose
|
|
440
|
+
probability → ``viridis``, flow/dp → ``inferno``, and raw input or
|
|
441
|
+
foreground intensity → plain grayscale.
|
|
442
|
+
"""
|
|
443
|
+
name = Path(self._rel_path).name
|
|
444
|
+
if "contour" in name:
|
|
445
|
+
return "magma"
|
|
446
|
+
if "_prob" in name:
|
|
447
|
+
return "viridis"
|
|
448
|
+
if "_dp" in name or "flow" in name:
|
|
449
|
+
return "inferno"
|
|
450
|
+
return "gray"
|
|
451
|
+
|
|
452
|
+
def _find_viewer(self):
|
|
453
|
+
widget = self.parentWidget()
|
|
454
|
+
while widget is not None:
|
|
455
|
+
viewer = getattr(widget, "viewer", None)
|
|
456
|
+
if viewer is not None and hasattr(viewer, "add_image") and hasattr(viewer, "add_labels"):
|
|
457
|
+
return viewer
|
|
458
|
+
widget = widget.parentWidget()
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def _layer_name(self) -> str:
|
|
462
|
+
return Path(self._rel_path).with_suffix("").as_posix().replace("/", "_")
|
|
463
|
+
|
|
464
|
+
def _load_tooltip(self, *, missing: bool = False, no_project: bool = False) -> str:
|
|
465
|
+
if no_project:
|
|
466
|
+
return "No project open."
|
|
467
|
+
if missing:
|
|
468
|
+
return "File is missing."
|
|
469
|
+
if self._loadable in {"tracked", "labels"}:
|
|
470
|
+
return "Load labels into napari."
|
|
471
|
+
if self._loadable == "tiff":
|
|
472
|
+
return "Load into napari viewer."
|
|
473
|
+
return "No direct napari load action for this file."
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
def _infer_load_kind(rel_path: str) -> str | None:
|
|
477
|
+
name = Path(rel_path).name
|
|
478
|
+
if name == "tracked_labels.tif":
|
|
479
|
+
return "tracked"
|
|
480
|
+
if name == "foreground_masks.tif":
|
|
481
|
+
return "labels"
|
|
482
|
+
# atoms.tif is an int32 atom-ID image (stage ① output); load as labels.
|
|
483
|
+
if name == "atoms.tif":
|
|
484
|
+
return "labels"
|
|
485
|
+
if name.endswith("_labels.tif") or ("labels" in name and name.endswith((".tif", ".tiff"))):
|
|
486
|
+
return "labels"
|
|
487
|
+
if name.endswith((".tif", ".tiff")):
|
|
488
|
+
return "tiff"
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _file_info(path: Path) -> str:
|
|
493
|
+
"""Return a concise shape/dtype string for a pipeline output file."""
|
|
494
|
+
if path.is_dir():
|
|
495
|
+
return "Directory"
|
|
496
|
+
suffix = path.suffix.lower()
|
|
497
|
+
if suffix in (".tif", ".tiff"):
|
|
498
|
+
try:
|
|
499
|
+
import tifffile
|
|
500
|
+
with tifffile.TiffFile(path) as tif:
|
|
501
|
+
# The series carries the full N-D shape (T, Z, Y, X); a single
|
|
502
|
+
# page is only the 2D Y×X plane, which would drop z and t.
|
|
503
|
+
series = tif.series[0] if tif.series else None
|
|
504
|
+
shape = tuple(series.shape) if series is not None else tuple(tif.pages[0].shape)
|
|
505
|
+
return "×".join(str(d) for d in shape)
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
return "TIFF"
|
|
509
|
+
if suffix in (".h5", ".hdf5"):
|
|
510
|
+
return "HDF5"
|
|
511
|
+
return f"{path.stat().st_size // 1024} KB"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class PipelineFilesWidget(QWidget):
|
|
515
|
+
"""Compact file-status display for pipeline-stage widgets."""
|
|
516
|
+
|
|
517
|
+
#: Emitted after every ``refresh()`` so a host can recompute aggregated
|
|
518
|
+
#: status (section dots, catalog rail) whenever a stage's on-disk output
|
|
519
|
+
#: changed. Every stage widget already refreshes its tracker after a run,
|
|
520
|
+
#: commit, or finalize, so this is the single upstream "disk changed" hook.
|
|
521
|
+
refreshed = Signal()
|
|
522
|
+
|
|
523
|
+
def __init__(
|
|
524
|
+
self,
|
|
525
|
+
groups: list[tuple[str, list[tuple[str, str]]]],
|
|
526
|
+
parent: QWidget | None = None,
|
|
527
|
+
viewer=None,
|
|
528
|
+
) -> None:
|
|
529
|
+
super().__init__(parent)
|
|
530
|
+
lay = QVBoxLayout(self)
|
|
531
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
532
|
+
lay.setSpacing(0)
|
|
533
|
+
self._rows: list[_PipelineFileRow] = []
|
|
534
|
+
self._rows_by_group: dict[str, list[_PipelineFileRow]] = {}
|
|
535
|
+
|
|
536
|
+
for group_label, entries in groups:
|
|
537
|
+
if group_label:
|
|
538
|
+
hdr = QLabel(group_label)
|
|
539
|
+
hdr.setStyleSheet(
|
|
540
|
+
"font-size: 7pt; font-weight: bold; padding: 1px 4px;"
|
|
541
|
+
" background: palette(alternateBase); color: palette(mid);"
|
|
542
|
+
)
|
|
543
|
+
lay.addWidget(hdr)
|
|
544
|
+
group_rows: list[_PipelineFileRow] = []
|
|
545
|
+
for entry in entries:
|
|
546
|
+
rel_path, display_name = entry[0], entry[1]
|
|
547
|
+
legacy = entry[2] if len(entry) > 2 else None
|
|
548
|
+
row = _PipelineFileRow(rel_path, display_name, loadable=None, viewer=viewer, legacy_rel_path=legacy)
|
|
549
|
+
self._rows.append(row)
|
|
550
|
+
group_rows.append(row)
|
|
551
|
+
lay.addWidget(row)
|
|
552
|
+
if group_label:
|
|
553
|
+
self._rows_by_group[group_label] = group_rows
|
|
554
|
+
|
|
555
|
+
def refresh(
|
|
556
|
+
self,
|
|
557
|
+
pos_dir: Path | None,
|
|
558
|
+
overrides: dict[str, Path | None] | None = None,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Update all rows to reflect current on-disk state.
|
|
561
|
+
|
|
562
|
+
``overrides`` maps a declared rel-path to the live location that row
|
|
563
|
+
should actually track, for rows whose real path is configurable (a
|
|
564
|
+
cellpose input the user repointed away from the canonical
|
|
565
|
+
``0_input/*.tif``). A ``None`` value marks that row missing.
|
|
566
|
+
"""
|
|
567
|
+
overrides = overrides or {}
|
|
568
|
+
if pos_dir is None:
|
|
569
|
+
for row in self._rows:
|
|
570
|
+
row.set_no_project()
|
|
571
|
+
self.refreshed.emit()
|
|
572
|
+
return
|
|
573
|
+
for row in self._rows:
|
|
574
|
+
if row._rel_path in overrides:
|
|
575
|
+
row.apply_override(overrides[row._rel_path], pos_dir)
|
|
576
|
+
continue
|
|
577
|
+
full_path = pos_dir / row._rel_path
|
|
578
|
+
if not full_path.exists() and row._legacy_rel_path is not None:
|
|
579
|
+
legacy_path = pos_dir / row._legacy_rel_path
|
|
580
|
+
if legacy_path.exists():
|
|
581
|
+
full_path = legacy_path
|
|
582
|
+
if full_path.exists():
|
|
583
|
+
row._full_path = full_path
|
|
584
|
+
row.set_present(_file_info(full_path))
|
|
585
|
+
else:
|
|
586
|
+
row.set_missing()
|
|
587
|
+
self.refreshed.emit()
|
|
588
|
+
|
|
589
|
+
def presence_count_by_group(self) -> dict[str, tuple[int, int]]:
|
|
590
|
+
"""Per-group (n_present, n_total) snapshot of current file state."""
|
|
591
|
+
return {
|
|
592
|
+
label: (
|
|
593
|
+
sum(1 for r in rows if r._full_path is not None),
|
|
594
|
+
len(rows),
|
|
595
|
+
)
|
|
596
|
+
for label, rows in self._rows_by_group.items()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def pipeline_status_from_files(
|
|
601
|
+
tracker: PipelineFilesWidget, *, done_group: str
|
|
602
|
+
) -> str:
|
|
603
|
+
"""Derive a "not_started" / "in_progress" / "done" status from on-disk files.
|
|
604
|
+
|
|
605
|
+
`done` when ``done_group`` is fully present. `in_progress` when any file in
|
|
606
|
+
an "Intermediates" group is present, or when ``done_group`` is partially
|
|
607
|
+
populated. Otherwise `not_started`.
|
|
608
|
+
"""
|
|
609
|
+
counts = tracker.presence_count_by_group()
|
|
610
|
+
done_present, done_total = counts.get(done_group, (0, 0))
|
|
611
|
+
if done_total > 0 and done_present == done_total:
|
|
612
|
+
return "done"
|
|
613
|
+
intermediates_present = counts.get("Intermediates", (0, 0))[0]
|
|
614
|
+
if intermediates_present > 0 or done_present > 0:
|
|
615
|
+
return "in_progress"
|
|
616
|
+
return "not_started"
|
itasc/napari.yaml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: itasc
|
|
2
|
+
display_name: ITASC
|
|
3
|
+
contributions:
|
|
4
|
+
commands:
|
|
5
|
+
- id: itasc.main_widget
|
|
6
|
+
title: "ITASC"
|
|
7
|
+
python_name: itasc.napari:ITASCWidget
|
|
8
|
+
# The full install folds the extracted tools' widgets into this single
|
|
9
|
+
# manifest. npe2 requires a manifest's name to match the providing
|
|
10
|
+
# distribution, so the standalone itasc-cellpose / itasc-tracking /
|
|
11
|
+
# itasc-aggregate manifests cannot be re-registered from the itasc
|
|
12
|
+
# distribution; their contributions are mirrored here instead. The integrated
|
|
13
|
+
# ITASC app's own per-stage widgets (Cellpose map-building, Cell
|
|
14
|
+
# Segmentation) live inside the ITASC widget above and are not exposed as
|
|
15
|
+
# separate menu entries.
|
|
16
|
+
#
|
|
17
|
+
# The itasc-cellpose distribution's *standalone* surface — native-mask
|
|
18
|
+
# segment + laptrack track + embedded basic correction. It is not part of the
|
|
19
|
+
# integrated pipeline (the pipeline uses the Cellpose widget above); it is
|
|
20
|
+
# mirrored here only so the full install can also reach the standalone tool.
|
|
21
|
+
- id: itasc.cellpose_segment_track_widget
|
|
22
|
+
title: "Cellpose Segment + Track"
|
|
23
|
+
python_name: itasc.napari.cellpose_segment_track_widget:make_cellpose_segment_track_widget
|
|
24
|
+
- id: itasc.nucleus_tracking_widget
|
|
25
|
+
title: "Ultrack Segment + Track"
|
|
26
|
+
python_name: itasc.napari.nucleus_workflow_widget:make_nucleus_tracking_widget
|
|
27
|
+
# The ITASC Aggregate app: the full catalog UI restricted to the
|
|
28
|
+
# Contact Analysis stage + Aggregate capstone (three-dot rail: cell labels,
|
|
29
|
+
# nucleus labels, contact analysis). Both the full install and the standalone
|
|
30
|
+
# itasc-aggregate wheel resolve this same factory; the standalone simply
|
|
31
|
+
# lacks the upstream segmentation/tracking distributions, so it always comes
|
|
32
|
+
# up contact-only. In the full install this is the slim aggregate-focused
|
|
33
|
+
# entry alongside the complete ITASC widget above.
|
|
34
|
+
- id: itasc.contact_analysis_widget
|
|
35
|
+
title: "ITASC Aggregate"
|
|
36
|
+
python_name: itasc.napari.main_widget:make_aggregate_app_widget
|
|
37
|
+
widgets:
|
|
38
|
+
- command: itasc.main_widget
|
|
39
|
+
display_name: "ITASC"
|
|
40
|
+
- command: itasc.cellpose_segment_track_widget
|
|
41
|
+
display_name: "Cellpose Segment + Track"
|
|
42
|
+
- command: itasc.nucleus_tracking_widget
|
|
43
|
+
display_name: "Ultrack Segment + Track"
|
|
44
|
+
- command: itasc.contact_analysis_widget
|
|
45
|
+
display_name: "ITASC Aggregate"
|