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/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()