batch2p 0.1.0__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.
- batch2p/__init__.py +1 -0
- batch2p/cli.py +427 -0
- batch2p/compute_F_sub.py +147 -0
- batch2p/extractors/__init__.py +17 -0
- batch2p/extractors/base.py +32 -0
- batch2p/extractors/suite2p.py +342 -0
- batch2p/extractors/suite3d.py +143 -0
- batch2p/gui.py +1513 -0
- batch2p/multi.py +176 -0
- batch2p-0.1.0.dist-info/METADATA +17 -0
- batch2p-0.1.0.dist-info/RECORD +13 -0
- batch2p-0.1.0.dist-info/WHEEL +4 -0
- batch2p-0.1.0.dist-info/entry_points.txt +5 -0
batch2p/gui.py
ADDED
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""batch2p GUI — visual configurator for batch2p data.json and params.json files.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
batch2p-gui
|
|
6
|
+
python -m batch2p.gui
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import copy
|
|
11
|
+
import itertools
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from PyQt5.QtCore import Qt, pyqtSignal
|
|
19
|
+
from PyQt5.QtGui import QColor, QFont
|
|
20
|
+
from PyQt5.QtWidgets import (
|
|
21
|
+
QAbstractItemView, QAction, QApplication, QButtonGroup, QCheckBox,
|
|
22
|
+
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
|
23
|
+
QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget,
|
|
24
|
+
QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QRadioButton,
|
|
25
|
+
QScrollArea, QSizePolicy, QSplitter, QStatusBar, QTabWidget,
|
|
26
|
+
QPlainTextEdit, QTableWidget, QTableWidgetItem, QTextBrowser, QToolBar,
|
|
27
|
+
QVBoxLayout, QWidget,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ─── Parameter definitions ────────────────────────────────────────────────────
|
|
31
|
+
# Each entry: (section, name, default, description)
|
|
32
|
+
# section=None → top-level key in the JSON (suite2p flat params or suite3d all params)
|
|
33
|
+
|
|
34
|
+
S2P_PARAMS = [
|
|
35
|
+
# Top-level (flat) suite2p settings
|
|
36
|
+
(None, "tau", 1.0, "Timescale for deconvolution and binning, in seconds."),
|
|
37
|
+
(None, "fs", 10.0, "Sampling rate per plane (Hz)."),
|
|
38
|
+
(None, "nplanes", 1, "Each tiff/file has this many planes in sequence."),
|
|
39
|
+
(None, "nchannels", 1, "Specify one- (1) or two-channel (2) recording."),
|
|
40
|
+
(None, "functional_chan", 1, "Channel used to extract functional ROIs (1-based)."),
|
|
41
|
+
(None, "torch_device", "cuda", "Torch device: 'cuda' for GPU or 'cpu' for CPU. Auto-detected if omitted."),
|
|
42
|
+
(None, "force_sktiff", False, "Use tifffile for TIFF reading instead of ScanImage TIFF reader."),
|
|
43
|
+
(None, "ignore_flyback", [], "List of plane indices (0-based) to skip during processing."),
|
|
44
|
+
(None, "keep_movie_raw", False, "Keep the binary file of non-registered frames."),
|
|
45
|
+
(None, "diameter", [12, 12], "ROI diameter in [Y, X] pixels for sourcery/cellpose detection."),
|
|
46
|
+
# run
|
|
47
|
+
("run", "do_registration", 1, "Whether to motion-register data (2 forces re-registration)."),
|
|
48
|
+
("run", "do_detection", True, "Whether to run ROI detection and extraction."),
|
|
49
|
+
("run", "do_deconvolution", True, "Whether to run spike deconvolution."),
|
|
50
|
+
("run", "multiplane_parallel",False, "Run each plane as a separate server job."),
|
|
51
|
+
# io
|
|
52
|
+
("io", "combined", True, "Combine all planes into a single result after processing."),
|
|
53
|
+
("io", "save_mat", False, "Save output as MATLAB .mat file."),
|
|
54
|
+
("io", "delete_bin", False, "Delete the binary file after processing."),
|
|
55
|
+
("io", "move_bin", False, "Move binary to save_path if fast_disk differs from save_path."),
|
|
56
|
+
# registration
|
|
57
|
+
("registration", "two_step_registration", False, "Run registration twice — useful for low-SNR data. Set keep_movie_raw=True when using this."),
|
|
58
|
+
("registration", "nimg_init", 400, "Subsampled frames used to build the reference image. Increase if reference looks poor."),
|
|
59
|
+
("registration", "batch_size", 100, "Frames per registration batch. Reduce if GPU runs out of memory."),
|
|
60
|
+
("registration", "maxregshift", 0.1, "Max allowed shift as a fraction of frame max(width, height)."),
|
|
61
|
+
("registration", "nonrigid", True, "Use non-rigid (block-based) registration."),
|
|
62
|
+
("registration", "maxregshiftNR", 5, "Maximum pixel shift for each non-rigid block, relative to the rigid shift."),
|
|
63
|
+
("registration", "block_size", [128, 128],"Block size for non-rigid registration. Keep as a multiple of 2, 3, and/or 5."),
|
|
64
|
+
("registration", "smooth_sigma", 1.15, "Gaussian smoothing in XY. ~1 is good for 2P; 3–5 may work for 1P."),
|
|
65
|
+
("registration", "smooth_sigma_time",0, "Gaussian smoothing in time before computing registration shifts. Useful for low SNR."),
|
|
66
|
+
("registration", "th_badframes", 1.0, "Frames with displacement above this × median are excluded from cropping estimate."),
|
|
67
|
+
("registration", "norm_frames", True, "Normalise frames when detecting shifts."),
|
|
68
|
+
("registration", "snr_thresh", 1.2, "Non-rigid blocks below this SNR are smoothed. Set to 1.0 to disable smoothing."),
|
|
69
|
+
("registration", "subpixel", 10, "Subpixel precision: 1/subpixel steps."),
|
|
70
|
+
("registration", "reg_tif", False, "Save registered TIFFs to disk."),
|
|
71
|
+
("registration", "reg_tif_chan2", False, "Save registered TIFFs for channel 2."),
|
|
72
|
+
# detection
|
|
73
|
+
("detection", "denoise", False, "Use PCA denoising before cell detection."),
|
|
74
|
+
("detection", "block_size", [64, 64], "Block size for PCA denoising."),
|
|
75
|
+
("detection", "nbins", 5000, "Max number of binned frames for detection. Reduce if RAM is limited."),
|
|
76
|
+
("detection", "highpass_time", 100, "Running-mean subtraction window (bins). Use low values for 1P data."),
|
|
77
|
+
("detection", "threshold_scaling", 1.0, "Multiplier for the auto-determined detection threshold. Lower = more cells."),
|
|
78
|
+
("detection", "max_overlap", 0.75, "ROIs sharing more than this fraction of pixels with another ROI are discarded."),
|
|
79
|
+
("detection", "soma_crop", True, "Crop dendrites from ROI when computing npix_norm and compactness."),
|
|
80
|
+
# classification
|
|
81
|
+
("classification", "use_builtin_classifier", False, "Use the built-in classifier instead of the user-trained one."),
|
|
82
|
+
("classification", "preclassify", 0.0, "Drop ROIs with classifier score below this before extraction."),
|
|
83
|
+
# extraction
|
|
84
|
+
("extraction", "neuropil_extract", True, "Extract neuropil signal. If False, Fneu is set to zero."),
|
|
85
|
+
("extraction", "neuropil_coefficient", 0.7, "Neuropil subtraction coefficient (F_corrected = F − coeff × Fneu)."),
|
|
86
|
+
("extraction", "inner_neuropil_radius", 2, "Pixels excluded from neuropil mask immediately adjacent to the ROI."),
|
|
87
|
+
("extraction", "min_neuropil_pixels", 350, "Minimum number of pixels in the per-ROI neuropil mask."),
|
|
88
|
+
("extraction", "lam_percentile", 50.0, "Percentile of ROI λ weights below which pixels are excluded from neuropil."),
|
|
89
|
+
("extraction", "allow_overlap", False, "Allow shared pixels between overlapping ROIs (True) or discard them (False)."),
|
|
90
|
+
# dcnv_preprocess
|
|
91
|
+
("dcnv_preprocess", "baseline", "maximin","Baseline estimation method: 'maximin', 'prctile', or 'constant'."),
|
|
92
|
+
("dcnv_preprocess", "win_baseline", 60.0, "Window length (seconds) for the max filter in maximin baseline."),
|
|
93
|
+
("dcnv_preprocess", "sig_baseline", 10.0, "Width of Gaussian filter in frames applied before/after the max filter."),
|
|
94
|
+
("dcnv_preprocess", "prctile_baseline", 8.0, "Percentile of trace used as baseline when method='prctile'."),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
S3D_PARAMS = [
|
|
98
|
+
# Mandatory
|
|
99
|
+
("Mandatory", "fs", 2.8, "Volume rate (Hz)."),
|
|
100
|
+
("Mandatory", "tau", 1.3, "GCaMP decay timescale in seconds. GCaMP6s ≈ 1.3, GCaMP6f ≈ 0.7."),
|
|
101
|
+
("Mandatory", "planes", "np.arange(0,30)", "Plane indices to analyse (0 = deepest). Passed as a Python/numpy expression."),
|
|
102
|
+
("Mandatory", "n_ch_tif", 30, "Number of planes recorded per volume in the TIFF."),
|
|
103
|
+
("Mandatory", "lbm", True, "Data from light-bead microscopy (LBM). Set False for standard multiplane 2P."),
|
|
104
|
+
("Mandatory", "voxel_size_um", [15, 2.5, 2.5], "Voxel size in microns [z, y, x]."),
|
|
105
|
+
("Mandatory", "num_colors", 1, "Number of colour channels recorded (non-LBM only)."),
|
|
106
|
+
("Mandatory", "functional_color_channel", 0, "0-based index of the functional colour channel (non-LBM only)."),
|
|
107
|
+
# Initialization
|
|
108
|
+
("Initialization", "n_init_files", 1, "Number of TIFFs used in the initialisation step (~1 min of data is usually enough)."),
|
|
109
|
+
("Initialization", "init_n_frames", 500, "Random frames sampled from init files. Set None to use all frames."),
|
|
110
|
+
("Initialization", "subtract_crosstalk", True,"Subtract optical crosstalk between plane pairs separated by cavity_size planes."),
|
|
111
|
+
("Initialization", "cavity_size", 15, "Number of planes separating a crosstalk pair (LBM-specific)."),
|
|
112
|
+
# Registration
|
|
113
|
+
("Registration", "3d_reg", True, "Use 3-D volumetric registration."),
|
|
114
|
+
("Registration", "gpu_reg", True, "Use GPU for registration."),
|
|
115
|
+
("Registration", "fuse_strips", True, "Fuse mesoscope strip ROIs before registration."),
|
|
116
|
+
("Registration", "max_rigid_shift_pix",100, "Maximum rigid shift in pixels. Must exceed any expected inter-plane LBM shift."),
|
|
117
|
+
("Registration", "nonrigid", False, "Use non-rigid registration (2-D; computationally expensive)."),
|
|
118
|
+
("Registration", "block_size", [128, 128],"Non-rigid registration block size in [y, x] pixels."),
|
|
119
|
+
("Registration", "smooth_sigma", 1.15, "Gaussian smoothing parameter for registration."),
|
|
120
|
+
("Registration", "snr_thresh", 1.2, "SNR threshold for non-rigid blocks (2-D registration)."),
|
|
121
|
+
("Registration", "pc_size", [2, 40, 40],"Phase-correlation search range in [z, y, x] pixels."),
|
|
122
|
+
# Correlation Map / Segmentation
|
|
123
|
+
("Corr Map", "cell_filt_xy_um", 5, "XY radius of the cell detection filter in microns."),
|
|
124
|
+
("Corr Map", "cell_filt_z_um", 10, "Z extent of the cell detection filter in microns."),
|
|
125
|
+
("Corr Map", "npil_filt_xy_um", 100.0, "XY radius of the neuropil subtraction filter in microns."),
|
|
126
|
+
("Corr Map", "npil_filt_z_um", 15.0, "Z extent of the neuropil subtraction filter in microns."),
|
|
127
|
+
("Corr Map", "peak_thresh", 0.1, "Correlation-map peak threshold for cell detection. Lower = more cells found."),
|
|
128
|
+
("Corr Map", "extend_thresh", 0.05, "Threshold to extend an ROI to a neighbouring pixel. Lower = larger ROIs."),
|
|
129
|
+
("Corr Map", "activity_thresh", 5.0, "Only timepoints above this value (SD units) are used for segmentation."),
|
|
130
|
+
("Corr Map", "max_iter", 10000, "Maximum number of ROIs detected per patch."),
|
|
131
|
+
("Corr Map", "allow_overlap", False, "Allow pixel overlap between ROIs."),
|
|
132
|
+
("Corr Map", "t_batch_size", 800, "Timepoints processed per batch (must be a multiple of temporal_hpf)."),
|
|
133
|
+
("Corr Map", "temporal_hpf", 200, "Temporal high-pass filter width (timepoints). Must divide t_batch_size evenly."),
|
|
134
|
+
# SVD
|
|
135
|
+
("SVD", "n_svd_comp", 600, "SVD components computed per block."),
|
|
136
|
+
("SVD", "svd_block_shape", [4, 200, 200],"Block shape for SVD denoising [z, y, x]."),
|
|
137
|
+
# Deconvolution
|
|
138
|
+
("Deconvolution", "npil_coeff", 0.7, "Neuropil subtraction coefficient."),
|
|
139
|
+
("Deconvolution", "dcnv_baseline", "maximin", "Baseline method: 'maximin', 'prctile', or 'constant'."),
|
|
140
|
+
("Deconvolution", "dcnv_win_baseline", 60, "Window length (seconds) for maximin baseline."),
|
|
141
|
+
("Deconvolution", "dcnv_sig_baseline", 10, "Gaussian filter width in frames."),
|
|
142
|
+
("Deconvolution", "dcnv_prctile_baseline", 8, "Baseline percentile (used when dcnv_baseline='prctile')."),
|
|
143
|
+
("Deconvolution", "split_tif_size", 100, "Internal: split registered data into chunks of this many frames."),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# ─── Run-config (data.json) field definitions ────────────────────────────────
|
|
147
|
+
# (key, label, default, widget_type, tooltip, shown_for)
|
|
148
|
+
# widget_type: "text" | "path" | "file" | "int" | "bool"
|
|
149
|
+
# shown_for: "both" | "suite2p" | "suite3d"
|
|
150
|
+
DATA_FIELDS = [
|
|
151
|
+
("job_id", "Job ID", "", "text", "Unique identifier for this run (used as output subdirectory name).", "both"),
|
|
152
|
+
("job_root_dir", "Job Root Dir", "", "path", "Directory where Suite3D creates its job folder. Not used by Suite2P.", "both"),
|
|
153
|
+
("results_root_dir", "Results Root Dir", "", "path", "Parent directory for the <job_id> results folder.", "both"),
|
|
154
|
+
("temp_dir", "Temp / Scratch Dir", "", "path", "Parent for the fast-disk scratch directory (Suite2P) or TIFF-split temp dir (Suite3D).", "both"),
|
|
155
|
+
("working_dir", "Working Dir", "", "path", "Isolate all work in a temp subdirectory here and copy results back on completion (optional).", "both"),
|
|
156
|
+
("block_size", "Block Size (planes/vol)", "3","int", "Planes per imaging volume; used for frame-index normalisation during behavioural sync.", "both"),
|
|
157
|
+
("fill_tsync_gaps", "Fill TSync Gaps", False, "bool", "Interpolate timestamp gaps in the behavioural log instead of truncating.", "both"),
|
|
158
|
+
("ignore_barcode", "Ignore Barcode", False, "bool", "Skip barcode-based alignment and fall back to frame-clock onset matching.", "both"),
|
|
159
|
+
("pinsheet_file", "Pinsheet File", "", "file", "Path to the TotalSync pin-mapping JSON (required when behavioural data is provided).", "both"),
|
|
160
|
+
("tiff_trim_size", "TIFF Trim Size", "9999","int", "Split each input TIFF into chunks of this many frames before processing. Set 0 to disable.", "both"),
|
|
161
|
+
("add_offset", "Add Offset", False, "bool", "Pass add_offset=True to split_3d_tiff_into_chunks when TIFF splitting is enabled.", "both"),
|
|
162
|
+
("do_F_sub", "Compute F_sub", False, "bool", "After extraction, compute F_sub = dcnv.preprocess(F - neucoeff*Fneu) and save as F_sub.npy. Also synchronized when behavioural sync is run. Uses baseline/neucoeff/fs params from the Suite2P params file.", "suite2p"),
|
|
163
|
+
("comments", "Comments", "", "textarea", "Free-form notes about this run configuration. Ignored by batch2p and extractors.", "both"),
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
# ─── Utilities ────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def safe_eval(expr: str):
|
|
169
|
+
"""Evaluate a Python/numpy expression safely. Returns the Python object."""
|
|
170
|
+
expr = expr.strip()
|
|
171
|
+
try:
|
|
172
|
+
return ast.literal_eval(expr)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
ns = {"np": np, "numpy": np, "arange": np.arange, "array": np.array,
|
|
176
|
+
"linspace": np.linspace, "logspace": np.logspace, "True": True,
|
|
177
|
+
"False": False, "None": None}
|
|
178
|
+
return eval(expr, {"__builtins__": {}}, ns)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def value_to_str(v) -> str:
|
|
182
|
+
"""Represent a default parameter value as an editable string."""
|
|
183
|
+
if isinstance(v, np.ndarray):
|
|
184
|
+
return repr(v.tolist())
|
|
185
|
+
if isinstance(v, (list, tuple)):
|
|
186
|
+
return repr(list(v))
|
|
187
|
+
if isinstance(v, str):
|
|
188
|
+
return repr(v)
|
|
189
|
+
return str(v)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def make_json_serializable(v):
|
|
193
|
+
"""Recursively convert numpy types/arrays to JSON-serialisable Python objects."""
|
|
194
|
+
if isinstance(v, np.ndarray):
|
|
195
|
+
return v.tolist()
|
|
196
|
+
if isinstance(v, (np.integer,)):
|
|
197
|
+
return int(v)
|
|
198
|
+
if isinstance(v, (np.floating,)):
|
|
199
|
+
return float(v)
|
|
200
|
+
if isinstance(v, dict):
|
|
201
|
+
return {k: make_json_serializable(val) for k, val in v.items()}
|
|
202
|
+
if isinstance(v, (list, tuple)):
|
|
203
|
+
return [make_json_serializable(x) for x in v]
|
|
204
|
+
return v
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _apply_template_vars(obj, variables: dict):
|
|
208
|
+
"""Recursively replace {{ var }} placeholders in string values.
|
|
209
|
+
|
|
210
|
+
Only substitutes a placeholder when the corresponding variable is non-empty;
|
|
211
|
+
otherwise the placeholder is left verbatim in the output.
|
|
212
|
+
"""
|
|
213
|
+
if isinstance(obj, str):
|
|
214
|
+
def _replace(m):
|
|
215
|
+
name = m.group(1).strip()
|
|
216
|
+
val = variables.get(name)
|
|
217
|
+
return val if val else m.group(0)
|
|
218
|
+
return re.sub(r'\{\{\s*(\w+)\s*\}\}', _replace, obj)
|
|
219
|
+
if isinstance(obj, dict):
|
|
220
|
+
return {k: _apply_template_vars(v, variables) for k, v in obj.items()}
|
|
221
|
+
if isinstance(obj, list):
|
|
222
|
+
return [_apply_template_vars(x, variables) for x in obj]
|
|
223
|
+
return obj
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ─── FileListWidget ───────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
class FileListWidget(QWidget):
|
|
229
|
+
"""Labeled list with Add / Remove / Move-Up / Move-Down buttons."""
|
|
230
|
+
|
|
231
|
+
order_changed = pyqtSignal()
|
|
232
|
+
|
|
233
|
+
def __init__(self, label: str, file_filter: str, root_path_getter=None, parent=None):
|
|
234
|
+
super().__init__(parent)
|
|
235
|
+
self._filter = file_filter
|
|
236
|
+
self._root_path_getter = root_path_getter # callable () -> str, or None
|
|
237
|
+
layout = QVBoxLayout(self)
|
|
238
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
239
|
+
|
|
240
|
+
header = QLabel(f"<b>{label}</b>")
|
|
241
|
+
layout.addWidget(header)
|
|
242
|
+
|
|
243
|
+
self.list_widget = QListWidget()
|
|
244
|
+
self.list_widget.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
245
|
+
self.list_widget.setAlternatingRowColors(True)
|
|
246
|
+
layout.addWidget(self.list_widget)
|
|
247
|
+
|
|
248
|
+
btn_row = QHBoxLayout()
|
|
249
|
+
self._btn_add = QPushButton("Add…")
|
|
250
|
+
self._btn_remove = QPushButton("Remove")
|
|
251
|
+
self._btn_up = QPushButton("▲")
|
|
252
|
+
self._btn_down = QPushButton("▼")
|
|
253
|
+
for btn in (self._btn_add, self._btn_remove, self._btn_up, self._btn_down):
|
|
254
|
+
btn.setFixedHeight(26)
|
|
255
|
+
btn_row.addWidget(btn)
|
|
256
|
+
layout.addLayout(btn_row)
|
|
257
|
+
|
|
258
|
+
self._btn_add.clicked.connect(self._add_files)
|
|
259
|
+
self._btn_remove.clicked.connect(self._remove_selected)
|
|
260
|
+
self._btn_up.clicked.connect(self._move_up)
|
|
261
|
+
self._btn_down.clicked.connect(self._move_down)
|
|
262
|
+
|
|
263
|
+
# ── public API ────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def paths(self) -> list[str]:
|
|
266
|
+
return [self.list_widget.item(i).data(Qt.UserRole)
|
|
267
|
+
for i in range(self.list_widget.count())]
|
|
268
|
+
|
|
269
|
+
def set_paths(self, paths: list[str]):
|
|
270
|
+
self.list_widget.clear()
|
|
271
|
+
for p in paths:
|
|
272
|
+
self._append(p)
|
|
273
|
+
self.order_changed.emit()
|
|
274
|
+
|
|
275
|
+
def count(self) -> int:
|
|
276
|
+
return self.list_widget.count()
|
|
277
|
+
|
|
278
|
+
def current_row(self) -> int:
|
|
279
|
+
return self.list_widget.currentRow()
|
|
280
|
+
|
|
281
|
+
# ── internals ─────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
def _append(self, path: str):
|
|
284
|
+
"""Append *path* to the list. *path* should already be a relative path."""
|
|
285
|
+
item = QListWidgetItem(path)
|
|
286
|
+
item.setData(Qt.UserRole, path)
|
|
287
|
+
item.setToolTip(path)
|
|
288
|
+
self.list_widget.addItem(item)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _to_relative(path: str, root_str: str) -> str:
|
|
292
|
+
"""Return *path* relative to *root_str* when possible, else return just the filename."""
|
|
293
|
+
if not root_str:
|
|
294
|
+
return path
|
|
295
|
+
try:
|
|
296
|
+
return str(Path(path).relative_to(root_str))
|
|
297
|
+
except ValueError:
|
|
298
|
+
return Path(path).name
|
|
299
|
+
|
|
300
|
+
def _add_files(self):
|
|
301
|
+
paths, _ = QFileDialog.getOpenFileNames(self, "Select files",
|
|
302
|
+
"", self._filter)
|
|
303
|
+
root_str = self._root_path_getter() if self._root_path_getter else ""
|
|
304
|
+
for p in paths:
|
|
305
|
+
self._append(self._to_relative(p, root_str))
|
|
306
|
+
self.order_changed.emit()
|
|
307
|
+
|
|
308
|
+
def _remove_selected(self):
|
|
309
|
+
for item in self.list_widget.selectedItems():
|
|
310
|
+
self.list_widget.takeItem(self.list_widget.row(item))
|
|
311
|
+
self.order_changed.emit()
|
|
312
|
+
|
|
313
|
+
def _move_up(self):
|
|
314
|
+
row = self.list_widget.currentRow()
|
|
315
|
+
if row > 0:
|
|
316
|
+
item = self.list_widget.takeItem(row)
|
|
317
|
+
self.list_widget.insertItem(row - 1, item)
|
|
318
|
+
self.list_widget.setCurrentRow(row - 1)
|
|
319
|
+
self.order_changed.emit()
|
|
320
|
+
|
|
321
|
+
def _move_down(self):
|
|
322
|
+
row = self.list_widget.currentRow()
|
|
323
|
+
if row < self.list_widget.count() - 1:
|
|
324
|
+
item = self.list_widget.takeItem(row)
|
|
325
|
+
self.list_widget.insertItem(row + 1, item)
|
|
326
|
+
self.list_widget.setCurrentRow(row + 1)
|
|
327
|
+
self.order_changed.emit()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ─── InputFilesWidget ─────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
class InputFilesWidget(QWidget):
|
|
333
|
+
"""Left panel: root path, paired TIFF + .b64 lists, save/load."""
|
|
334
|
+
|
|
335
|
+
def __init__(self, parent=None):
|
|
336
|
+
super().__init__(parent)
|
|
337
|
+
layout = QVBoxLayout(self)
|
|
338
|
+
|
|
339
|
+
# ── Root path ─────────────────────────────────────────────────────────
|
|
340
|
+
root_box = QGroupBox("Root Path (for server transfer — may differ from actual file location)")
|
|
341
|
+
root_layout = QHBoxLayout(root_box)
|
|
342
|
+
self.root_edit = QLineEdit()
|
|
343
|
+
self.root_edit.setPlaceholderText("/data/ofl_2p/YYYYMMDD")
|
|
344
|
+
btn_root = QPushButton("Browse…")
|
|
345
|
+
btn_root.setFixedWidth(80)
|
|
346
|
+
btn_root.clicked.connect(self._browse_root)
|
|
347
|
+
root_layout.addWidget(self.root_edit)
|
|
348
|
+
root_layout.addWidget(btn_root)
|
|
349
|
+
layout.addWidget(root_box)
|
|
350
|
+
|
|
351
|
+
# ── File lists ────────────────────────────────────────────────────────
|
|
352
|
+
lists_splitter = QSplitter(Qt.Horizontal)
|
|
353
|
+
self.tif_list = FileListWidget("TIFF files", "TIFF files (*.tif *.tiff)",
|
|
354
|
+
root_path_getter=self.root_path)
|
|
355
|
+
self.b64_list = FileListWidget(".b64 files (optional)", "b64 files (*.b64)",
|
|
356
|
+
root_path_getter=self.root_path)
|
|
357
|
+
lists_splitter.addWidget(self.tif_list)
|
|
358
|
+
lists_splitter.addWidget(self.b64_list)
|
|
359
|
+
lists_splitter.setSizes([300, 300])
|
|
360
|
+
layout.addWidget(lists_splitter)
|
|
361
|
+
|
|
362
|
+
# Sync selection: highlight matching row in the other list
|
|
363
|
+
self.tif_list.list_widget.currentRowChanged.connect(
|
|
364
|
+
lambda r: self.b64_list.list_widget.setCurrentRow(r))
|
|
365
|
+
self.b64_list.list_widget.currentRowChanged.connect(
|
|
366
|
+
lambda r: self.tif_list.list_widget.setCurrentRow(r))
|
|
367
|
+
|
|
368
|
+
# Mismatch warning
|
|
369
|
+
self._mismatch_label = QLabel("")
|
|
370
|
+
self._mismatch_label.setStyleSheet("color: #cc4400;")
|
|
371
|
+
layout.addWidget(self._mismatch_label)
|
|
372
|
+
for lst in (self.tif_list, self.b64_list):
|
|
373
|
+
lst.order_changed.connect(self._check_counts)
|
|
374
|
+
|
|
375
|
+
# ── Save / Load ───────────────────────────────────────────────────────
|
|
376
|
+
io_row = QHBoxLayout()
|
|
377
|
+
btn_save = QPushButton("Save file list…")
|
|
378
|
+
btn_load = QPushButton("Load file list…")
|
|
379
|
+
btn_save.clicked.connect(self._save_file_list)
|
|
380
|
+
btn_load.clicked.connect(self._load_file_list)
|
|
381
|
+
io_row.addStretch()
|
|
382
|
+
io_row.addWidget(btn_load)
|
|
383
|
+
io_row.addWidget(btn_save)
|
|
384
|
+
layout.addLayout(io_row)
|
|
385
|
+
|
|
386
|
+
# ── public API ────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
def root_path(self) -> str:
|
|
389
|
+
return self.root_edit.text().strip()
|
|
390
|
+
|
|
391
|
+
def to_dict(self) -> dict:
|
|
392
|
+
return {
|
|
393
|
+
"root_path": self.root_path(),
|
|
394
|
+
"tif_files": self.tif_list.paths(),
|
|
395
|
+
"b64_files": self.b64_list.paths(),
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
def from_dict(self, d: dict):
|
|
399
|
+
self.root_edit.setText(d.get("root_path", ""))
|
|
400
|
+
self.tif_list.set_paths(d.get("tif_files", []))
|
|
401
|
+
self.b64_list.set_paths(d.get("b64_files", []))
|
|
402
|
+
|
|
403
|
+
# ── internals ─────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
def _browse_root(self):
|
|
406
|
+
path = QFileDialog.getExistingDirectory(self, "Select root path")
|
|
407
|
+
if path:
|
|
408
|
+
self.root_edit.setText(path)
|
|
409
|
+
|
|
410
|
+
def _check_counts(self):
|
|
411
|
+
n_tif = self.tif_list.count()
|
|
412
|
+
n_b64 = self.b64_list.count()
|
|
413
|
+
if n_b64 > 0 and n_b64 != n_tif:
|
|
414
|
+
self._mismatch_label.setText(
|
|
415
|
+
f"⚠ {n_tif} TIFF files but {n_b64} .b64 files — counts must match for behavioural sync.")
|
|
416
|
+
else:
|
|
417
|
+
self._mismatch_label.setText("")
|
|
418
|
+
|
|
419
|
+
def _save_file_list(self):
|
|
420
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save file list",
|
|
421
|
+
"", "JSON (*.json)")
|
|
422
|
+
if not path:
|
|
423
|
+
return
|
|
424
|
+
with open(path, "w") as f:
|
|
425
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
426
|
+
|
|
427
|
+
def _load_file_list(self):
|
|
428
|
+
path, _ = QFileDialog.getOpenFileName(self, "Load file list",
|
|
429
|
+
"", "JSON (*.json)")
|
|
430
|
+
if not path:
|
|
431
|
+
return
|
|
432
|
+
with open(path) as f:
|
|
433
|
+
self.from_dict(json.load(f))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ─── ParamTableWidget ─────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
_SECTION_COLOR = QColor("#e8edf4")
|
|
439
|
+
_SWEEP_COLOR = QColor("#d6ecff")
|
|
440
|
+
_SWEEP_HDR_COLOR = QColor("#4a90d9")
|
|
441
|
+
|
|
442
|
+
class ParamTableWidget(QWidget):
|
|
443
|
+
"""Parameter table with value editing, sweep checkboxes, and a description pane."""
|
|
444
|
+
|
|
445
|
+
sweep_changed = pyqtSignal()
|
|
446
|
+
|
|
447
|
+
COL_SECTION = 0
|
|
448
|
+
COL_NAME = 1
|
|
449
|
+
COL_VALUE = 2
|
|
450
|
+
COL_SWEEP = 3
|
|
451
|
+
|
|
452
|
+
def __init__(self, param_defs: list, parent=None):
|
|
453
|
+
super().__init__(parent)
|
|
454
|
+
self._defs = param_defs # [(section, name, default, desc), ...]
|
|
455
|
+
layout = QVBoxLayout(self)
|
|
456
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
457
|
+
|
|
458
|
+
# ── Load buttons ──────────────────────────────────────────────────────
|
|
459
|
+
btn_row = QHBoxLayout()
|
|
460
|
+
btn_defaults = QPushButton("Load built-in defaults")
|
|
461
|
+
btn_file = QPushButton("Load from JSON file…")
|
|
462
|
+
btn_reset = QPushButton("Reset selected to default")
|
|
463
|
+
btn_defaults.clicked.connect(self.load_defaults)
|
|
464
|
+
btn_file.clicked.connect(self._load_from_file)
|
|
465
|
+
btn_reset.clicked.connect(self._reset_selected)
|
|
466
|
+
btn_reset.setToolTip("Reset the currently selected parameter to its built-in default value.")
|
|
467
|
+
btn_row.addWidget(btn_defaults)
|
|
468
|
+
btn_row.addWidget(btn_file)
|
|
469
|
+
btn_row.addWidget(btn_reset)
|
|
470
|
+
btn_row.addStretch()
|
|
471
|
+
self._sweep_label = QLabel("")
|
|
472
|
+
self._sweep_label.setStyleSheet("color: #2266aa; font-style: italic;")
|
|
473
|
+
btn_row.addWidget(self._sweep_label)
|
|
474
|
+
layout.addLayout(btn_row)
|
|
475
|
+
|
|
476
|
+
# ── Table ─────────────────────────────────────────────────────────────
|
|
477
|
+
splitter = QSplitter(Qt.Vertical)
|
|
478
|
+
self.table = QTableWidget()
|
|
479
|
+
self.table.setColumnCount(4)
|
|
480
|
+
self.table.setHorizontalHeaderLabels(["Section", "Parameter", "Value", "Sweep"])
|
|
481
|
+
hh = self.table.horizontalHeader()
|
|
482
|
+
hh.setSectionResizeMode(self.COL_SECTION, QHeaderView.ResizeToContents)
|
|
483
|
+
hh.setSectionResizeMode(self.COL_NAME, QHeaderView.ResizeToContents)
|
|
484
|
+
hh.setSectionResizeMode(self.COL_VALUE, QHeaderView.Stretch)
|
|
485
|
+
hh.setSectionResizeMode(self.COL_SWEEP, QHeaderView.Fixed)
|
|
486
|
+
self.table.setColumnWidth(self.COL_SWEEP, 55)
|
|
487
|
+
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
488
|
+
self.table.setEditTriggers(QAbstractItemView.DoubleClicked |
|
|
489
|
+
QAbstractItemView.SelectedClicked |
|
|
490
|
+
QAbstractItemView.EditKeyPressed)
|
|
491
|
+
self.table.currentItemChanged.connect(lambda cur, _: self._show_description(self.table.currentRow()))
|
|
492
|
+
self.table.itemChanged.connect(self._on_item_changed)
|
|
493
|
+
splitter.addWidget(self.table)
|
|
494
|
+
|
|
495
|
+
# ── Description pane ──────────────────────────────────────────────────
|
|
496
|
+
self.desc_browser = QTextBrowser()
|
|
497
|
+
self.desc_browser.setMaximumHeight(100)
|
|
498
|
+
self.desc_browser.setPlaceholderText("Click a row to see the parameter description.")
|
|
499
|
+
splitter.addWidget(self.desc_browser)
|
|
500
|
+
splitter.setSizes([400, 100])
|
|
501
|
+
layout.addWidget(splitter)
|
|
502
|
+
|
|
503
|
+
# ── Comments ──────────────────────────────────────────────────────────
|
|
504
|
+
comments_row = QHBoxLayout()
|
|
505
|
+
comments_row.addWidget(QLabel("Comments:"))
|
|
506
|
+
self.comments_edit = QLineEdit()
|
|
507
|
+
self.comments_edit.setPlaceholderText(
|
|
508
|
+
"Optional notes (ignored by batch2p and extractors)"
|
|
509
|
+
)
|
|
510
|
+
comments_row.addWidget(self.comments_edit)
|
|
511
|
+
layout.addLayout(comments_row)
|
|
512
|
+
|
|
513
|
+
self.load_defaults()
|
|
514
|
+
|
|
515
|
+
# ── public API ────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
def load_defaults(self):
|
|
518
|
+
self._populate([(s, n, v, d) for s, n, v, d in self._defs])
|
|
519
|
+
|
|
520
|
+
def get_params(self) -> tuple[dict, list[tuple]]:
|
|
521
|
+
"""Return (base_params_dict, sweep_list).
|
|
522
|
+
|
|
523
|
+
base_params_dict: nested dict ready to write as params.json.
|
|
524
|
+
sweep_list: [(section, name, [val1, val2, ...]), ...] for params with Sweep checked.
|
|
525
|
+
"""
|
|
526
|
+
base: dict = {}
|
|
527
|
+
sweep_list = []
|
|
528
|
+
n_rows = self.table.rowCount()
|
|
529
|
+
for row in range(n_rows):
|
|
530
|
+
section_item = self.table.item(row, self.COL_SECTION)
|
|
531
|
+
name_item = self.table.item(row, self.COL_NAME)
|
|
532
|
+
value_item = self.table.item(row, self.COL_VALUE)
|
|
533
|
+
sweep_cb = self._get_sweep_cb(row)
|
|
534
|
+
if not (section_item and name_item and value_item):
|
|
535
|
+
continue
|
|
536
|
+
section = section_item.text().strip()
|
|
537
|
+
name = name_item.text().strip()
|
|
538
|
+
val_str = value_item.text().strip()
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
value = safe_eval(val_str)
|
|
542
|
+
except Exception:
|
|
543
|
+
value = val_str # keep as string if unparseable
|
|
544
|
+
|
|
545
|
+
is_sweep = sweep_cb.isChecked() if sweep_cb else False
|
|
546
|
+
|
|
547
|
+
if is_sweep:
|
|
548
|
+
vals = list(value) if hasattr(value, "__iter__") and not isinstance(value, str) else [value]
|
|
549
|
+
sweep_list.append((section, name, vals))
|
|
550
|
+
else:
|
|
551
|
+
if section == "—":
|
|
552
|
+
base[name] = value
|
|
553
|
+
else:
|
|
554
|
+
base.setdefault(section, {})[name] = value
|
|
555
|
+
return base, sweep_list
|
|
556
|
+
|
|
557
|
+
def get_visible_count(self) -> int:
|
|
558
|
+
"""Number of non-sweep parameters (for display)."""
|
|
559
|
+
_, sweeps = self.get_params()
|
|
560
|
+
count = 1
|
|
561
|
+
for _, _, vals in sweeps:
|
|
562
|
+
count *= len(vals)
|
|
563
|
+
return count
|
|
564
|
+
|
|
565
|
+
# ── internals ─────────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
def _populate(self, rows: list):
|
|
568
|
+
self.table.blockSignals(True)
|
|
569
|
+
self.table.setRowCount(0)
|
|
570
|
+
self.table.setRowCount(len(rows))
|
|
571
|
+
bold = QFont()
|
|
572
|
+
bold.setBold(True)
|
|
573
|
+
for r, (section, name, default, desc) in enumerate(rows):
|
|
574
|
+
sec_label = section if section else "—"
|
|
575
|
+
|
|
576
|
+
sec_item = QTableWidgetItem(sec_label)
|
|
577
|
+
sec_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
|
578
|
+
sec_item.setBackground(_SECTION_COLOR)
|
|
579
|
+
sec_item.setFont(bold)
|
|
580
|
+
sec_item.setData(Qt.UserRole, desc)
|
|
581
|
+
self.table.setItem(r, self.COL_SECTION, sec_item)
|
|
582
|
+
|
|
583
|
+
name_item = QTableWidgetItem(name)
|
|
584
|
+
name_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
|
585
|
+
name_item.setData(Qt.UserRole, desc)
|
|
586
|
+
self.table.setItem(r, self.COL_NAME, name_item)
|
|
587
|
+
|
|
588
|
+
val_item = QTableWidgetItem(value_to_str(default))
|
|
589
|
+
val_item.setData(Qt.UserRole, desc)
|
|
590
|
+
self.table.setItem(r, self.COL_VALUE, val_item)
|
|
591
|
+
|
|
592
|
+
sweep_widget = QWidget()
|
|
593
|
+
sweep_layout = QHBoxLayout(sweep_widget)
|
|
594
|
+
sweep_layout.setContentsMargins(4, 0, 4, 0)
|
|
595
|
+
sweep_layout.setAlignment(Qt.AlignCenter)
|
|
596
|
+
cb = QCheckBox()
|
|
597
|
+
cb.stateChanged.connect(lambda _, row=r: self._on_sweep_toggled(row))
|
|
598
|
+
sweep_layout.addWidget(cb)
|
|
599
|
+
self.table.setCellWidget(r, self.COL_SWEEP, sweep_widget)
|
|
600
|
+
|
|
601
|
+
self.table.blockSignals(False)
|
|
602
|
+
self._update_sweep_label()
|
|
603
|
+
|
|
604
|
+
def _get_sweep_cb(self, row: int) -> QCheckBox | None:
|
|
605
|
+
widget = self.table.cellWidget(row, self.COL_SWEEP)
|
|
606
|
+
if widget is None:
|
|
607
|
+
return None
|
|
608
|
+
return widget.findChild(QCheckBox)
|
|
609
|
+
|
|
610
|
+
def _on_sweep_toggled(self, row: int):
|
|
611
|
+
cb = self._get_sweep_cb(row)
|
|
612
|
+
value_item = self.table.item(row, self.COL_VALUE)
|
|
613
|
+
if value_item:
|
|
614
|
+
if cb and cb.isChecked():
|
|
615
|
+
value_item.setBackground(_SWEEP_COLOR)
|
|
616
|
+
value_item.setToolTip("Enter a Python/numpy list expression, e.g. [0.1, 0.5, 1.0] or np.arange(5)")
|
|
617
|
+
else:
|
|
618
|
+
value_item.setBackground(QColor("white"))
|
|
619
|
+
value_item.setToolTip("")
|
|
620
|
+
self._update_sweep_label()
|
|
621
|
+
self.sweep_changed.emit()
|
|
622
|
+
|
|
623
|
+
def _on_item_changed(self, item):
|
|
624
|
+
if item.column() == self.COL_VALUE:
|
|
625
|
+
row = item.row()
|
|
626
|
+
cb = self._get_sweep_cb(row)
|
|
627
|
+
if cb and cb.isChecked():
|
|
628
|
+
item.setBackground(_SWEEP_COLOR)
|
|
629
|
+
self._update_sweep_label()
|
|
630
|
+
|
|
631
|
+
def _update_sweep_label(self):
|
|
632
|
+
_, sweeps = self.get_params()
|
|
633
|
+
if not sweeps:
|
|
634
|
+
self._sweep_label.setText("")
|
|
635
|
+
return
|
|
636
|
+
parts = [f"{n}: {len(v)}" for _, n, v in sweeps]
|
|
637
|
+
total = self.get_visible_count()
|
|
638
|
+
self._sweep_label.setText(f"Sweep: {' × '.join(parts)} = {total} configs")
|
|
639
|
+
|
|
640
|
+
def _show_description(self, row: int):
|
|
641
|
+
if row < 0:
|
|
642
|
+
return
|
|
643
|
+
item = self.table.item(row, self.COL_NAME)
|
|
644
|
+
if item:
|
|
645
|
+
desc = item.data(Qt.UserRole) or ""
|
|
646
|
+
name = item.text()
|
|
647
|
+
sec_item = self.table.item(row, self.COL_SECTION)
|
|
648
|
+
sec = sec_item.text() if sec_item else ""
|
|
649
|
+
html = f"<b>{sec + '.' if sec and sec != '—' else ''}{name}</b><br>{desc}"
|
|
650
|
+
self.desc_browser.setHtml(html)
|
|
651
|
+
|
|
652
|
+
def _load_from_file(self):
|
|
653
|
+
path, _ = QFileDialog.getOpenFileName(self, "Load parameters JSON",
|
|
654
|
+
"", "JSON (*.json)")
|
|
655
|
+
if not path:
|
|
656
|
+
return
|
|
657
|
+
with open(path) as f:
|
|
658
|
+
data = json.load(f)
|
|
659
|
+
if "comments" in data:
|
|
660
|
+
self.comments_edit.setText(str(data["comments"]))
|
|
661
|
+
# Flatten two-level dict → {(section, name): value}
|
|
662
|
+
flat: dict[tuple, object] = {}
|
|
663
|
+
for k, v in data.items():
|
|
664
|
+
if k == "comments":
|
|
665
|
+
continue
|
|
666
|
+
if isinstance(v, dict):
|
|
667
|
+
for sub_k, sub_v in v.items():
|
|
668
|
+
flat[(k, sub_k)] = sub_v
|
|
669
|
+
else:
|
|
670
|
+
flat[(None, k)] = v
|
|
671
|
+
# Update matching table rows
|
|
672
|
+
self.table.blockSignals(True)
|
|
673
|
+
for row in range(self.table.rowCount()):
|
|
674
|
+
sec_item = self.table.item(row, self.COL_SECTION)
|
|
675
|
+
name_item = self.table.item(row, self.COL_NAME)
|
|
676
|
+
if not (sec_item and name_item):
|
|
677
|
+
continue
|
|
678
|
+
sec = sec_item.text()
|
|
679
|
+
name = name_item.text()
|
|
680
|
+
sec_key = None if sec == "—" else sec
|
|
681
|
+
val = flat.get((sec_key, name))
|
|
682
|
+
if val is None:
|
|
683
|
+
# Try flat lookup (top-level overrides)
|
|
684
|
+
val = flat.get((None, name))
|
|
685
|
+
if val is not None:
|
|
686
|
+
self.table.item(row, self.COL_VALUE).setText(value_to_str(val))
|
|
687
|
+
self.table.blockSignals(False)
|
|
688
|
+
self._update_sweep_label()
|
|
689
|
+
|
|
690
|
+
def _reset_selected(self):
|
|
691
|
+
row = self.table.currentRow()
|
|
692
|
+
if row < 0:
|
|
693
|
+
return
|
|
694
|
+
si = self.table.item(row, self.COL_SECTION)
|
|
695
|
+
ni = self.table.item(row, self.COL_NAME)
|
|
696
|
+
if not (si and ni):
|
|
697
|
+
return
|
|
698
|
+
sec = si.text()
|
|
699
|
+
name = ni.text()
|
|
700
|
+
# Look up default in _defs: match on section label and name
|
|
701
|
+
sec_key = None if sec == "—" else sec
|
|
702
|
+
for def_sec, def_name, def_val, _ in self._defs:
|
|
703
|
+
def_sec_label = def_sec if def_sec else "—"
|
|
704
|
+
if def_sec_label == sec and def_name == name:
|
|
705
|
+
vi = self.table.item(row, self.COL_VALUE)
|
|
706
|
+
if vi:
|
|
707
|
+
vi.setText(value_to_str(def_val))
|
|
708
|
+
cb = self._get_sweep_cb(row)
|
|
709
|
+
if cb and cb.isChecked():
|
|
710
|
+
vi.setBackground(_SWEEP_COLOR)
|
|
711
|
+
else:
|
|
712
|
+
vi.setBackground(QColor("white"))
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# ─── RunConfigWidget ──────────────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
class RunConfigWidget(QWidget):
|
|
719
|
+
"""data.json run-configuration fields."""
|
|
720
|
+
|
|
721
|
+
algorithm_changed = pyqtSignal(str)
|
|
722
|
+
data_json_imported = pyqtSignal(dict) # emitted with the full loaded dict
|
|
723
|
+
|
|
724
|
+
def __init__(self, parent=None):
|
|
725
|
+
super().__init__(parent)
|
|
726
|
+
layout = QVBoxLayout(self)
|
|
727
|
+
|
|
728
|
+
# ── Algorithm ─────────────────────────────────────────────────────────
|
|
729
|
+
algo_box = QGroupBox("Source Extraction Algorithm")
|
|
730
|
+
algo_layout = QHBoxLayout(algo_box)
|
|
731
|
+
self._algo_group = QButtonGroup(self)
|
|
732
|
+
self._rb_s2p = QRadioButton("Suite2P")
|
|
733
|
+
self._rb_s3d = QRadioButton("Suite3D")
|
|
734
|
+
self._rb_s2p.setChecked(True)
|
|
735
|
+
self._algo_group.addButton(self._rb_s2p)
|
|
736
|
+
self._algo_group.addButton(self._rb_s3d)
|
|
737
|
+
algo_layout.addWidget(self._rb_s2p)
|
|
738
|
+
algo_layout.addWidget(self._rb_s3d)
|
|
739
|
+
algo_layout.addStretch()
|
|
740
|
+
btn_import = QPushButton("Load from data.json…")
|
|
741
|
+
btn_import.setToolTip("Import run configuration (and optionally file list) from an existing data.json file.")
|
|
742
|
+
btn_import.clicked.connect(self._import_data_json)
|
|
743
|
+
algo_layout.addWidget(btn_import)
|
|
744
|
+
layout.addWidget(algo_box)
|
|
745
|
+
self._rb_s2p.toggled.connect(self._on_algo_changed)
|
|
746
|
+
self._rb_s3d.toggled.connect(self._on_algo_changed)
|
|
747
|
+
|
|
748
|
+
# ── Output directory for generated JSON files ─────────────────────────
|
|
749
|
+
out_box = QGroupBox("Output Directory for Generated JSON Files")
|
|
750
|
+
out_layout = QHBoxLayout(out_box)
|
|
751
|
+
self.output_dir_edit = QLineEdit()
|
|
752
|
+
self.output_dir_edit.setPlaceholderText("Directory where data_*.json and params_*.json will be written")
|
|
753
|
+
btn_out = QPushButton("Browse…")
|
|
754
|
+
btn_out.setFixedWidth(80)
|
|
755
|
+
btn_out.clicked.connect(lambda: self._browse_dir(self.output_dir_edit))
|
|
756
|
+
out_layout.addWidget(self.output_dir_edit)
|
|
757
|
+
out_layout.addWidget(btn_out)
|
|
758
|
+
layout.addWidget(out_box)
|
|
759
|
+
|
|
760
|
+
# ── SLURM script generation ───────────────────────────────────────────
|
|
761
|
+
slurm_box = QGroupBox("SLURM Job Scripts")
|
|
762
|
+
slurm_layout = QHBoxLayout(slurm_box)
|
|
763
|
+
self._slurm_cb = QCheckBox("Generate .sh scripts from Jinja2 template")
|
|
764
|
+
self._slurm_cb.setToolTip(
|
|
765
|
+
"After generating JSON files, render one SLURM shell script per data.json.\n"
|
|
766
|
+
"Available template variables: {{ data_file }}, {{ job_id }}, {{ params_file }}."
|
|
767
|
+
)
|
|
768
|
+
self._slurm_template_edit = QLineEdit()
|
|
769
|
+
self._slurm_template_edit.setPlaceholderText("Path to Jinja2 .sh.in template file")
|
|
770
|
+
self._slurm_template_edit.setEnabled(False)
|
|
771
|
+
btn_tmpl = QPushButton("Browse…")
|
|
772
|
+
btn_tmpl.setFixedWidth(70)
|
|
773
|
+
btn_tmpl.setEnabled(False)
|
|
774
|
+
btn_tmpl.clicked.connect(self._browse_slurm_template)
|
|
775
|
+
self._slurm_cb.toggled.connect(self._slurm_template_edit.setEnabled)
|
|
776
|
+
self._slurm_cb.toggled.connect(btn_tmpl.setEnabled)
|
|
777
|
+
slurm_layout.addWidget(self._slurm_cb)
|
|
778
|
+
slurm_layout.addWidget(self._slurm_template_edit, stretch=1)
|
|
779
|
+
slurm_layout.addWidget(btn_tmpl)
|
|
780
|
+
layout.addWidget(slurm_box)
|
|
781
|
+
|
|
782
|
+
# ── Run-config form ───────────────────────────────────────────────────
|
|
783
|
+
form_scroll = QScrollArea()
|
|
784
|
+
form_scroll.setWidgetResizable(True)
|
|
785
|
+
form_container = QWidget()
|
|
786
|
+
self._form = QFormLayout(form_container)
|
|
787
|
+
self._form.setRowWrapPolicy(QFormLayout.WrapLongRows)
|
|
788
|
+
form_scroll.setWidget(form_container)
|
|
789
|
+
layout.addWidget(form_scroll)
|
|
790
|
+
|
|
791
|
+
# Build widgets for each field
|
|
792
|
+
self._widgets: dict[str, QWidget] = {}
|
|
793
|
+
self._rows: dict[str, int] = {} # field key → form row index
|
|
794
|
+
self._shown_for: dict[str, str] = {} # field key → "both"/"suite2p"/"suite3d"
|
|
795
|
+
|
|
796
|
+
for key, label, default, wtype, tooltip, shown_for in DATA_FIELDS:
|
|
797
|
+
self._shown_for[key] = shown_for
|
|
798
|
+
if wtype == "bool":
|
|
799
|
+
w = QCheckBox()
|
|
800
|
+
w.setChecked(bool(default))
|
|
801
|
+
elif wtype == "int":
|
|
802
|
+
w = QLineEdit(str(default))
|
|
803
|
+
w.setFixedWidth(120)
|
|
804
|
+
elif wtype == "path":
|
|
805
|
+
w = self._make_path_row(tooltip)
|
|
806
|
+
elif wtype == "file":
|
|
807
|
+
w = self._make_file_row(tooltip)
|
|
808
|
+
elif wtype == "textarea":
|
|
809
|
+
w = QPlainTextEdit()
|
|
810
|
+
w.setPlaceholderText("Optional notes (ignored by batch2p and extractors)")
|
|
811
|
+
w.setFixedHeight(72)
|
|
812
|
+
else: # text
|
|
813
|
+
w = QLineEdit(str(default) if default else "")
|
|
814
|
+
if wtype not in ("path", "file"):
|
|
815
|
+
w.setToolTip(tooltip)
|
|
816
|
+
row_label = QLabel(label + ":")
|
|
817
|
+
row_label.setToolTip(tooltip)
|
|
818
|
+
self._form.addRow(row_label, w)
|
|
819
|
+
self._widgets[key] = w
|
|
820
|
+
self._rows[key] = self._form.rowCount() - 1
|
|
821
|
+
|
|
822
|
+
self._update_visibility()
|
|
823
|
+
|
|
824
|
+
# ── public API ────────────────────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
def algorithm(self) -> str:
|
|
827
|
+
return "suite2p" if self._rb_s2p.isChecked() else "suite3d"
|
|
828
|
+
|
|
829
|
+
def output_dir(self) -> str:
|
|
830
|
+
return self.output_dir_edit.text().strip()
|
|
831
|
+
|
|
832
|
+
def get_fields(self) -> dict:
|
|
833
|
+
"""Return a dict of data.json run-config fields (non-empty values only)."""
|
|
834
|
+
result = {}
|
|
835
|
+
for key, _, _, wtype, _, shown_for in DATA_FIELDS:
|
|
836
|
+
if shown_for not in ("both", self.algorithm()):
|
|
837
|
+
continue
|
|
838
|
+
w = self._widgets[key]
|
|
839
|
+
if wtype == "bool":
|
|
840
|
+
result[key] = w.isChecked()
|
|
841
|
+
elif wtype in ("path", "file"):
|
|
842
|
+
edit = w.findChild(QLineEdit)
|
|
843
|
+
val = edit.text().strip() if edit else ""
|
|
844
|
+
if val:
|
|
845
|
+
result[key] = val
|
|
846
|
+
elif wtype == "textarea":
|
|
847
|
+
val = w.toPlainText().strip()
|
|
848
|
+
if val:
|
|
849
|
+
result[key] = val
|
|
850
|
+
else:
|
|
851
|
+
val = w.text().strip()
|
|
852
|
+
if val:
|
|
853
|
+
result[key] = int(val) if wtype == "int" and val.lstrip("-").isdigit() else val
|
|
854
|
+
return result
|
|
855
|
+
|
|
856
|
+
def slurm_config(self) -> dict | None:
|
|
857
|
+
"""Return {'template': path} if SLURM generation is enabled, else None."""
|
|
858
|
+
if not self._slurm_cb.isChecked():
|
|
859
|
+
return None
|
|
860
|
+
tmpl = self._slurm_template_edit.text().strip()
|
|
861
|
+
return {"template": tmpl} if tmpl else None
|
|
862
|
+
|
|
863
|
+
def to_dict(self) -> dict:
|
|
864
|
+
return {"algorithm": self.algorithm(),
|
|
865
|
+
"output_dir": self.output_dir(),
|
|
866
|
+
"fields": self.get_fields(),
|
|
867
|
+
"slurm": {"enabled": self._slurm_cb.isChecked(),
|
|
868
|
+
"template": self._slurm_template_edit.text().strip()}}
|
|
869
|
+
|
|
870
|
+
def from_dict(self, d: dict):
|
|
871
|
+
algo = d.get("algorithm", "suite2p")
|
|
872
|
+
(self._rb_s2p if algo == "suite2p" else self._rb_s3d).setChecked(True)
|
|
873
|
+
self.output_dir_edit.setText(d.get("output_dir", ""))
|
|
874
|
+
slurm = d.get("slurm", {})
|
|
875
|
+
self._slurm_cb.setChecked(bool(slurm.get("enabled", False)))
|
|
876
|
+
self._slurm_template_edit.setText(slurm.get("template", ""))
|
|
877
|
+
fields = d.get("fields", {})
|
|
878
|
+
for key, _, _, wtype, _, _ in DATA_FIELDS:
|
|
879
|
+
if key not in fields:
|
|
880
|
+
continue
|
|
881
|
+
w = self._widgets[key]
|
|
882
|
+
val = fields[key]
|
|
883
|
+
if wtype == "bool":
|
|
884
|
+
w.setChecked(bool(val))
|
|
885
|
+
elif wtype in ("path", "file"):
|
|
886
|
+
edit = w.findChild(QLineEdit)
|
|
887
|
+
if edit:
|
|
888
|
+
edit.setText(str(val))
|
|
889
|
+
elif wtype == "textarea":
|
|
890
|
+
w.setPlainText(str(val))
|
|
891
|
+
else:
|
|
892
|
+
w.setText(str(val))
|
|
893
|
+
|
|
894
|
+
def apply_data_json(self, data: dict):
|
|
895
|
+
"""Populate run-config fields from a raw data.json dict.
|
|
896
|
+
|
|
897
|
+
Sets the algorithm from 'source_extraction', then fills every DATA_FIELDS
|
|
898
|
+
key that is present in *data*. Fields not present in *data* are left
|
|
899
|
+
unchanged. The keys 'root_path', 'data', 'behavior_data', and
|
|
900
|
+
'params_file' are intentionally skipped here — the caller is responsible
|
|
901
|
+
for updating the InputFilesWidget with those values.
|
|
902
|
+
"""
|
|
903
|
+
algo = data.get("source_extraction", "suite2p")
|
|
904
|
+
(self._rb_s2p if algo == "suite2p" else self._rb_s3d).setChecked(True)
|
|
905
|
+
skip = {"source_extraction", "root_path", "data", "behavior_data", "params_file"}
|
|
906
|
+
for key, _, _, wtype, _, _ in DATA_FIELDS:
|
|
907
|
+
if key in skip or key not in data:
|
|
908
|
+
continue
|
|
909
|
+
w = self._widgets[key]
|
|
910
|
+
val = data[key]
|
|
911
|
+
if wtype == "bool":
|
|
912
|
+
w.setChecked(bool(val))
|
|
913
|
+
elif wtype in ("path", "file"):
|
|
914
|
+
edit = w.findChild(QLineEdit)
|
|
915
|
+
if edit:
|
|
916
|
+
edit.setText(str(val))
|
|
917
|
+
elif wtype == "textarea":
|
|
918
|
+
w.setPlainText(str(val))
|
|
919
|
+
else:
|
|
920
|
+
w.setText(str(val))
|
|
921
|
+
|
|
922
|
+
# ── internals ─────────────────────────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
def _browse_slurm_template(self):
|
|
925
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
926
|
+
self, "Select Jinja2 template", "",
|
|
927
|
+
"Shell templates (*.sh.in *.sh *.j2 *.jinja2);;All files (*)"
|
|
928
|
+
)
|
|
929
|
+
if path:
|
|
930
|
+
self._slurm_template_edit.setText(path)
|
|
931
|
+
|
|
932
|
+
def _import_data_json(self):
|
|
933
|
+
path, _ = QFileDialog.getOpenFileName(self, "Open data.json",
|
|
934
|
+
"", "JSON (*.json)")
|
|
935
|
+
if not path:
|
|
936
|
+
return
|
|
937
|
+
try:
|
|
938
|
+
with open(path) as f:
|
|
939
|
+
data = json.load(f)
|
|
940
|
+
except Exception as exc:
|
|
941
|
+
QMessageBox.critical(self, "Load error", f"Could not read file:\n{exc}")
|
|
942
|
+
return
|
|
943
|
+
self.apply_data_json(data)
|
|
944
|
+
self.data_json_imported.emit(data)
|
|
945
|
+
|
|
946
|
+
def _make_path_row(self, tooltip: str) -> QWidget:
|
|
947
|
+
container = QWidget()
|
|
948
|
+
row = QHBoxLayout(container)
|
|
949
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
950
|
+
edit = QLineEdit()
|
|
951
|
+
edit.setToolTip(tooltip)
|
|
952
|
+
btn = QPushButton("…")
|
|
953
|
+
btn.setFixedWidth(28)
|
|
954
|
+
btn.clicked.connect(lambda: self._browse_dir(edit))
|
|
955
|
+
row.addWidget(edit)
|
|
956
|
+
row.addWidget(btn)
|
|
957
|
+
return container
|
|
958
|
+
|
|
959
|
+
def _make_file_row(self, tooltip: str) -> QWidget:
|
|
960
|
+
container = QWidget()
|
|
961
|
+
row = QHBoxLayout(container)
|
|
962
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
963
|
+
edit = QLineEdit()
|
|
964
|
+
edit.setToolTip(tooltip)
|
|
965
|
+
btn = QPushButton("…")
|
|
966
|
+
btn.setFixedWidth(28)
|
|
967
|
+
btn.clicked.connect(lambda: self._browse_file(edit))
|
|
968
|
+
row.addWidget(edit)
|
|
969
|
+
row.addWidget(btn)
|
|
970
|
+
return container
|
|
971
|
+
|
|
972
|
+
def _browse_file(self, edit: QLineEdit):
|
|
973
|
+
path, _ = QFileDialog.getOpenFileName(self, "Select file",
|
|
974
|
+
edit.text() or "",
|
|
975
|
+
"JSON files (*.json);;All files (*)")
|
|
976
|
+
if path:
|
|
977
|
+
edit.setText(path)
|
|
978
|
+
|
|
979
|
+
def _browse_dir(self, target: QLineEdit):
|
|
980
|
+
# target may be a QLineEdit directly or a container with one inside
|
|
981
|
+
if isinstance(target, QLineEdit):
|
|
982
|
+
edit = target
|
|
983
|
+
else:
|
|
984
|
+
edit = target.findChild(QLineEdit)
|
|
985
|
+
if edit is None:
|
|
986
|
+
return
|
|
987
|
+
path = QFileDialog.getExistingDirectory(self, "Select directory",
|
|
988
|
+
edit.text() or "")
|
|
989
|
+
if path:
|
|
990
|
+
edit.setText(path)
|
|
991
|
+
|
|
992
|
+
def _on_algo_changed(self):
|
|
993
|
+
self._update_visibility()
|
|
994
|
+
self.algorithm_changed.emit(self.algorithm())
|
|
995
|
+
|
|
996
|
+
def _update_visibility(self):
|
|
997
|
+
algo = self.algorithm()
|
|
998
|
+
for key, _, _, _, _, shown_for in DATA_FIELDS:
|
|
999
|
+
visible = shown_for == "both" or shown_for == algo
|
|
1000
|
+
row_idx = self._rows[key]
|
|
1001
|
+
label_item = self._form.itemAt(row_idx, QFormLayout.LabelRole)
|
|
1002
|
+
field_item = self._form.itemAt(row_idx, QFormLayout.FieldRole)
|
|
1003
|
+
for item in (label_item, field_item):
|
|
1004
|
+
if item and item.widget():
|
|
1005
|
+
item.widget().setVisible(visible)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# ─── MainWindow ───────────────────────────────────────────────────────────────
|
|
1009
|
+
|
|
1010
|
+
class MainWindow(QMainWindow):
|
|
1011
|
+
def __init__(self):
|
|
1012
|
+
super().__init__()
|
|
1013
|
+
self._current_project_path: str | None = None
|
|
1014
|
+
self.setWindowTitle("batch2p GUI")
|
|
1015
|
+
self.resize(1300, 800)
|
|
1016
|
+
|
|
1017
|
+
# ── Menu ──────────────────────────────────────────────────────────────
|
|
1018
|
+
menu = self.menuBar()
|
|
1019
|
+
file_menu = menu.addMenu("&File")
|
|
1020
|
+
act_new = QAction("&New project", self, shortcut="Ctrl+N")
|
|
1021
|
+
act_open = QAction("&Open project…", self, shortcut="Ctrl+O")
|
|
1022
|
+
act_save = QAction("&Save project", self, shortcut="Ctrl+S")
|
|
1023
|
+
act_save_as = QAction("Save project &As…", self, shortcut="Ctrl+Shift+S")
|
|
1024
|
+
act_gen = QAction("&Generate JSON files…", self, shortcut="Ctrl+G")
|
|
1025
|
+
file_menu.addAction(act_new)
|
|
1026
|
+
file_menu.addAction(act_open)
|
|
1027
|
+
file_menu.addAction(act_save)
|
|
1028
|
+
file_menu.addAction(act_save_as)
|
|
1029
|
+
act_exit = QAction("E&xit", self, shortcut="Ctrl+Q")
|
|
1030
|
+
file_menu.addSeparator()
|
|
1031
|
+
file_menu.addAction(act_gen)
|
|
1032
|
+
file_menu.addSeparator()
|
|
1033
|
+
file_menu.addAction(act_exit)
|
|
1034
|
+
act_new.triggered.connect(self._new_project)
|
|
1035
|
+
act_open.triggered.connect(self._open_project)
|
|
1036
|
+
act_save.triggered.connect(self._save_project)
|
|
1037
|
+
act_save_as.triggered.connect(self._save_project_as)
|
|
1038
|
+
act_gen.triggered.connect(self._generate)
|
|
1039
|
+
act_exit.triggered.connect(self.close)
|
|
1040
|
+
|
|
1041
|
+
# ── Toolbar ───────────────────────────────────────────────────────────
|
|
1042
|
+
tb = QToolBar("Main toolbar")
|
|
1043
|
+
tb.setMovable(False)
|
|
1044
|
+
self.addToolBar(tb)
|
|
1045
|
+
tb.addAction(act_new)
|
|
1046
|
+
tb.addAction(act_open)
|
|
1047
|
+
tb.addAction(act_save)
|
|
1048
|
+
tb.addAction(act_save_as)
|
|
1049
|
+
tb.addSeparator()
|
|
1050
|
+
btn_gen = QPushButton(" ⚙ Generate JSON files ")
|
|
1051
|
+
btn_gen.setFixedHeight(32)
|
|
1052
|
+
btn_gen.setStyleSheet("font-weight: bold; background: #2266aa; color: white; border-radius: 4px;")
|
|
1053
|
+
btn_gen.clicked.connect(self._generate)
|
|
1054
|
+
tb.addWidget(btn_gen)
|
|
1055
|
+
|
|
1056
|
+
# ── Central widget ────────────────────────────────────────────────────
|
|
1057
|
+
splitter = QSplitter(Qt.Horizontal)
|
|
1058
|
+
self.setCentralWidget(splitter)
|
|
1059
|
+
|
|
1060
|
+
# Left: input files
|
|
1061
|
+
self.input_widget = InputFilesWidget()
|
|
1062
|
+
self.input_widget.setMinimumWidth(340)
|
|
1063
|
+
splitter.addWidget(self.input_widget)
|
|
1064
|
+
|
|
1065
|
+
# Right: settings tabs
|
|
1066
|
+
right_panel = QWidget()
|
|
1067
|
+
right_layout = QVBoxLayout(right_panel)
|
|
1068
|
+
right_layout.setContentsMargins(4, 4, 4, 4)
|
|
1069
|
+
|
|
1070
|
+
tabs = QTabWidget()
|
|
1071
|
+
self.run_config = RunConfigWidget()
|
|
1072
|
+
tabs.addTab(self.run_config, "Run Configuration")
|
|
1073
|
+
|
|
1074
|
+
self.param_table_s2p = ParamTableWidget(S2P_PARAMS)
|
|
1075
|
+
self.param_table_s3d = ParamTableWidget(S3D_PARAMS)
|
|
1076
|
+
# Stack both in a container; show only the active one
|
|
1077
|
+
self._param_stack = QWidget()
|
|
1078
|
+
stack_layout = QVBoxLayout(self._param_stack)
|
|
1079
|
+
stack_layout.setContentsMargins(0, 0, 0, 0)
|
|
1080
|
+
stack_layout.addWidget(self.param_table_s2p)
|
|
1081
|
+
stack_layout.addWidget(self.param_table_s3d)
|
|
1082
|
+
tabs.addTab(self._param_stack, "Algorithm Parameters")
|
|
1083
|
+
|
|
1084
|
+
right_layout.addWidget(tabs)
|
|
1085
|
+
|
|
1086
|
+
# Status strip
|
|
1087
|
+
self._status = QLabel("")
|
|
1088
|
+
self._status.setStyleSheet("padding: 4px; background: #f5f5f5;")
|
|
1089
|
+
right_layout.addWidget(self._status)
|
|
1090
|
+
|
|
1091
|
+
splitter.addWidget(right_panel)
|
|
1092
|
+
splitter.setSizes([420, 880])
|
|
1093
|
+
|
|
1094
|
+
# Connect algorithm change to param table visibility
|
|
1095
|
+
self.run_config.algorithm_changed.connect(self._on_algo_changed)
|
|
1096
|
+
self._on_algo_changed(self.run_config.algorithm())
|
|
1097
|
+
for tbl in (self.param_table_s2p, self.param_table_s3d):
|
|
1098
|
+
tbl.sweep_changed.connect(self._update_status)
|
|
1099
|
+
self._update_status()
|
|
1100
|
+
self.run_config.data_json_imported.connect(self._on_data_json_imported)
|
|
1101
|
+
|
|
1102
|
+
# Status bar
|
|
1103
|
+
self.setStatusBar(QStatusBar())
|
|
1104
|
+
self.statusBar().showMessage("Ready.")
|
|
1105
|
+
|
|
1106
|
+
# ── Project save / load ───────────────────────────────────────────────────
|
|
1107
|
+
|
|
1108
|
+
def _new_project(self):
|
|
1109
|
+
if QMessageBox.question(self, "New project",
|
|
1110
|
+
"Discard current project and start fresh?",
|
|
1111
|
+
QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes:
|
|
1112
|
+
self._current_project_path = None
|
|
1113
|
+
self.setWindowTitle("batch2p GUI")
|
|
1114
|
+
self.input_widget.from_dict({"root_path": "", "tif_files": [], "b64_files": []})
|
|
1115
|
+
self.run_config.from_dict({})
|
|
1116
|
+
self.param_table_s2p.load_defaults()
|
|
1117
|
+
self.param_table_s2p.comments_edit.clear()
|
|
1118
|
+
self.param_table_s3d.load_defaults()
|
|
1119
|
+
self.param_table_s3d.comments_edit.clear()
|
|
1120
|
+
|
|
1121
|
+
def _save_project(self):
|
|
1122
|
+
"""Save to the current project file, or show a dialog if none is open."""
|
|
1123
|
+
if self._current_project_path:
|
|
1124
|
+
self._write_project(self._current_project_path)
|
|
1125
|
+
else:
|
|
1126
|
+
self._save_project_as()
|
|
1127
|
+
|
|
1128
|
+
def _save_project_as(self):
|
|
1129
|
+
"""Always show a save dialog to choose (or change) the project file."""
|
|
1130
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save project as",
|
|
1131
|
+
self._current_project_path or "",
|
|
1132
|
+
"batch2p project (*.b2p.json)")
|
|
1133
|
+
if not path:
|
|
1134
|
+
return
|
|
1135
|
+
if not path.endswith(".b2p.json"):
|
|
1136
|
+
path += ".b2p.json"
|
|
1137
|
+
self._write_project(path)
|
|
1138
|
+
|
|
1139
|
+
def _write_project(self, path: str):
|
|
1140
|
+
project = {
|
|
1141
|
+
"input_files": self.input_widget.to_dict(),
|
|
1142
|
+
"run_config": self.run_config.to_dict(),
|
|
1143
|
+
"params_s2p": self._table_to_save(self.param_table_s2p),
|
|
1144
|
+
"params_s3d": self._table_to_save(self.param_table_s3d),
|
|
1145
|
+
}
|
|
1146
|
+
with open(path, "w") as f:
|
|
1147
|
+
json.dump(project, f, indent=2)
|
|
1148
|
+
self._current_project_path = path
|
|
1149
|
+
self.setWindowTitle(f"batch2p GUI — {Path(path).name}")
|
|
1150
|
+
self.statusBar().showMessage(f"Project saved to {path}")
|
|
1151
|
+
|
|
1152
|
+
def _open_project(self):
|
|
1153
|
+
path, _ = QFileDialog.getOpenFileName(self, "Open project",
|
|
1154
|
+
"", "batch2p project (*.b2p.json);;JSON (*.json)")
|
|
1155
|
+
if not path:
|
|
1156
|
+
return
|
|
1157
|
+
self._load_project_file(path)
|
|
1158
|
+
|
|
1159
|
+
def _load_project_file(self, path: str):
|
|
1160
|
+
with open(path) as f:
|
|
1161
|
+
project = json.load(f)
|
|
1162
|
+
self.input_widget.from_dict(project.get("input_files", {}))
|
|
1163
|
+
self.run_config.from_dict(project.get("run_config", {}))
|
|
1164
|
+
self._table_from_save(self.param_table_s2p, project.get("params_s2p", {}))
|
|
1165
|
+
self._table_from_save(self.param_table_s3d, project.get("params_s3d", {}))
|
|
1166
|
+
self._current_project_path = path
|
|
1167
|
+
self.setWindowTitle(f"batch2p GUI — {Path(path).name}")
|
|
1168
|
+
self.statusBar().showMessage(f"Project loaded from {path}")
|
|
1169
|
+
|
|
1170
|
+
@staticmethod
|
|
1171
|
+
def _table_to_save(table: ParamTableWidget) -> dict:
|
|
1172
|
+
"""Serialise table state (values + sweep flags) for project files."""
|
|
1173
|
+
rows = []
|
|
1174
|
+
for r in range(table.table.rowCount()):
|
|
1175
|
+
si = table.table.item(r, ParamTableWidget.COL_SECTION)
|
|
1176
|
+
ni = table.table.item(r, ParamTableWidget.COL_NAME)
|
|
1177
|
+
vi = table.table.item(r, ParamTableWidget.COL_VALUE)
|
|
1178
|
+
cb = table._get_sweep_cb(r)
|
|
1179
|
+
if si and ni and vi:
|
|
1180
|
+
rows.append({
|
|
1181
|
+
"section": si.text(),
|
|
1182
|
+
"name": ni.text(),
|
|
1183
|
+
"value": vi.text(),
|
|
1184
|
+
"sweep": cb.isChecked() if cb else False,
|
|
1185
|
+
})
|
|
1186
|
+
return {"rows": rows, "comments": table.comments_edit.text()}
|
|
1187
|
+
|
|
1188
|
+
@staticmethod
|
|
1189
|
+
def _table_from_save(table: ParamTableWidget, saved: dict):
|
|
1190
|
+
rows = saved.get("rows", [])
|
|
1191
|
+
lookup = {(r["section"], r["name"]): r for r in rows}
|
|
1192
|
+
table.table.blockSignals(True)
|
|
1193
|
+
for r in range(table.table.rowCount()):
|
|
1194
|
+
si = table.table.item(r, ParamTableWidget.COL_SECTION)
|
|
1195
|
+
ni = table.table.item(r, ParamTableWidget.COL_NAME)
|
|
1196
|
+
vi = table.table.item(r, ParamTableWidget.COL_VALUE)
|
|
1197
|
+
cb = table._get_sweep_cb(r)
|
|
1198
|
+
if si and ni and vi:
|
|
1199
|
+
key = (si.text(), ni.text())
|
|
1200
|
+
saved_row = lookup.get(key)
|
|
1201
|
+
if saved_row:
|
|
1202
|
+
vi.setText(saved_row["value"])
|
|
1203
|
+
if cb:
|
|
1204
|
+
cb.setChecked(saved_row.get("sweep", False))
|
|
1205
|
+
table.table.blockSignals(False)
|
|
1206
|
+
table._update_sweep_label()
|
|
1207
|
+
table.comments_edit.setText(saved.get("comments", ""))
|
|
1208
|
+
|
|
1209
|
+
# ── Generate ──────────────────────────────────────────────────────────────
|
|
1210
|
+
|
|
1211
|
+
def _generate(self):
|
|
1212
|
+
# Collect data
|
|
1213
|
+
algo = self.run_config.algorithm()
|
|
1214
|
+
out_dir = self.run_config.output_dir()
|
|
1215
|
+
fields = self.run_config.get_fields()
|
|
1216
|
+
file_data = self.input_widget.to_dict()
|
|
1217
|
+
param_table = self.param_table_s2p if algo == "suite2p" else self.param_table_s3d
|
|
1218
|
+
|
|
1219
|
+
# Validate
|
|
1220
|
+
errors = []
|
|
1221
|
+
if not out_dir:
|
|
1222
|
+
errors.append("Output directory is not set (Run Configuration tab).")
|
|
1223
|
+
if not file_data.get("tif_files"):
|
|
1224
|
+
errors.append("No TIFF files selected.")
|
|
1225
|
+
if not fields.get("job_id"):
|
|
1226
|
+
errors.append("Job ID is not set.")
|
|
1227
|
+
|
|
1228
|
+
# Try to evaluate sweep values; collect errors
|
|
1229
|
+
# Suite3D params.json must be a flat (single-level) dict; sections are
|
|
1230
|
+
# display-only in the GUI and must not appear as nested keys in the file.
|
|
1231
|
+
flat_params = (algo == "suite3d")
|
|
1232
|
+
base_params, sweep_list = self._validate_sweep(param_table, errors, flat=flat_params)
|
|
1233
|
+
if errors:
|
|
1234
|
+
QMessageBox.critical(self, "Validation error",
|
|
1235
|
+
"Please fix the following issues:\n\n" + "\n".join(f"• {e}" for e in errors))
|
|
1236
|
+
return
|
|
1237
|
+
|
|
1238
|
+
# Confirm
|
|
1239
|
+
n_configs = 1
|
|
1240
|
+
for _, _, vals in sweep_list:
|
|
1241
|
+
n_configs *= len(vals)
|
|
1242
|
+
msg = (f"Algorithm: {algo}\n"
|
|
1243
|
+
f"Job ID: {fields.get('job_id', '')}\n"
|
|
1244
|
+
f"Output dir: {out_dir}\n\n"
|
|
1245
|
+
f"This will generate {n_configs} data.json + params.json file pair{'s' if n_configs > 1 else ''}.\n"
|
|
1246
|
+
"Proceed?")
|
|
1247
|
+
if QMessageBox.question(self, "Generate JSON files", msg,
|
|
1248
|
+
QMessageBox.Yes | QMessageBox.No) != QMessageBox.Yes:
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
# Generate
|
|
1252
|
+
try:
|
|
1253
|
+
out_path = Path(out_dir)
|
|
1254
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
1255
|
+
job_id = fields.get("job_id", "run")
|
|
1256
|
+
|
|
1257
|
+
# Load Jinja2 template once (if requested)
|
|
1258
|
+
slurm_cfg = self.run_config.slurm_config()
|
|
1259
|
+
jinja_template = None
|
|
1260
|
+
if slurm_cfg:
|
|
1261
|
+
try:
|
|
1262
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
1263
|
+
tmpl_path = Path(slurm_cfg["template"])
|
|
1264
|
+
env = Environment(
|
|
1265
|
+
loader=FileSystemLoader(str(tmpl_path.parent)),
|
|
1266
|
+
undefined=StrictUndefined,
|
|
1267
|
+
keep_trailing_newline=True,
|
|
1268
|
+
)
|
|
1269
|
+
jinja_template = env.get_template(tmpl_path.name)
|
|
1270
|
+
except ImportError:
|
|
1271
|
+
QMessageBox.critical(self, "Missing dependency",
|
|
1272
|
+
"jinja2 is required for SLURM script generation.\n"
|
|
1273
|
+
"Install it with: pip install jinja2")
|
|
1274
|
+
return
|
|
1275
|
+
except Exception as exc:
|
|
1276
|
+
QMessageBox.critical(self, "Template error",
|
|
1277
|
+
f"Could not load template:\n{exc}")
|
|
1278
|
+
return
|
|
1279
|
+
|
|
1280
|
+
combinations = self._expand_sweeps(sweep_list)
|
|
1281
|
+
if not combinations:
|
|
1282
|
+
combinations = [{}]
|
|
1283
|
+
|
|
1284
|
+
generated_pairs = []
|
|
1285
|
+
digits = len(str(len(combinations)))
|
|
1286
|
+
for idx, combo in enumerate(combinations, start=1):
|
|
1287
|
+
# Build params dict for this combo
|
|
1288
|
+
params_dict = copy.deepcopy(base_params)
|
|
1289
|
+
for section, name, val in combo:
|
|
1290
|
+
if flat_params or section == "—" or not section:
|
|
1291
|
+
params_dict[name] = val
|
|
1292
|
+
else:
|
|
1293
|
+
params_dict.setdefault(section, {})[name] = val
|
|
1294
|
+
params_comments = param_table.comments_edit.text().strip()
|
|
1295
|
+
if params_comments:
|
|
1296
|
+
params_dict["comments"] = params_comments
|
|
1297
|
+
params_dict = make_json_serializable(params_dict)
|
|
1298
|
+
|
|
1299
|
+
# File naming
|
|
1300
|
+
suffix = f"_{idx:0{digits}d}" if len(combinations) > 1 else ""
|
|
1301
|
+
params_fname = f"{job_id}{suffix}_params.json"
|
|
1302
|
+
data_fname = f"{job_id}{suffix}_data.json"
|
|
1303
|
+
params_path = out_path / params_fname
|
|
1304
|
+
data_path_f = out_path / data_fname
|
|
1305
|
+
|
|
1306
|
+
with open(params_path, "w") as f:
|
|
1307
|
+
json.dump(params_dict, f, indent=2)
|
|
1308
|
+
|
|
1309
|
+
# Build data.json — prepend root_path to make entries absolute server paths
|
|
1310
|
+
root_path = file_data.get("root_path", "")
|
|
1311
|
+
tif_rel = file_data.get("tif_files", [])
|
|
1312
|
+
b64_rel = file_data.get("b64_files", [])
|
|
1313
|
+
if root_path:
|
|
1314
|
+
root = Path(root_path)
|
|
1315
|
+
tif_entries = [str(root / p) for p in tif_rel]
|
|
1316
|
+
b64_entries = [str(root / p) for p in b64_rel]
|
|
1317
|
+
else:
|
|
1318
|
+
tif_entries = tif_rel
|
|
1319
|
+
b64_entries = b64_rel
|
|
1320
|
+
|
|
1321
|
+
data_dict = {
|
|
1322
|
+
"source_extraction": algo,
|
|
1323
|
+
"params_file": str(params_path.resolve()),
|
|
1324
|
+
"data": tif_entries,
|
|
1325
|
+
}
|
|
1326
|
+
if root_path:
|
|
1327
|
+
data_dict["root_path"] = root_path
|
|
1328
|
+
if b64_entries:
|
|
1329
|
+
data_dict["behavior_data"] = b64_entries
|
|
1330
|
+
data_dict.update(fields)
|
|
1331
|
+
# Give each sweep configuration a unique job_id by appending the
|
|
1332
|
+
# same numeric suffix used in the file names.
|
|
1333
|
+
if suffix:
|
|
1334
|
+
data_dict["job_id"] = f"{job_id}{suffix}"
|
|
1335
|
+
data_dict = make_json_serializable(data_dict)
|
|
1336
|
+
data_dict = _apply_template_vars(data_dict, {
|
|
1337
|
+
"job_id": data_dict.get("job_id", ""),
|
|
1338
|
+
"root_dir": root_path,
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
with open(data_path_f, "w") as f:
|
|
1342
|
+
json.dump(data_dict, f, indent=2)
|
|
1343
|
+
|
|
1344
|
+
generated_pairs.append((params_fname, data_fname))
|
|
1345
|
+
|
|
1346
|
+
# One SLURM script covers all configurations (array or single).
|
|
1347
|
+
sh_fname = None
|
|
1348
|
+
if jinja_template is not None:
|
|
1349
|
+
is_array = len(combinations) > 1
|
|
1350
|
+
sh_fname = f"{job_id}.sh"
|
|
1351
|
+
# For the non-array case supply the exact data file path.
|
|
1352
|
+
single_data_file = str((out_path / f"{job_id}_data.json").resolve())
|
|
1353
|
+
rendered = jinja_template.render(
|
|
1354
|
+
is_array=is_array,
|
|
1355
|
+
n_jobs=len(combinations),
|
|
1356
|
+
index_digits=digits,
|
|
1357
|
+
out_dir=str(out_path.resolve()),
|
|
1358
|
+
job_id=job_id,
|
|
1359
|
+
data_file=single_data_file,
|
|
1360
|
+
working_dir=fields.get("working_dir", ""),
|
|
1361
|
+
)
|
|
1362
|
+
with open(out_path / sh_fname, "w") as f:
|
|
1363
|
+
f.write(rendered)
|
|
1364
|
+
|
|
1365
|
+
# Summary
|
|
1366
|
+
lines = [f"Generated {len(generated_pairs)} configuration(s) in:\n{out_dir}\n"]
|
|
1367
|
+
for pf, df in generated_pairs[:10]:
|
|
1368
|
+
lines.append(f" {df} + {pf}")
|
|
1369
|
+
if len(generated_pairs) > 10:
|
|
1370
|
+
lines.append(f" … and {len(generated_pairs) - 10} more")
|
|
1371
|
+
if sh_fname:
|
|
1372
|
+
array_note = f" (job array 1–{len(combinations)})" if len(combinations) > 1 else ""
|
|
1373
|
+
lines.append(f"\nSLURM script{array_note}: {sh_fname}")
|
|
1374
|
+
QMessageBox.information(self, "Generation complete", "\n".join(lines))
|
|
1375
|
+
what = "config pair(s)" + (f" + {sh_fname}" if sh_fname else "")
|
|
1376
|
+
self.statusBar().showMessage(f"Generated {len(generated_pairs)} {what} → {out_dir}")
|
|
1377
|
+
|
|
1378
|
+
except Exception as exc:
|
|
1379
|
+
QMessageBox.critical(self, "Generation failed", str(exc))
|
|
1380
|
+
|
|
1381
|
+
@staticmethod
|
|
1382
|
+
def _validate_sweep(table: ParamTableWidget, errors: list,
|
|
1383
|
+
flat: bool = False) -> tuple[dict, list]:
|
|
1384
|
+
"""Evaluate and validate sweep parameters. Appends to errors on failure.
|
|
1385
|
+
|
|
1386
|
+
When *flat* is True every parameter is written as a top-level key
|
|
1387
|
+
regardless of its section (required for Suite3D params.json).
|
|
1388
|
+
"""
|
|
1389
|
+
base_params: dict = {}
|
|
1390
|
+
sweep_list = []
|
|
1391
|
+
for row in range(table.table.rowCount()):
|
|
1392
|
+
si = table.table.item(row, ParamTableWidget.COL_SECTION)
|
|
1393
|
+
ni = table.table.item(row, ParamTableWidget.COL_NAME)
|
|
1394
|
+
vi = table.table.item(row, ParamTableWidget.COL_VALUE)
|
|
1395
|
+
cb = table._get_sweep_cb(row)
|
|
1396
|
+
if not (si and ni and vi):
|
|
1397
|
+
continue
|
|
1398
|
+
section = si.text().strip()
|
|
1399
|
+
name = ni.text().strip()
|
|
1400
|
+
val_str = vi.text().strip()
|
|
1401
|
+
is_sweep = cb.isChecked() if cb else False
|
|
1402
|
+
|
|
1403
|
+
try:
|
|
1404
|
+
value = safe_eval(val_str)
|
|
1405
|
+
except Exception as e:
|
|
1406
|
+
errors.append(f"Parameter '{name}' — invalid expression '{val_str}': {e}")
|
|
1407
|
+
continue
|
|
1408
|
+
|
|
1409
|
+
if is_sweep:
|
|
1410
|
+
if not hasattr(value, "__iter__") or isinstance(value, str):
|
|
1411
|
+
errors.append(f"Parameter '{name}' is marked Sweep but value is not a list/array.")
|
|
1412
|
+
continue
|
|
1413
|
+
vals = list(value)
|
|
1414
|
+
if not vals:
|
|
1415
|
+
errors.append(f"Parameter '{name}' sweep list is empty.")
|
|
1416
|
+
continue
|
|
1417
|
+
sweep_list.append((section, name, vals))
|
|
1418
|
+
else:
|
|
1419
|
+
if flat or section == "—" or not section:
|
|
1420
|
+
base_params[name] = value
|
|
1421
|
+
else:
|
|
1422
|
+
base_params.setdefault(section, {})[name] = value
|
|
1423
|
+
|
|
1424
|
+
return base_params, sweep_list
|
|
1425
|
+
|
|
1426
|
+
@staticmethod
|
|
1427
|
+
def _expand_sweeps(sweep_list: list) -> list[list[tuple]]:
|
|
1428
|
+
"""Return a list of combinations; each combination is a list of (section, name, value)."""
|
|
1429
|
+
if not sweep_list:
|
|
1430
|
+
return []
|
|
1431
|
+
keys = [(s, n) for s, n, _ in sweep_list]
|
|
1432
|
+
values = [v for _, _, v in sweep_list]
|
|
1433
|
+
combos = []
|
|
1434
|
+
for combo_vals in itertools.product(*values):
|
|
1435
|
+
combos.append([(sec, name, val)
|
|
1436
|
+
for (sec, name), val in zip(keys, combo_vals)])
|
|
1437
|
+
return combos
|
|
1438
|
+
|
|
1439
|
+
# ── Misc ──────────────────────────────────────────────────────────────────
|
|
1440
|
+
|
|
1441
|
+
def _on_data_json_imported(self, data: dict):
|
|
1442
|
+
"""Offer to also import root_path / data / behavior_data into the file list."""
|
|
1443
|
+
has_files = bool(data.get("data") or data.get("behavior_data") or data.get("root_path"))
|
|
1444
|
+
if not has_files:
|
|
1445
|
+
return
|
|
1446
|
+
reply = QMessageBox.question(
|
|
1447
|
+
self, "Import file list",
|
|
1448
|
+
"The data.json also contains file-list entries (root_path / data / behavior_data).\n"
|
|
1449
|
+
"Import them into the Input Files panel as well?",
|
|
1450
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
1451
|
+
)
|
|
1452
|
+
if reply != QMessageBox.Yes:
|
|
1453
|
+
return
|
|
1454
|
+
root = data.get("root_path", "")
|
|
1455
|
+
tif_entries = data.get("data", [])
|
|
1456
|
+
b64_entries = data.get("behavior_data", [])
|
|
1457
|
+
# Strip root_path prefix so the file list shows relative paths.
|
|
1458
|
+
def to_rel(entries, root_dir):
|
|
1459
|
+
if not root_dir:
|
|
1460
|
+
return entries
|
|
1461
|
+
r = Path(root_dir)
|
|
1462
|
+
result = []
|
|
1463
|
+
for e in entries:
|
|
1464
|
+
try:
|
|
1465
|
+
result.append(str(Path(e).relative_to(r)))
|
|
1466
|
+
except ValueError:
|
|
1467
|
+
result.append(e)
|
|
1468
|
+
return result
|
|
1469
|
+
self.input_widget.from_dict({
|
|
1470
|
+
"root_path": root,
|
|
1471
|
+
"tif_files": to_rel(tif_entries, root),
|
|
1472
|
+
"b64_files": to_rel(b64_entries, root),
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
def _on_algo_changed(self, algo: str):
|
|
1476
|
+
is_s2p = algo == "suite2p"
|
|
1477
|
+
self.param_table_s2p.setVisible(is_s2p)
|
|
1478
|
+
self.param_table_s3d.setVisible(not is_s2p)
|
|
1479
|
+
self._update_status()
|
|
1480
|
+
|
|
1481
|
+
def _update_status(self):
|
|
1482
|
+
algo = self.run_config.algorithm()
|
|
1483
|
+
tbl = self.param_table_s2p if algo == "suite2p" else self.param_table_s3d
|
|
1484
|
+
n = tbl.get_visible_count()
|
|
1485
|
+
self._status.setText(
|
|
1486
|
+
f"Algorithm: <b>{algo}</b> | "
|
|
1487
|
+
f"{'<b>' + str(n) + ' run configuration(s)</b> will be generated' if n > 1 else '1 run configuration will be generated'}"
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
1492
|
+
|
|
1493
|
+
def main():
|
|
1494
|
+
app = QApplication(sys.argv)
|
|
1495
|
+
app.setApplicationName("batch2p GUI")
|
|
1496
|
+
app.setStyle("Fusion")
|
|
1497
|
+
win = MainWindow()
|
|
1498
|
+
win.show()
|
|
1499
|
+
# Open project file passed as CLI argument
|
|
1500
|
+
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
|
1501
|
+
if args:
|
|
1502
|
+
project_path = args[0]
|
|
1503
|
+
try:
|
|
1504
|
+
win._load_project_file(project_path)
|
|
1505
|
+
except Exception as exc:
|
|
1506
|
+
from PyQt5.QtWidgets import QMessageBox
|
|
1507
|
+
QMessageBox.critical(win, "Open failed",
|
|
1508
|
+
f"Could not open project file:\n{project_path}\n\n{exc}")
|
|
1509
|
+
sys.exit(app.exec_())
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
if __name__ == "__main__":
|
|
1513
|
+
main()
|