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,1091 @@
|
|
|
1
|
+
"""The shared experiments/positions panel (napariTFM ExperimentsList parity).
|
|
2
|
+
|
|
3
|
+
One panel, two homes: the main ITASC app and the standalone Contact Analysis
|
|
4
|
+
distro both mount this. Descended from napariTFM's ExperimentsList, but reshaped
|
|
5
|
+
around a filesystem-centric flow —
|
|
6
|
+
|
|
7
|
+
Setup (calibration + input-file names + optional output dir)
|
|
8
|
+
→ Find data folders → one additive scan of a parent root; every matching
|
|
9
|
+
data folder is added straight to the list (deduped),
|
|
10
|
+
each a committed row with an editable folder-nesting
|
|
11
|
+
header, an accent select-bar, a per-position status
|
|
12
|
+
rail, and an overall-status chip
|
|
13
|
+
→ tag selected rows → set a condition column on the current selection, the
|
|
14
|
+
grouping columns of the aggregate tidy table
|
|
15
|
+
→ Run selected / Workers, and a running count
|
|
16
|
+
|
|
17
|
+
— and speaks ITASC: the rail is :class:`~itasc.napari._status_rail.StatusRail`
|
|
18
|
+
(the five-state commit-contract vocabulary), the styling is ITASC's own designed
|
|
19
|
+
surface tokens, and *what* a scan looks for / *how* a row maps to a host record is
|
|
20
|
+
injected by the host (``discover_fn`` / ``status_fn``).
|
|
21
|
+
|
|
22
|
+
The panel owns the displayed catalog (the ordered rows + their editable columns);
|
|
23
|
+
the host reads :meth:`payloads` for scope / run / save and reacts to the signals.
|
|
24
|
+
Each row carries an opaque ``payload`` (the host's record dict) threaded through
|
|
25
|
+
untouched, plus a ``columns`` dict keyed by the shared, editable column names.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from qtpy.QtCore import Qt, Signal
|
|
34
|
+
from qtpy.QtGui import QColor, QDoubleValidator, QPainter
|
|
35
|
+
from qtpy.QtWidgets import (
|
|
36
|
+
QDialog,
|
|
37
|
+
QDialogButtonBox,
|
|
38
|
+
QFormLayout,
|
|
39
|
+
QFrame,
|
|
40
|
+
QHBoxLayout,
|
|
41
|
+
QLabel,
|
|
42
|
+
QLineEdit,
|
|
43
|
+
QScrollArea,
|
|
44
|
+
QSizePolicy,
|
|
45
|
+
QSpinBox,
|
|
46
|
+
QTextBrowser,
|
|
47
|
+
QToolButton,
|
|
48
|
+
QVBoxLayout,
|
|
49
|
+
QWidget,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
from itasc.napari._stage_status import (
|
|
53
|
+
DONE,
|
|
54
|
+
STAGES,
|
|
55
|
+
STALE,
|
|
56
|
+
UNKNOWN,
|
|
57
|
+
WORKING,
|
|
58
|
+
)
|
|
59
|
+
from itasc.napari._status_rail import StatusRail
|
|
60
|
+
from itasc.napari.ui_style import (
|
|
61
|
+
COMPACT_SPACING,
|
|
62
|
+
TEXT_DIM,
|
|
63
|
+
TEXT_MID,
|
|
64
|
+
action_button_style,
|
|
65
|
+
experiment_name_color,
|
|
66
|
+
experiment_row_style,
|
|
67
|
+
experiment_status_color,
|
|
68
|
+
mono_input_style,
|
|
69
|
+
stage_accent,
|
|
70
|
+
)
|
|
71
|
+
from itasc.napari.widgets import CollapsibleSection
|
|
72
|
+
|
|
73
|
+
# Free-text calibration fields (soft validator, not spinbox stepping).
|
|
74
|
+
_CALIBRATION_SPECS = (
|
|
75
|
+
("pixel_size_um", "Pixel Size (µm)", 0.0001, 1000.0),
|
|
76
|
+
# Entered in minutes (long timelapses read naturally that way); the host
|
|
77
|
+
# converts to the backend's seconds-based ``time_interval_s`` at the boundary.
|
|
78
|
+
("time_interval_min", "Frame Length (min)", 0.0, 1e9),
|
|
79
|
+
)
|
|
80
|
+
_INPUT_DECIMALS = 6
|
|
81
|
+
|
|
82
|
+
# Fixed widths of the non-column cells, shared by the header and every data row so
|
|
83
|
+
# the editable column headers line up over their value cells.
|
|
84
|
+
_SELBAR_W = 3
|
|
85
|
+
_RAIL_W = 60
|
|
86
|
+
_CHIP_W = 56
|
|
87
|
+
|
|
88
|
+
#: The rows-list resize grip: its thickness, and the floor the list can't drag below.
|
|
89
|
+
_GRIP_H = 11
|
|
90
|
+
_MIN_ROWS_HEIGHT = 80
|
|
91
|
+
|
|
92
|
+
#: Overall-status word → chip text (kept short so the chip column stays narrow).
|
|
93
|
+
_CHIP_TEXT = {"run": "run", "done": "done", "queued": "queued"}
|
|
94
|
+
|
|
95
|
+
#: The `?` quickstart, distilled from docs/manual/full-app.md. Self-contained
|
|
96
|
+
#: (no browser / hosted-docs dependency: the standalone distros mount this too).
|
|
97
|
+
_QUICKSTART_HTML = """
|
|
98
|
+
<h3>One folder per movie</h3>
|
|
99
|
+
<p>Each movie or field of view lives in its own <i>data folder</i> holding the raw
|
|
100
|
+
nucleus and cell images, and every stage writes its results back into that same
|
|
101
|
+
folder: the folder on disk is the source of truth for results. The list of data
|
|
102
|
+
folders and how you classify them (conditions, replicates) is your <i>project</i>,
|
|
103
|
+
which you save to and reload from a <b>project catalog</b> (a CSV). That catalog is
|
|
104
|
+
also what drives aggregate quantification across the whole set.</p>
|
|
105
|
+
|
|
106
|
+
<h3>A worked example</h3>
|
|
107
|
+
<p>Say your images sit like this, two conditions and three fields of view:</p>
|
|
108
|
+
<pre>experiment/
|
|
109
|
+
WT/
|
|
110
|
+
pos01/ nucleus.tif cell.tif
|
|
111
|
+
pos02/ nucleus.tif cell.tif
|
|
112
|
+
KO/
|
|
113
|
+
pos01/ nucleus.tif cell.tif</pre>
|
|
114
|
+
<p>Name the two files in <b>Setup</b>, then point <b>Find data folders</b> at
|
|
115
|
+
<code>experiment/</code>. ITASC adds the three folders that hold both images and
|
|
116
|
+
reads the <code>WT</code> / <code>KO</code> / <code>pos..</code> nesting into
|
|
117
|
+
columns. Select the two <code>WT</code> rows and set <code>condition = WT</code>;
|
|
118
|
+
select the <code>KO</code> row and set <code>condition = KO</code>. Those
|
|
119
|
+
columns become the grouping columns of the aggregate table.</p>
|
|
120
|
+
|
|
121
|
+
<h3>Where results go</h3>
|
|
122
|
+
<p><b>Run</b> processes the selected folders and writes each stage back inside the
|
|
123
|
+
data folder: one numbered working folder per stage, and the final outputs in the
|
|
124
|
+
folder root beside the images:</p>
|
|
125
|
+
<pre>pos01/
|
|
126
|
+
0_input 1_cellpose 2_nucleus 3_cell
|
|
127
|
+
cell_labels.tif nucleus_labels.tif contact_analysis.h5</pre>
|
|
128
|
+
<p>The status rail on each row shows how far that folder got. Run <b>Find data
|
|
129
|
+
folders</b> again on another parent to add more: the list accumulates and skips any
|
|
130
|
+
folder already listed.</p>
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def overall_status(status: dict[str, str]) -> str:
|
|
135
|
+
"""Collapse a per-stage status map into a single chip word.
|
|
136
|
+
|
|
137
|
+
``done`` when every *known* stage is committed/present; ``run`` when the
|
|
138
|
+
position has some real progress (a committed, working, or stale stage) but is
|
|
139
|
+
not finished; ``queued`` otherwise (nothing on disk, or an all-``unknown`` row
|
|
140
|
+
with no canonical root).
|
|
141
|
+
"""
|
|
142
|
+
real = [v for v in status.values() if v != UNKNOWN]
|
|
143
|
+
if real and all(v == DONE for v in real):
|
|
144
|
+
return "done"
|
|
145
|
+
if any(v in (DONE, WORKING, STALE) for v in real):
|
|
146
|
+
return "run"
|
|
147
|
+
return "queued"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _format_value(value) -> str:
|
|
151
|
+
"""Compact text for a calibration value — no trailing-zero noise."""
|
|
152
|
+
try:
|
|
153
|
+
return f"{float(value):g}"
|
|
154
|
+
except (TypeError, ValueError):
|
|
155
|
+
return str(value or "")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _count_above_root(entries: list[dict], root: str) -> int:
|
|
159
|
+
"""How many found entries resolve to a folder *above* the scanned *root*.
|
|
160
|
+
|
|
161
|
+
A folder-path key is "above root" when root is neither it nor one of its
|
|
162
|
+
ancestors: the sign of a too-deep pick (the user chose a data folder's own
|
|
163
|
+
subfolder, so its position resolves to root's parent). Keys that aren't
|
|
164
|
+
filesystem paths are ignored.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
root_resolved = Path(root).resolve()
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
return 0
|
|
170
|
+
n = 0
|
|
171
|
+
for entry in entries:
|
|
172
|
+
try:
|
|
173
|
+
key = Path(str(entry["key"])).resolve()
|
|
174
|
+
except (OSError, ValueError):
|
|
175
|
+
continue
|
|
176
|
+
if key != root_resolved and root_resolved not in key.parents:
|
|
177
|
+
n += 1
|
|
178
|
+
return n
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ExperimentRow(QWidget):
|
|
182
|
+
"""One data folder: accent select-bar, per-column value cells, rail, status chip.
|
|
183
|
+
|
|
184
|
+
Shows its rail + chip and selects/activates on click.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
clicked = Signal(str, int) # key, modifier flag: 0 plain, 1 ctrl, 2 shift
|
|
188
|
+
dot_clicked = Signal(str, str) # key, stage — one rail dot's on-demand load
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
key: str,
|
|
193
|
+
values: list[str] | None = None,
|
|
194
|
+
*,
|
|
195
|
+
stages: Sequence[str] = STAGES,
|
|
196
|
+
parent: QWidget | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
super().__init__(parent)
|
|
199
|
+
self._key = key
|
|
200
|
+
self._selected = False
|
|
201
|
+
# The row paints its own (styled) background — selected rows lift.
|
|
202
|
+
self.setObjectName("experiment_row")
|
|
203
|
+
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
204
|
+
|
|
205
|
+
layout = QHBoxLayout(self)
|
|
206
|
+
layout.setContentsMargins(7, 5, 10, 5)
|
|
207
|
+
layout.setSpacing(COMPACT_SPACING + 4)
|
|
208
|
+
|
|
209
|
+
self._selbar = QFrame()
|
|
210
|
+
self._selbar.setFixedWidth(_SELBAR_W)
|
|
211
|
+
self._selbar.setStyleSheet("background: transparent;")
|
|
212
|
+
layout.addWidget(self._selbar)
|
|
213
|
+
|
|
214
|
+
cells = list(values) if values else [key]
|
|
215
|
+
self._value_labels: list[QLabel] = []
|
|
216
|
+
for text in cells:
|
|
217
|
+
label = QLabel(str(text))
|
|
218
|
+
layout.addWidget(label, 1)
|
|
219
|
+
self._value_labels.append(label)
|
|
220
|
+
|
|
221
|
+
self.rail = StatusRail(stages)
|
|
222
|
+
self.rail.setFixedWidth(_RAIL_W)
|
|
223
|
+
self.rail.dotClicked.connect(
|
|
224
|
+
lambda stage: self.dot_clicked.emit(self._key, stage)
|
|
225
|
+
)
|
|
226
|
+
layout.addWidget(self.rail)
|
|
227
|
+
|
|
228
|
+
self._chip = QLabel("queued")
|
|
229
|
+
self._chip.setFixedWidth(_CHIP_W)
|
|
230
|
+
self._chip.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
231
|
+
self._chip.setStyleSheet(f"color: {experiment_status_color('queued')};")
|
|
232
|
+
layout.addWidget(self._chip)
|
|
233
|
+
|
|
234
|
+
self.set_selected(False)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def key(self) -> str:
|
|
238
|
+
return self._key
|
|
239
|
+
|
|
240
|
+
def is_selected(self) -> bool:
|
|
241
|
+
return self._selected
|
|
242
|
+
|
|
243
|
+
def set_selected(self, on: bool) -> None:
|
|
244
|
+
self._selected = on
|
|
245
|
+
accent = stage_accent("nucleus")
|
|
246
|
+
self._selbar.setStyleSheet(
|
|
247
|
+
f"background: {accent};" if on else "background: transparent;"
|
|
248
|
+
)
|
|
249
|
+
self.setStyleSheet(experiment_row_style(on, accent))
|
|
250
|
+
color = experiment_name_color(on)
|
|
251
|
+
for label in self._value_labels:
|
|
252
|
+
label.setStyleSheet(f"color: {color};")
|
|
253
|
+
|
|
254
|
+
def set_status(self, status: dict[str, str]) -> None:
|
|
255
|
+
"""Repaint the rail + derive the chip word from the full status map."""
|
|
256
|
+
self.rail.set_status(status)
|
|
257
|
+
word = overall_status(status)
|
|
258
|
+
self._chip.setText(_CHIP_TEXT[word])
|
|
259
|
+
self._chip.setStyleSheet(f"color: {experiment_status_color(word)};")
|
|
260
|
+
|
|
261
|
+
def mousePressEvent(self, event) -> None:
|
|
262
|
+
mods = event.modifiers()
|
|
263
|
+
if mods & Qt.ControlModifier:
|
|
264
|
+
flag = 1
|
|
265
|
+
elif mods & Qt.ShiftModifier:
|
|
266
|
+
flag = 2
|
|
267
|
+
else:
|
|
268
|
+
flag = 0
|
|
269
|
+
self.clicked.emit(self._key, flag)
|
|
270
|
+
super().mousePressEvent(event)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _event_global_y(event) -> int:
|
|
274
|
+
"""The event's global y, across the Qt5 (``globalPos``) / Qt6 split."""
|
|
275
|
+
if hasattr(event, "globalPosition"): # Qt6
|
|
276
|
+
return int(event.globalPosition().toPoint().y())
|
|
277
|
+
return int(event.globalPos().y()) # Qt5
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class _RowsResizeGrip(QWidget):
|
|
281
|
+
"""A thin handle under the rows list; drag it vertically to resize the list.
|
|
282
|
+
|
|
283
|
+
Emits ``pressed`` at grab (the panel snapshots the current height), then the
|
|
284
|
+
signed pixel delta since that press on every move — so the panel can size
|
|
285
|
+
the list live. A double-click emits ``reset`` to drop back to auto-fit.
|
|
286
|
+
Purely a controller: it owns no size state of its own.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
pressed = Signal()
|
|
290
|
+
dragged = Signal(int) # pixels moved since press (down positive)
|
|
291
|
+
reset = Signal()
|
|
292
|
+
|
|
293
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
294
|
+
super().__init__(parent)
|
|
295
|
+
self.setObjectName("experiments_rows_grip")
|
|
296
|
+
self.setFixedHeight(_GRIP_H)
|
|
297
|
+
self.setCursor(Qt.SizeVerCursor)
|
|
298
|
+
self.setToolTip("Drag to resize the list — double-click to auto-fit")
|
|
299
|
+
self._press_y: int | None = None
|
|
300
|
+
self._hover = False
|
|
301
|
+
|
|
302
|
+
def enterEvent(self, event) -> None:
|
|
303
|
+
self._hover = True
|
|
304
|
+
self.update()
|
|
305
|
+
super().enterEvent(event)
|
|
306
|
+
|
|
307
|
+
def leaveEvent(self, event) -> None:
|
|
308
|
+
self._hover = False
|
|
309
|
+
self.update()
|
|
310
|
+
super().leaveEvent(event)
|
|
311
|
+
|
|
312
|
+
def paintEvent(self, event) -> None:
|
|
313
|
+
# A faint strip carrying a centred grab-bar — a recognisable drag handle
|
|
314
|
+
# (brighter on hover) rather than an invisible hit-zone.
|
|
315
|
+
painter = QPainter(self)
|
|
316
|
+
painter.setRenderHint(QPainter.Antialiasing, True)
|
|
317
|
+
painter.setPen(Qt.NoPen)
|
|
318
|
+
width, height = self.width(), self.height()
|
|
319
|
+
strip_a = 26 if self._hover else 16
|
|
320
|
+
painter.setBrush(QColor(128, 128, 128, strip_a))
|
|
321
|
+
painter.drawRect(0, 0, width, height)
|
|
322
|
+
bar_w, bar_h = 44, 4
|
|
323
|
+
bar_x = (width - bar_w) // 2
|
|
324
|
+
bar_y = (height - bar_h) // 2
|
|
325
|
+
bar_a = 200 if self._hover else 130
|
|
326
|
+
painter.setBrush(QColor(128, 128, 128, bar_a))
|
|
327
|
+
painter.drawRoundedRect(bar_x, bar_y, bar_w, bar_h, bar_h // 2, bar_h // 2)
|
|
328
|
+
|
|
329
|
+
def mousePressEvent(self, event) -> None:
|
|
330
|
+
self._press_y = _event_global_y(event)
|
|
331
|
+
self.pressed.emit()
|
|
332
|
+
event.accept()
|
|
333
|
+
|
|
334
|
+
def mouseMoveEvent(self, event) -> None:
|
|
335
|
+
if self._press_y is None:
|
|
336
|
+
return
|
|
337
|
+
self.dragged.emit(_event_global_y(event) - self._press_y)
|
|
338
|
+
event.accept()
|
|
339
|
+
|
|
340
|
+
def mouseReleaseEvent(self, event) -> None:
|
|
341
|
+
self._press_y = None
|
|
342
|
+
event.accept()
|
|
343
|
+
|
|
344
|
+
def mouseDoubleClickEvent(self, event) -> None:
|
|
345
|
+
self.reset.emit()
|
|
346
|
+
event.accept()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ExperimentsPanel(QWidget):
|
|
350
|
+
"""Setup + an additive Find + an editable-column list of data-folder rows.
|
|
351
|
+
|
|
352
|
+
Host contract:
|
|
353
|
+
|
|
354
|
+
* ``discover_fn(root, input_names) -> list[entry]`` — scan a root; each *entry*
|
|
355
|
+
is ``{"key": str, "columns": {name: value}, "payload": dict}`` (``key`` is the
|
|
356
|
+
row identity, typically the data-folder path).
|
|
357
|
+
* ``status_fn(payload) -> {stage: state}`` — the per-stage rail status.
|
|
358
|
+
|
|
359
|
+
Signals let the host stay the model of record for persistence/scope while the
|
|
360
|
+
panel owns the list UI, Setup, folder discovery, and selection.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
active_changed = Signal(object) # payload | None — the single active row
|
|
364
|
+
selection_changed = Signal() # multi-selection changed (read selected_payloads)
|
|
365
|
+
records_changed = Signal() # committed row set changed
|
|
366
|
+
run_requested = Signal(list, int) # (selected payloads, num_workers)
|
|
367
|
+
stage_load_requested = Signal(object, str) # (payload, stage)
|
|
368
|
+
calibration_changed = Signal(str, str) # (name, text)
|
|
369
|
+
discover_requested = Signal() # Find-data-folders button clicked
|
|
370
|
+
output_dir_requested = Signal() # output-dir button clicked
|
|
371
|
+
|
|
372
|
+
def __init__(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
title: str = "Data folders",
|
|
376
|
+
input_fields: Iterable[tuple[str, str, str]] = (),
|
|
377
|
+
discover_fn: Callable[[str, dict[str, str]], list[dict]] | None = None,
|
|
378
|
+
status_fn: Callable[[dict], dict[str, str]] | None = None,
|
|
379
|
+
stages: Sequence[str] = STAGES,
|
|
380
|
+
show_calibration: bool = True,
|
|
381
|
+
show_output_dir: bool = False,
|
|
382
|
+
show_run: bool = True,
|
|
383
|
+
max_rows_height: int = 480,
|
|
384
|
+
parent: QWidget | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
super().__init__(parent)
|
|
387
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
388
|
+
self._input_fields = list(input_fields)
|
|
389
|
+
self._discover_fn = discover_fn
|
|
390
|
+
self._status_fn = status_fn
|
|
391
|
+
self._stages = tuple(stages)
|
|
392
|
+
self._show_calibration = show_calibration
|
|
393
|
+
self._show_output_dir = show_output_dir
|
|
394
|
+
self._show_run = show_run
|
|
395
|
+
self._max_rows_height = max_rows_height
|
|
396
|
+
# None → auto-fit (flush to rows, capped at max); an int → the user has
|
|
397
|
+
# dragged the grip to an explicit height. _grip_base is the drag anchor.
|
|
398
|
+
self._rows_height_override: int | None = None
|
|
399
|
+
self._grip_base: int | None = None
|
|
400
|
+
self._applied_rows_h: int = max_rows_height
|
|
401
|
+
|
|
402
|
+
# Committed rows: ordered keys + per-key {columns, payload}; the shared,
|
|
403
|
+
# editable column names line up over the value cells.
|
|
404
|
+
self._paths: list[str] = []
|
|
405
|
+
self._records: dict[str, dict] = {}
|
|
406
|
+
self._column_names: list[str] = []
|
|
407
|
+
self._rows: list[ExperimentRow] = []
|
|
408
|
+
|
|
409
|
+
# Committed-row selection: a single active row plus a multi-selection for
|
|
410
|
+
# delete/run; ``_anchor`` is the Shift-range pivot.
|
|
411
|
+
self._active: str | None = None
|
|
412
|
+
self._selected_paths: set[str] = set()
|
|
413
|
+
self._anchor: str | None = None
|
|
414
|
+
|
|
415
|
+
layout = QVBoxLayout(self)
|
|
416
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
417
|
+
layout.setSpacing(COMPACT_SPACING)
|
|
418
|
+
|
|
419
|
+
layout.addLayout(self._build_heading(title))
|
|
420
|
+
|
|
421
|
+
self.setup_section = self._build_setup_section()
|
|
422
|
+
layout.addWidget(self.setup_section)
|
|
423
|
+
|
|
424
|
+
# One status line, double duty: the empty-state call to action when the
|
|
425
|
+
# list is empty, transient "Added N…" / dry-scan feedback after a Find.
|
|
426
|
+
self._hint = QLabel("")
|
|
427
|
+
self._hint.setObjectName("experiments_hint_label")
|
|
428
|
+
self._hint.setWordWrap(True)
|
|
429
|
+
self._hint.setStyleSheet(f"color: {TEXT_DIM};")
|
|
430
|
+
layout.addWidget(self._hint)
|
|
431
|
+
|
|
432
|
+
# Rows in a bounded scroll region: a long list scrolls internally instead
|
|
433
|
+
# of shoving the action bar off-screen. The editable column header is the
|
|
434
|
+
# first item inside, so it always aligns with the value cells below.
|
|
435
|
+
self._rows_box = QVBoxLayout()
|
|
436
|
+
self._rows_box.setContentsMargins(0, 0, 0, 0)
|
|
437
|
+
self._rows_box.setSpacing(2)
|
|
438
|
+
self._rows_box.setAlignment(Qt.AlignTop)
|
|
439
|
+
rows_container = QWidget()
|
|
440
|
+
rows_container.setLayout(self._rows_box)
|
|
441
|
+
self._rows_scroll = QScrollArea()
|
|
442
|
+
self._rows_scroll.setObjectName("experiments_rows_scroll")
|
|
443
|
+
self._rows_scroll.setWidgetResizable(True)
|
|
444
|
+
# Height tracks content (see _fit_rows_height): grows flush to the rows,
|
|
445
|
+
# clamped to max_rows_height, above which the list scrolls internally.
|
|
446
|
+
# Fixed vertical policy (not the QScrollArea default Expanding) so the
|
|
447
|
+
# pinned height is honoured verbatim — otherwise the panel reads as
|
|
448
|
+
# expandable and soaks up its host's spare space, ballooning the labels.
|
|
449
|
+
self._rows_container = rows_container
|
|
450
|
+
self._rows_scroll.setSizePolicy(
|
|
451
|
+
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
|
|
452
|
+
)
|
|
453
|
+
self._rows_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
454
|
+
self._rows_scroll.setWidget(rows_container)
|
|
455
|
+
layout.addWidget(self._rows_scroll)
|
|
456
|
+
|
|
457
|
+
# A drag handle flush under the list to resize it by hand; double-click
|
|
458
|
+
# to return to auto-fit. Hidden alongside the list when there are no rows.
|
|
459
|
+
self._rows_grip = _RowsResizeGrip()
|
|
460
|
+
self._rows_grip.pressed.connect(self._on_grip_pressed)
|
|
461
|
+
self._rows_grip.dragged.connect(self._on_grip_dragged)
|
|
462
|
+
self._rows_grip.reset.connect(self._on_grip_reset)
|
|
463
|
+
layout.addWidget(self._rows_grip)
|
|
464
|
+
|
|
465
|
+
layout.addLayout(self._build_list_actions())
|
|
466
|
+
if self._show_run:
|
|
467
|
+
layout.addLayout(self._build_run_actions())
|
|
468
|
+
|
|
469
|
+
self._meta = QLabel("")
|
|
470
|
+
self._meta.setStyleSheet(f"color: {TEXT_DIM};")
|
|
471
|
+
layout.addWidget(self._meta)
|
|
472
|
+
|
|
473
|
+
self._rebuild_table()
|
|
474
|
+
self._update_meta()
|
|
475
|
+
|
|
476
|
+
# -- heading + quickstart --------------------------------------------
|
|
477
|
+
def _build_heading(self, title: str) -> QHBoxLayout:
|
|
478
|
+
row = QHBoxLayout()
|
|
479
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
480
|
+
heading = QLabel(title)
|
|
481
|
+
heading.setObjectName("experiments_panel_label")
|
|
482
|
+
heading.setStyleSheet(f"color: {TEXT_MID}; font-weight: bold;")
|
|
483
|
+
row.addWidget(heading)
|
|
484
|
+
row.addStretch()
|
|
485
|
+
self.help_btn = QToolButton()
|
|
486
|
+
self.help_btn.setObjectName("experiments_help_button")
|
|
487
|
+
self.help_btn.setText("?")
|
|
488
|
+
self.help_btn.setToolTip("Quickstart: how ITASC manages your data")
|
|
489
|
+
self.help_btn.clicked.connect(self._show_quickstart)
|
|
490
|
+
row.addWidget(self.help_btn)
|
|
491
|
+
return row
|
|
492
|
+
|
|
493
|
+
def _show_quickstart(self) -> None:
|
|
494
|
+
dialog = QDialog(self)
|
|
495
|
+
dialog.setObjectName("experiments_quickstart_dialog")
|
|
496
|
+
dialog.setWindowTitle("ITASC quickstart")
|
|
497
|
+
dialog.setMinimumWidth(460)
|
|
498
|
+
box = QVBoxLayout(dialog)
|
|
499
|
+
body = QTextBrowser()
|
|
500
|
+
body.setOpenExternalLinks(False)
|
|
501
|
+
body.setHtml(_QUICKSTART_HTML)
|
|
502
|
+
box.addWidget(body)
|
|
503
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
|
504
|
+
buttons.rejected.connect(dialog.reject)
|
|
505
|
+
buttons.accepted.connect(dialog.accept)
|
|
506
|
+
box.addWidget(buttons)
|
|
507
|
+
dialog.exec_()
|
|
508
|
+
|
|
509
|
+
# -- setup: calibration + input names + optional output dir ----------
|
|
510
|
+
def _build_setup_section(self) -> CollapsibleSection:
|
|
511
|
+
inner = QWidget()
|
|
512
|
+
box = QVBoxLayout(inner)
|
|
513
|
+
box.setContentsMargins(0, 0, 0, 0)
|
|
514
|
+
box.setSpacing(COMPACT_SPACING)
|
|
515
|
+
if self._show_calibration:
|
|
516
|
+
box.addLayout(self._build_calibration_row())
|
|
517
|
+
box.addLayout(self._build_input_config())
|
|
518
|
+
if self._show_output_dir:
|
|
519
|
+
box.addLayout(self._build_output_dir_row())
|
|
520
|
+
return CollapsibleSection("Setup", inner, expanded=True, title_color=TEXT_MID)
|
|
521
|
+
|
|
522
|
+
def _build_calibration_row(self) -> QHBoxLayout:
|
|
523
|
+
self.calibration_controls: dict[str, QLineEdit] = {}
|
|
524
|
+
cal = QHBoxLayout()
|
|
525
|
+
cal.setContentsMargins(0, 0, 0, 0)
|
|
526
|
+
cal.setSpacing(COMPACT_SPACING + 4)
|
|
527
|
+
for name, label, min_val, max_val in _CALIBRATION_SPECS:
|
|
528
|
+
field = QLineEdit()
|
|
529
|
+
validator = QDoubleValidator(min_val, max_val, _INPUT_DECIMALS, field)
|
|
530
|
+
validator.setNotation(QDoubleValidator.StandardNotation)
|
|
531
|
+
field.setValidator(validator)
|
|
532
|
+
field.setObjectName(f"experiments_calibration_{name}")
|
|
533
|
+
field.setStyleSheet(mono_input_style())
|
|
534
|
+
field.editingFinished.connect(
|
|
535
|
+
lambda n=name, c=field: self.calibration_changed.emit(n, c.text())
|
|
536
|
+
)
|
|
537
|
+
self.calibration_controls[name] = field
|
|
538
|
+
|
|
539
|
+
caption = QLabel(label)
|
|
540
|
+
caption.setStyleSheet(f"color: {TEXT_MID};")
|
|
541
|
+
cell = QVBoxLayout()
|
|
542
|
+
cell.setContentsMargins(0, 0, 0, 0)
|
|
543
|
+
cell.setSpacing(1)
|
|
544
|
+
cell.addWidget(caption)
|
|
545
|
+
cell.addWidget(field)
|
|
546
|
+
cal.addLayout(cell, 1)
|
|
547
|
+
return cal
|
|
548
|
+
|
|
549
|
+
def calibration_values(self) -> dict[str, str]:
|
|
550
|
+
if not self._show_calibration:
|
|
551
|
+
return {}
|
|
552
|
+
return {n: c.text().strip() for n, c in self.calibration_controls.items()}
|
|
553
|
+
|
|
554
|
+
def set_calibration_values(self, values: dict) -> None:
|
|
555
|
+
if not self._show_calibration:
|
|
556
|
+
return
|
|
557
|
+
for name, control in self.calibration_controls.items():
|
|
558
|
+
if name in values:
|
|
559
|
+
control.blockSignals(True)
|
|
560
|
+
control.setText(_format_value(values[name]))
|
|
561
|
+
control.blockSignals(False)
|
|
562
|
+
|
|
563
|
+
def _build_input_config(self) -> QVBoxLayout:
|
|
564
|
+
"""Input file names — the discovery requirements, copied to each scan."""
|
|
565
|
+
box = QVBoxLayout()
|
|
566
|
+
box.setContentsMargins(0, 0, 0, 0)
|
|
567
|
+
box.setSpacing(COMPACT_SPACING)
|
|
568
|
+
files = QFormLayout()
|
|
569
|
+
files.setContentsMargins(0, 0, 0, 0)
|
|
570
|
+
files.setSpacing(2)
|
|
571
|
+
self.input_name_fields: dict[str, QLineEdit] = {}
|
|
572
|
+
for key, label, default in self._input_fields:
|
|
573
|
+
field = QLineEdit(default)
|
|
574
|
+
field.setObjectName(f"experiments_input_{key}")
|
|
575
|
+
field.setStyleSheet(mono_input_style())
|
|
576
|
+
self.input_name_fields[key] = field
|
|
577
|
+
name = QLabel(label)
|
|
578
|
+
name.setStyleSheet(f"color: {TEXT_MID};")
|
|
579
|
+
files.addRow(name, field)
|
|
580
|
+
box.addLayout(files)
|
|
581
|
+
return box
|
|
582
|
+
|
|
583
|
+
def input_names(self) -> dict[str, str]:
|
|
584
|
+
"""Current non-blank input file names, keyed by field key."""
|
|
585
|
+
return {
|
|
586
|
+
key: field.text().strip()
|
|
587
|
+
for key, field in self.input_name_fields.items()
|
|
588
|
+
if field.text().strip()
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
def _build_output_dir_row(self) -> QHBoxLayout:
|
|
592
|
+
out = QHBoxLayout()
|
|
593
|
+
out.setContentsMargins(0, 0, 0, 0)
|
|
594
|
+
self.output_dir_button = QToolButton()
|
|
595
|
+
self.output_dir_button.setObjectName("experiments_output_dir_button")
|
|
596
|
+
self.output_dir_button.setText("Add custom output directory")
|
|
597
|
+
self.output_dir_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
|
598
|
+
self.output_dir_button.clicked.connect(self.output_dir_requested)
|
|
599
|
+
self.output_dir_label = QLabel("")
|
|
600
|
+
self.output_dir_label.setObjectName("experiments_output_dir_label")
|
|
601
|
+
self.output_dir_label.setStyleSheet(f"color: {TEXT_DIM};")
|
|
602
|
+
self.output_dir_label.setVisible(False)
|
|
603
|
+
out.addWidget(self.output_dir_button)
|
|
604
|
+
out.addWidget(self.output_dir_label, 1)
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
def set_output_dir_text(self, text: str) -> None:
|
|
608
|
+
if not self._show_output_dir:
|
|
609
|
+
return
|
|
610
|
+
self.output_dir_label.setText(text)
|
|
611
|
+
self.output_dir_label.setToolTip(text)
|
|
612
|
+
self.output_dir_label.setVisible(bool(text))
|
|
613
|
+
self.output_dir_button.setText(
|
|
614
|
+
"Change output directory" if text else "Add custom output directory"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# -- list + run actions ----------------------------------------------
|
|
618
|
+
def _build_list_actions(self) -> QHBoxLayout:
|
|
619
|
+
row = QHBoxLayout()
|
|
620
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
621
|
+
self.discover_btn = QToolButton()
|
|
622
|
+
self.discover_btn.setObjectName("experiments_discover_button")
|
|
623
|
+
self.discover_btn.setText("Find data folders…")
|
|
624
|
+
self.discover_btn.setToolTip(
|
|
625
|
+
"Pick a parent directory; every folder under it holding the Setup "
|
|
626
|
+
"image files is added to the list (run again to add more)."
|
|
627
|
+
)
|
|
628
|
+
self.discover_btn.setStyleSheet(action_button_style())
|
|
629
|
+
self.discover_btn.clicked.connect(self.discover_requested)
|
|
630
|
+
row.addWidget(self.discover_btn)
|
|
631
|
+
|
|
632
|
+
self.delete_btn = QToolButton()
|
|
633
|
+
self.delete_btn.setObjectName("experiments_delete_button")
|
|
634
|
+
self.delete_btn.setText("Delete selected")
|
|
635
|
+
self.delete_btn.setToolTip(
|
|
636
|
+
"Remove the selected rows (Ctrl/Shift-click to select several)"
|
|
637
|
+
)
|
|
638
|
+
self.delete_btn.setEnabled(False)
|
|
639
|
+
self.delete_btn.setStyleSheet(action_button_style())
|
|
640
|
+
self.delete_btn.clicked.connect(self.delete_selected)
|
|
641
|
+
row.addWidget(self.delete_btn)
|
|
642
|
+
row.addStretch()
|
|
643
|
+
return row
|
|
644
|
+
|
|
645
|
+
def _build_run_actions(self) -> QHBoxLayout:
|
|
646
|
+
row = QHBoxLayout()
|
|
647
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
648
|
+
self.run_btn = QToolButton()
|
|
649
|
+
self.run_btn.setObjectName("experiments_run_button")
|
|
650
|
+
self.run_btn.setText("Run selected")
|
|
651
|
+
self.run_btn.setToolTip(
|
|
652
|
+
"Run the selected rows (Ctrl/Shift-click for several, Ctrl+A for all)"
|
|
653
|
+
)
|
|
654
|
+
self.run_btn.setEnabled(False)
|
|
655
|
+
self.run_btn.setStyleSheet(action_button_style())
|
|
656
|
+
self.run_btn.clicked.connect(self._on_run_clicked)
|
|
657
|
+
row.addWidget(self.run_btn)
|
|
658
|
+
row.addStretch()
|
|
659
|
+
|
|
660
|
+
workers_label = QLabel("Workers:")
|
|
661
|
+
workers_label.setStyleSheet(f"color: {TEXT_DIM};")
|
|
662
|
+
row.addWidget(workers_label)
|
|
663
|
+
self.workers_spinbox = QSpinBox()
|
|
664
|
+
self.workers_spinbox.setObjectName("experiments_workers_spinbox")
|
|
665
|
+
self.workers_spinbox.setRange(1, os.cpu_count() or 1)
|
|
666
|
+
self.workers_spinbox.setValue(1)
|
|
667
|
+
self.workers_spinbox.setToolTip("How many data folders Run processes in parallel")
|
|
668
|
+
row.addWidget(self.workers_spinbox)
|
|
669
|
+
return row
|
|
670
|
+
|
|
671
|
+
def num_workers(self) -> int:
|
|
672
|
+
return self.workers_spinbox.value() if self._show_run else 1
|
|
673
|
+
|
|
674
|
+
def _on_run_clicked(self) -> None:
|
|
675
|
+
self.run_requested.emit(self.selected_payloads(), self.num_workers())
|
|
676
|
+
|
|
677
|
+
# -- discovery (one additive Find) -----------------------------------
|
|
678
|
+
def discover(self, root: str) -> list[dict]:
|
|
679
|
+
"""Scan *root* via ``discover_fn`` and add every match straight to the list.
|
|
680
|
+
|
|
681
|
+
Additive and de-duped by key: re-running against another root accumulates
|
|
682
|
+
and never disturbs existing (possibly edited) rows. Returns the newly added
|
|
683
|
+
entries and leaves a transient hint naming what happened.
|
|
684
|
+
"""
|
|
685
|
+
if self._discover_fn is None:
|
|
686
|
+
return []
|
|
687
|
+
found = list(self._discover_fn(root, self.input_names()))
|
|
688
|
+
added = self._add_entries(found)
|
|
689
|
+
if added:
|
|
690
|
+
message = (
|
|
691
|
+
f"Added {added} data folder{'s' if added != 1 else ''}."
|
|
692
|
+
+ ("" if added == len(found) else f" ({len(found) - added} already listed.)")
|
|
693
|
+
)
|
|
694
|
+
above = _count_above_root(found, root)
|
|
695
|
+
if above:
|
|
696
|
+
message += (
|
|
697
|
+
f" {'It sits' if above == 1 else f'{above} sit'} above the folder "
|
|
698
|
+
"you picked: point Find at the parent directory to scan a whole batch."
|
|
699
|
+
)
|
|
700
|
+
self._refresh_hint(message)
|
|
701
|
+
else:
|
|
702
|
+
self._refresh_hint(
|
|
703
|
+
f"No new data folders found under {root} — "
|
|
704
|
+
"check the image filenames in Setup."
|
|
705
|
+
if not found
|
|
706
|
+
else f"All {len(found)} found data folders were already listed."
|
|
707
|
+
)
|
|
708
|
+
return found
|
|
709
|
+
|
|
710
|
+
# -- committed-row model ---------------------------------------------
|
|
711
|
+
def column_names(self) -> list[str]:
|
|
712
|
+
return list(self._column_names)
|
|
713
|
+
|
|
714
|
+
def rename_column(self, index: int, new_name: str) -> None:
|
|
715
|
+
"""Rename column *index* table-wide, carrying each row's value across."""
|
|
716
|
+
if not 0 <= index < len(self._column_names):
|
|
717
|
+
return
|
|
718
|
+
old = self._column_names[index]
|
|
719
|
+
new = new_name.strip()
|
|
720
|
+
if not new or new == old:
|
|
721
|
+
return
|
|
722
|
+
self._column_names[index] = new
|
|
723
|
+
for key in self._paths:
|
|
724
|
+
cols = self._records[key]["columns"]
|
|
725
|
+
if old in cols:
|
|
726
|
+
cols[new] = cols.pop(old)
|
|
727
|
+
self._rebuild_table()
|
|
728
|
+
self.records_changed.emit()
|
|
729
|
+
|
|
730
|
+
def remove_column(self, index: int) -> None:
|
|
731
|
+
"""Drop column *index* table-wide, discarding each row's value for it.
|
|
732
|
+
|
|
733
|
+
A folder-derived column re-appears on the next Discover; this only
|
|
734
|
+
removes it from the current committed list.
|
|
735
|
+
"""
|
|
736
|
+
if not 0 <= index < len(self._column_names):
|
|
737
|
+
return
|
|
738
|
+
name = self._column_names.pop(index)
|
|
739
|
+
for key in self._paths:
|
|
740
|
+
self._records[key]["columns"].pop(name, None)
|
|
741
|
+
self._rebuild_table()
|
|
742
|
+
self.records_changed.emit()
|
|
743
|
+
|
|
744
|
+
def _ensure_columns(self, names: Iterable[str]) -> None:
|
|
745
|
+
for name in names:
|
|
746
|
+
if name and name not in self._column_names:
|
|
747
|
+
self._column_names.append(name)
|
|
748
|
+
|
|
749
|
+
def _add_entries(self, entries: list[dict]) -> int:
|
|
750
|
+
"""Append entries as committed rows (de-duped by key), extending columns.
|
|
751
|
+
|
|
752
|
+
Returns the number of rows actually added (skips keys already listed).
|
|
753
|
+
"""
|
|
754
|
+
was_empty = not self._paths
|
|
755
|
+
added = 0
|
|
756
|
+
for entry in entries:
|
|
757
|
+
key = str(entry["key"])
|
|
758
|
+
if key in self._records:
|
|
759
|
+
continue
|
|
760
|
+
cols = dict(entry.get("columns") or {})
|
|
761
|
+
self._ensure_columns(cols.keys())
|
|
762
|
+
self._records[key] = {"columns": cols, "payload": entry.get("payload")}
|
|
763
|
+
self._paths.append(key)
|
|
764
|
+
added += 1
|
|
765
|
+
if not added:
|
|
766
|
+
return 0
|
|
767
|
+
self._rebuild_table()
|
|
768
|
+
self._update_meta()
|
|
769
|
+
self.records_changed.emit()
|
|
770
|
+
if was_empty:
|
|
771
|
+
self.set_active(self._paths[0])
|
|
772
|
+
self.setup_section.collapse()
|
|
773
|
+
return added
|
|
774
|
+
|
|
775
|
+
def set_records(self, entries: list[dict]) -> None:
|
|
776
|
+
"""Replace the whole committed list from host-owned records (CSV / load).
|
|
777
|
+
|
|
778
|
+
Each entry is ``{"key", "columns", "payload"}``. The shared column header is
|
|
779
|
+
rebuilt from the union of column names (first-seen order).
|
|
780
|
+
"""
|
|
781
|
+
self._paths = []
|
|
782
|
+
self._records = {}
|
|
783
|
+
self._column_names = []
|
|
784
|
+
self._selected_paths = set()
|
|
785
|
+
for entry in entries:
|
|
786
|
+
key = str(entry["key"])
|
|
787
|
+
if key in self._records:
|
|
788
|
+
continue
|
|
789
|
+
cols = dict(entry.get("columns") or {})
|
|
790
|
+
self._ensure_columns(cols.keys())
|
|
791
|
+
self._records[key] = {"columns": cols, "payload": entry.get("payload")}
|
|
792
|
+
self._paths.append(key)
|
|
793
|
+
if self._active not in self._paths:
|
|
794
|
+
self._active = None
|
|
795
|
+
self._rebuild_table()
|
|
796
|
+
self._update_meta()
|
|
797
|
+
self.setup_section.expand() if not self._paths else self.setup_section.collapse()
|
|
798
|
+
self.records_changed.emit()
|
|
799
|
+
|
|
800
|
+
def payloads(self) -> list[object]:
|
|
801
|
+
"""Every committed row's payload, in row order."""
|
|
802
|
+
return [self._records[key]["payload"] for key in self._paths]
|
|
803
|
+
|
|
804
|
+
def selected_payloads(self) -> list[object]:
|
|
805
|
+
return [
|
|
806
|
+
self._records[key]["payload"]
|
|
807
|
+
for key in self._paths
|
|
808
|
+
if key in self._selected_paths
|
|
809
|
+
]
|
|
810
|
+
|
|
811
|
+
def active_payload(self) -> object:
|
|
812
|
+
return self._records[self._active]["payload"] if self._active else None
|
|
813
|
+
|
|
814
|
+
def _row_record(self, key: str) -> object:
|
|
815
|
+
"""One row's payload with the *live* editable columns merged in.
|
|
816
|
+
|
|
817
|
+
When the payload is a dict (the catalog-record case), the row's current
|
|
818
|
+
column values — including in-header renames — override its ``columns`` bag,
|
|
819
|
+
so a host that reads :meth:`records` always sees what the header shows.
|
|
820
|
+
"""
|
|
821
|
+
rec = self._records[key]
|
|
822
|
+
payload = rec["payload"]
|
|
823
|
+
if isinstance(payload, dict):
|
|
824
|
+
return {**payload, "columns": dict(rec["columns"])}
|
|
825
|
+
return payload
|
|
826
|
+
|
|
827
|
+
def records(self) -> list[dict]:
|
|
828
|
+
"""Every committed row as ``payload + live columns`` (row order)."""
|
|
829
|
+
return [self._row_record(key) for key in self._paths]
|
|
830
|
+
|
|
831
|
+
def selected_records(self) -> list[dict]:
|
|
832
|
+
"""In-scope rows as ``payload + live columns`` (empty if nothing selected)."""
|
|
833
|
+
return [
|
|
834
|
+
self._row_record(key) for key in self._paths if key in self._selected_paths
|
|
835
|
+
]
|
|
836
|
+
|
|
837
|
+
def active_record(self) -> object:
|
|
838
|
+
return self._row_record(self._active) if self._active else None
|
|
839
|
+
|
|
840
|
+
def keys(self) -> list[str]:
|
|
841
|
+
return list(self._paths)
|
|
842
|
+
|
|
843
|
+
# -- selection --------------------------------------------------------
|
|
844
|
+
def set_active(self, key: str | None, *, selection=None) -> None:
|
|
845
|
+
if key is not None and key not in self._paths:
|
|
846
|
+
return
|
|
847
|
+
changed = key != self._active
|
|
848
|
+
self._active = key
|
|
849
|
+
if selection is None:
|
|
850
|
+
self._selected_paths = {key} if key else set()
|
|
851
|
+
else:
|
|
852
|
+
self._selected_paths = {k for k in selection if k in self._paths}
|
|
853
|
+
self._apply_selection_styles()
|
|
854
|
+
self._update_action_buttons()
|
|
855
|
+
if changed:
|
|
856
|
+
self.active_changed.emit(self.active_payload())
|
|
857
|
+
self.selection_changed.emit()
|
|
858
|
+
|
|
859
|
+
def select_all(self) -> None:
|
|
860
|
+
self._selected_paths = set(self._paths)
|
|
861
|
+
self._apply_selection_styles()
|
|
862
|
+
self._update_action_buttons()
|
|
863
|
+
self.selection_changed.emit()
|
|
864
|
+
|
|
865
|
+
def clear_selection(self) -> None:
|
|
866
|
+
self._active = None
|
|
867
|
+
self._selected_paths = set()
|
|
868
|
+
self._anchor = None
|
|
869
|
+
self._apply_selection_styles()
|
|
870
|
+
self._update_action_buttons()
|
|
871
|
+
self.selection_changed.emit()
|
|
872
|
+
|
|
873
|
+
def delete_selected(self) -> None:
|
|
874
|
+
"""Remove the selected committed rows."""
|
|
875
|
+
if not self._selected_paths:
|
|
876
|
+
return
|
|
877
|
+
remaining = [k for k in self._paths if k not in self._selected_paths]
|
|
878
|
+
if self._active in self._selected_paths:
|
|
879
|
+
self._active = None
|
|
880
|
+
self._selected_paths = set()
|
|
881
|
+
self._paths = remaining
|
|
882
|
+
self._records = {k: self._records[k] for k in remaining}
|
|
883
|
+
self._rebuild_table()
|
|
884
|
+
self._update_meta()
|
|
885
|
+
self.records_changed.emit()
|
|
886
|
+
self.selection_changed.emit()
|
|
887
|
+
|
|
888
|
+
def _on_row_clicked(self, key: str, flag: int) -> None:
|
|
889
|
+
self.setFocus()
|
|
890
|
+
if flag == 1: # Ctrl: toggle
|
|
891
|
+
selection = set(self._selected_paths)
|
|
892
|
+
selection.discard(key) if key in selection else selection.add(key)
|
|
893
|
+
self._anchor = key
|
|
894
|
+
active = key if key in selection else next(iter(selection), None)
|
|
895
|
+
self.set_active(active, selection=selection)
|
|
896
|
+
elif flag == 2 and self._anchor in self._paths: # Shift: range
|
|
897
|
+
lo, hi = sorted((self._paths.index(self._anchor), self._paths.index(key)))
|
|
898
|
+
self.set_active(key, selection=set(self._paths[lo : hi + 1]))
|
|
899
|
+
else: # plain: single select + activate
|
|
900
|
+
self._anchor = key
|
|
901
|
+
self.set_active(key)
|
|
902
|
+
|
|
903
|
+
def _apply_selection_styles(self) -> None:
|
|
904
|
+
for row in self._rows:
|
|
905
|
+
row.set_selected(row.key in self._selected_paths)
|
|
906
|
+
|
|
907
|
+
def _update_action_buttons(self) -> None:
|
|
908
|
+
has_sel = bool(self._selected_paths)
|
|
909
|
+
self.delete_btn.setEnabled(has_sel)
|
|
910
|
+
if self._show_run:
|
|
911
|
+
self.run_btn.setEnabled(has_sel)
|
|
912
|
+
|
|
913
|
+
def keyPressEvent(self, event) -> None:
|
|
914
|
+
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and self._selected_paths:
|
|
915
|
+
self.delete_selected()
|
|
916
|
+
event.accept()
|
|
917
|
+
return
|
|
918
|
+
if event.key() == Qt.Key_A and event.modifiers() & Qt.ControlModifier:
|
|
919
|
+
self.select_all()
|
|
920
|
+
event.accept()
|
|
921
|
+
return
|
|
922
|
+
super().keyPressEvent(event)
|
|
923
|
+
|
|
924
|
+
# -- status -----------------------------------------------------------
|
|
925
|
+
def refresh_statuses(self) -> None:
|
|
926
|
+
"""Re-read every committed row's stage status and repaint rail + chip."""
|
|
927
|
+
if self._status_fn is None:
|
|
928
|
+
return
|
|
929
|
+
for row in self._rows:
|
|
930
|
+
payload = self._records[row.key]["payload"]
|
|
931
|
+
try:
|
|
932
|
+
status = self._status_fn(payload)
|
|
933
|
+
except Exception:
|
|
934
|
+
status = {stage: UNKNOWN for stage in self._stages}
|
|
935
|
+
row.set_status(status)
|
|
936
|
+
|
|
937
|
+
def _on_row_dot_clicked(self, key: str, stage: str) -> None:
|
|
938
|
+
payload = self._records.get(key, {}).get("payload")
|
|
939
|
+
self.stage_load_requested.emit(payload, stage)
|
|
940
|
+
|
|
941
|
+
# -- rendering --------------------------------------------------------
|
|
942
|
+
def _build_header_widget(self) -> QWidget:
|
|
943
|
+
widget = QWidget()
|
|
944
|
+
widget.setObjectName("experiments_table_header")
|
|
945
|
+
row = QHBoxLayout(widget)
|
|
946
|
+
row.setContentsMargins(7, 5, 10, 5)
|
|
947
|
+
row.setSpacing(COMPACT_SPACING + 4)
|
|
948
|
+
|
|
949
|
+
lead = QWidget()
|
|
950
|
+
lead.setFixedWidth(_SELBAR_W)
|
|
951
|
+
row.addWidget(lead)
|
|
952
|
+
|
|
953
|
+
self._header_fields: list[QLineEdit] = []
|
|
954
|
+
if self._column_names:
|
|
955
|
+
for index, name in enumerate(self._column_names):
|
|
956
|
+
cell = QWidget()
|
|
957
|
+
cell_row = QHBoxLayout(cell)
|
|
958
|
+
cell_row.setContentsMargins(0, 0, 0, 0)
|
|
959
|
+
cell_row.setSpacing(2)
|
|
960
|
+
field = QLineEdit(name)
|
|
961
|
+
field.setObjectName(f"experiments_column_header_{index}")
|
|
962
|
+
field.setStyleSheet(mono_input_style())
|
|
963
|
+
field.editingFinished.connect(
|
|
964
|
+
lambda i=index, f=field: self.rename_column(i, f.text())
|
|
965
|
+
)
|
|
966
|
+
cell_row.addWidget(field, 1)
|
|
967
|
+
remove = QToolButton()
|
|
968
|
+
remove.setObjectName(f"experiments_remove_column_{index}")
|
|
969
|
+
remove.setText("×")
|
|
970
|
+
remove.setToolTip(f"Remove the '{name}' column")
|
|
971
|
+
remove.setAutoRaise(True)
|
|
972
|
+
remove.setFixedWidth(16)
|
|
973
|
+
remove.setStyleSheet(f"color: {TEXT_DIM}; border: none;")
|
|
974
|
+
remove.clicked.connect(lambda _=False, i=index: self.remove_column(i))
|
|
975
|
+
cell_row.addWidget(remove)
|
|
976
|
+
row.addWidget(cell, 1)
|
|
977
|
+
self._header_fields.append(field)
|
|
978
|
+
else:
|
|
979
|
+
placeholder = QLineEdit("Folder")
|
|
980
|
+
placeholder.setEnabled(False)
|
|
981
|
+
placeholder.setStyleSheet(mono_input_style())
|
|
982
|
+
row.addWidget(placeholder, 1)
|
|
983
|
+
|
|
984
|
+
rail = QWidget()
|
|
985
|
+
rail.setFixedWidth(_RAIL_W)
|
|
986
|
+
row.addWidget(rail)
|
|
987
|
+
chip = QWidget()
|
|
988
|
+
chip.setFixedWidth(_CHIP_W)
|
|
989
|
+
row.addWidget(chip)
|
|
990
|
+
return widget
|
|
991
|
+
|
|
992
|
+
def _rebuild_table(self) -> None:
|
|
993
|
+
while self._rows_box.count():
|
|
994
|
+
item = self._rows_box.takeAt(0)
|
|
995
|
+
if item.widget() is not None:
|
|
996
|
+
item.widget().deleteLater()
|
|
997
|
+
self._rows = []
|
|
998
|
+
|
|
999
|
+
self._rows_box.addWidget(self._build_header_widget())
|
|
1000
|
+
|
|
1001
|
+
for key in self._paths:
|
|
1002
|
+
cols = self._records[key]["columns"]
|
|
1003
|
+
values = [cols.get(name, "") for name in self._column_names]
|
|
1004
|
+
row = ExperimentRow(key, values or None, stages=self._stages)
|
|
1005
|
+
row.clicked.connect(self._on_row_clicked)
|
|
1006
|
+
row.dot_clicked.connect(self._on_row_dot_clicked)
|
|
1007
|
+
row.set_selected(key in self._selected_paths)
|
|
1008
|
+
self._rows_box.addWidget(row)
|
|
1009
|
+
self._rows.append(row)
|
|
1010
|
+
|
|
1011
|
+
has_rows = bool(self._paths)
|
|
1012
|
+
self._rows_scroll.setVisible(has_rows)
|
|
1013
|
+
self._rows_grip.setVisible(has_rows)
|
|
1014
|
+
self._fit_rows_height()
|
|
1015
|
+
self.refresh_statuses()
|
|
1016
|
+
|
|
1017
|
+
def _rows_content_height(self) -> int:
|
|
1018
|
+
"""The rows' natural stacked height, summed from the child hints.
|
|
1019
|
+
|
|
1020
|
+
Summing the children is deterministic right after a bulk rebuild, where
|
|
1021
|
+
the container's own aggregate size hint is momentarily stale (0) until
|
|
1022
|
+
the event loop lays the new rows out.
|
|
1023
|
+
"""
|
|
1024
|
+
box = self._rows_box
|
|
1025
|
+
margins = box.contentsMargins()
|
|
1026
|
+
total = margins.top() + margins.bottom()
|
|
1027
|
+
count = box.count()
|
|
1028
|
+
for i in range(count):
|
|
1029
|
+
widget = box.itemAt(i).widget()
|
|
1030
|
+
if widget is not None:
|
|
1031
|
+
total += widget.sizeHint().height()
|
|
1032
|
+
if count > 1:
|
|
1033
|
+
total += box.spacing() * (count - 1)
|
|
1034
|
+
return total
|
|
1035
|
+
|
|
1036
|
+
def _fit_rows_height(self) -> None:
|
|
1037
|
+
"""Size the scroll region, honouring a dragged override else auto-fitting.
|
|
1038
|
+
|
|
1039
|
+
Auto-fit grows the list flush to the number of rows instead of reserving
|
|
1040
|
+
a fixed slab, capped at ``max_rows_height``; past the cap it scrolls
|
|
1041
|
+
internally. A grip drag pins an explicit height instead, bounded to
|
|
1042
|
+
``[_MIN_ROWS_HEIGHT, max(content, max_rows_height)]``.
|
|
1043
|
+
"""
|
|
1044
|
+
# Add the frame both sides so the viewport fits content exactly — else a
|
|
1045
|
+
# 1-2px shortfall triggers a spurious scrollbar on a list that fits.
|
|
1046
|
+
natural = self._rows_content_height() + 2 * self._rows_scroll.frameWidth()
|
|
1047
|
+
if self._rows_height_override is None:
|
|
1048
|
+
height = min(natural, self._max_rows_height)
|
|
1049
|
+
else:
|
|
1050
|
+
upper = max(natural, self._max_rows_height)
|
|
1051
|
+
height = max(_MIN_ROWS_HEIGHT, min(self._rows_height_override, upper))
|
|
1052
|
+
self._rows_scroll.setFixedHeight(height)
|
|
1053
|
+
# Remember what we applied: the grip drags from this, not from live
|
|
1054
|
+
# geometry, which may not have settled between synchronous calls.
|
|
1055
|
+
self._applied_rows_h = height
|
|
1056
|
+
|
|
1057
|
+
# -- resize grip ------------------------------------------------------
|
|
1058
|
+
def _on_grip_pressed(self) -> None:
|
|
1059
|
+
"""Anchor the drag to the list's current (last-applied) height."""
|
|
1060
|
+
self._grip_base = self._applied_rows_h
|
|
1061
|
+
|
|
1062
|
+
def _on_grip_dragged(self, delta: int) -> None:
|
|
1063
|
+
"""Resize to the anchor height plus the drag delta (clamped in _fit)."""
|
|
1064
|
+
if self._grip_base is None:
|
|
1065
|
+
return
|
|
1066
|
+
self._rows_height_override = self._grip_base + delta
|
|
1067
|
+
self._fit_rows_height()
|
|
1068
|
+
|
|
1069
|
+
def _on_grip_reset(self) -> None:
|
|
1070
|
+
"""Double-click: forget the manual size, return to auto-fit."""
|
|
1071
|
+
self._rows_height_override = None
|
|
1072
|
+
self._grip_base = None
|
|
1073
|
+
self._fit_rows_height()
|
|
1074
|
+
|
|
1075
|
+
def _refresh_hint(self, message: str | None = None) -> None:
|
|
1076
|
+
"""The one status line: a transient *message*, else the empty-state CTA."""
|
|
1077
|
+
if message:
|
|
1078
|
+
self._hint.setText(message)
|
|
1079
|
+
elif not self._paths:
|
|
1080
|
+
self._hint.setText(
|
|
1081
|
+
"Add folders with cell and nucleus images to start. "
|
|
1082
|
+
"Click ? for how ITASC stores its work."
|
|
1083
|
+
)
|
|
1084
|
+
else:
|
|
1085
|
+
self._hint.setText("")
|
|
1086
|
+
|
|
1087
|
+
def _update_meta(self) -> None:
|
|
1088
|
+
n = len(self._paths)
|
|
1089
|
+
self._meta.setText(f"{n} data folder{'s' if n != 1 else ''}")
|
|
1090
|
+
self._refresh_hint()
|
|
1091
|
+
self._update_action_buttons()
|