mesofield 0.3.2b0__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.
Files changed (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -0,0 +1,1050 @@
1
+ import os
2
+ import json
3
+ from typing import List, Tuple, Optional
4
+
5
+ import numpy as np
6
+ import tifffile
7
+ import pyqtgraph as pg
8
+
9
+ from PyQt6.QtCore import QObject, pyqtSignal, QRunnable, QThreadPool, Qt, QTimer
10
+ from PyQt6.QtWidgets import (
11
+ QApplication,
12
+ QWidget,
13
+ QVBoxLayout,
14
+ QHBoxLayout,
15
+ QPushButton,
16
+ QLabel,
17
+ QComboBox,
18
+ QFileDialog,
19
+ QSlider,
20
+ QProgressBar,
21
+ QCheckBox,
22
+ QSpinBox,
23
+ QDoubleSpinBox,
24
+ QGroupBox,
25
+ QGridLayout,
26
+ QMessageBox,
27
+ )
28
+
29
+ from mesofield.utils._logger import get_logger
30
+ logger = get_logger("TiffViewer")
31
+
32
+ # Optimize pyqtgraph settings
33
+ pg.setConfigOptions(useOpenGL=True, antialias=False)
34
+
35
+ # Shared thread pool for background tasks
36
+ thread_pool = QThreadPool()
37
+ thread_pool.setMaxThreadCount(4)
38
+
39
+
40
+ class ROIWorkerSignals(QObject):
41
+ """
42
+ Signals for ROIWorker.
43
+ finished: emits (index, time_series)
44
+ progress: emits (index, percent_complete)
45
+ """
46
+ finished = pyqtSignal(int, np.ndarray)
47
+ progress = pyqtSignal(int, int)
48
+
49
+
50
+ class ROIWorker(QRunnable):
51
+ """
52
+ Worker that computes the mean intensity over time for a given ROI.
53
+ Automatically clips the ROI mask to image bounds.
54
+ """
55
+ def __init__(
56
+ self,
57
+ index: int,
58
+ filepath: str,
59
+ dtype_str: str,
60
+ shape: Tuple[int, int, int],
61
+ x0: int,
62
+ y0: int,
63
+ mask: np.ndarray,
64
+ chunk: int = 1000,
65
+ frame_start: int = 1,
66
+ frame_end: Optional[int] = None,
67
+ ):
68
+ super().__init__()
69
+ self.index = index
70
+ self.filepath = filepath
71
+ self.dtype_str = dtype_str
72
+ self.shape = shape # (frames, height, width)
73
+ self.x0 = x0
74
+ self.y0 = y0
75
+ self.mask = mask.astype(bool)
76
+ self.chunk = chunk
77
+ # Inclusive start, exclusive end, both 0-indexed.
78
+ self.frame_start = max(0, int(frame_start))
79
+ self.frame_end = int(frame_end) if frame_end is not None else int(shape[0])
80
+ self.frame_end = max(self.frame_start + 1, min(self.frame_end, int(shape[0])))
81
+ self.signals = ROIWorkerSignals()
82
+
83
+ def run(self) -> None:
84
+ # Memory-map the TIFF for fast access
85
+ mmap = np.memmap(
86
+ self.filepath,
87
+ mode='r',
88
+ dtype=np.dtype(self.dtype_str),
89
+ shape=self.shape
90
+ )
91
+ total_frames = self.frame_end - self.frame_start
92
+ img_h, img_w = self.shape[1], self.shape[2]
93
+
94
+ # Compute clipped ROI bounds
95
+ h_mask, w_mask = self.mask.shape
96
+ y1 = min(self.y0 + h_mask, img_h)
97
+ x1 = min(self.x0 + w_mask, img_w)
98
+ mask_clipped = self.mask[:(y1 - self.y0), :(x1 - self.x0)]
99
+ mask_sum = mask_clipped.sum()
100
+
101
+ # Prepare result array
102
+ result = np.empty(total_frames, dtype=float)
103
+
104
+ # Process in chunks to update progress
105
+ for start in range(self.frame_start, self.frame_end, self.chunk):
106
+ end = min(self.frame_end, start + self.chunk)
107
+ block = mmap[
108
+ start:end,
109
+ self.y0:y1,
110
+ self.x0:x1
111
+ ]
112
+ # Compute sums within clipped mask
113
+ sums = (block * mask_clipped).sum(axis=(1, 2))
114
+ idx0 = start - self.frame_start
115
+ length = end - start
116
+ result[idx0:idx0 + length] = sums / mask_sum
117
+
118
+ percent = int((idx0 + length) * 100 / total_frames)
119
+ self.signals.progress.emit(self.index, percent)
120
+
121
+ self.signals.finished.emit(self.index, result)
122
+
123
+ class AlignmentWorkerSignals(QObject):
124
+ """Signals for AlignmentWorker: result(lags, correlation_values)"""
125
+ result = pyqtSignal(np.ndarray, np.ndarray)
126
+
127
+
128
+ class AlignmentWorker(QRunnable):
129
+ """
130
+ Worker that computes normalized cross-correlation between two time series.
131
+ """
132
+ def __init__(self, series_list: List[np.ndarray]) -> None:
133
+ super().__init__()
134
+ self.series_list = series_list
135
+ self.signals = AlignmentWorkerSignals()
136
+
137
+ def run(self) -> None:
138
+ s0, s1 = self.series_list[0], self.series_list[1]
139
+ a = (s0 - s0.mean()) / s0.std()
140
+ b = (s1 - s1.mean()) / s1.std()
141
+ corr = np.correlate(a, b, mode='full')
142
+ lags = np.arange(-len(a) + 1, len(a))
143
+ self.signals.result.emit(lags, corr)
144
+
145
+
146
+ class EnhancedROIWorkerSignals(QObject):
147
+ """
148
+ Enhanced signals for ROIWorker with ΔF/F calculations.
149
+ finished: emits (index, raw_series, df_f_series)
150
+ progress: emits (index, percent_complete)
151
+ """
152
+ finished = pyqtSignal(int, np.ndarray, np.ndarray)
153
+ progress = pyqtSignal(int, int)
154
+
155
+
156
+ class EnhancedROIWorker(QRunnable):
157
+ """
158
+ Enhanced worker that computes ROI analysis with ΔF/F calculations.
159
+ """
160
+ def __init__(
161
+ self,
162
+ index: int,
163
+ filepath: str,
164
+ dtype_str: str,
165
+ shape: Tuple[int, int, int],
166
+ x0: int,
167
+ y0: int,
168
+ mask: np.ndarray,
169
+ baseline_frames: int = 100,
170
+ chunk: int = 1000,
171
+ frame_start: int = 1,
172
+ frame_end: Optional[int] = None,
173
+ ):
174
+ super().__init__()
175
+ self.index = index
176
+ self.filepath = filepath
177
+ self.dtype_str = dtype_str
178
+ self.shape = shape # (frames, height, width)
179
+ self.x0 = x0
180
+ self.y0 = y0
181
+ self.mask = mask.astype(bool)
182
+ self.baseline_frames = baseline_frames
183
+ self.chunk = chunk
184
+ self.frame_start = max(0, int(frame_start))
185
+ self.frame_end = int(frame_end) if frame_end is not None else int(shape[0])
186
+ self.frame_end = max(self.frame_start + 1, min(self.frame_end, int(shape[0])))
187
+ self.signals = EnhancedROIWorkerSignals()
188
+
189
+ def run(self) -> None:
190
+ # Memory-map the TIFF for fast access
191
+ mmap = np.memmap(
192
+ self.filepath,
193
+ mode='r',
194
+ dtype=np.dtype(self.dtype_str),
195
+ shape=self.shape
196
+ )
197
+ total_frames = self.frame_end - self.frame_start
198
+ img_h, img_w = self.shape[1], self.shape[2]
199
+
200
+ # Compute clipped ROI bounds
201
+ h_mask, w_mask = self.mask.shape
202
+ y1 = min(self.y0 + h_mask, img_h)
203
+ x1 = min(self.x0 + w_mask, img_w)
204
+ mask_clipped = self.mask[:(y1 - self.y0), :(x1 - self.x0)]
205
+ mask_sum = mask_clipped.sum()
206
+
207
+ if mask_sum == 0:
208
+ # Empty mask, return zeros
209
+ zeros = np.zeros(total_frames, dtype=float)
210
+ self.signals.finished.emit(self.index, zeros, zeros)
211
+ return
212
+
213
+ # Prepare result array
214
+ raw_series = np.empty(total_frames, dtype=float)
215
+
216
+ # Process in chunks to update progress
217
+ for start in range(self.frame_start, self.frame_end, self.chunk):
218
+ end = min(self.frame_end, start + self.chunk)
219
+
220
+ # Load data block
221
+ data_block = mmap[start:end, self.y0:y1, self.x0:x1].astype(np.float32)
222
+
223
+ # Compute mean for each frame
224
+ for i in range(len(data_block)):
225
+ frame = data_block[i]
226
+ frame_pixels = frame[mask_clipped]
227
+ raw_series[start - self.frame_start + i] = frame_pixels.mean()
228
+
229
+ percent = int((start - self.frame_start + len(data_block)) * 100 / total_frames)
230
+ self.signals.progress.emit(self.index, percent)
231
+
232
+ # Compute baseline (median of first N frames)
233
+ baseline_end = min(self.baseline_frames, len(raw_series))
234
+ baseline = np.median(raw_series[:baseline_end])
235
+
236
+ # Compute ΔF/F
237
+ df_f_series = (raw_series - baseline) / baseline
238
+
239
+ self.signals.finished.emit(self.index, raw_series, df_f_series)
240
+
241
+
242
+ class PixelTraceWorkerSignals(QObject):
243
+ finished = pyqtSignal(int, int, int, np.ndarray) # x, y, bin, series
244
+
245
+
246
+ class PixelTraceWorker(QRunnable):
247
+ """Compute the mean time-series over an NxN window around (x, y).
248
+
249
+ Runs off the GUI thread; uses a fresh np.memmap so it does not block
250
+ or contend with the viewer's display memmap.
251
+ """
252
+ def __init__(self, filepath: str, dtype_str: str, shape: Tuple[int, int, int],
253
+ x: int, y: int, bin_size: int,
254
+ frame_start: int = 0, frame_end: Optional[int] = None):
255
+ super().__init__()
256
+ self.filepath = filepath
257
+ self.dtype_str = dtype_str
258
+ self.shape = shape
259
+ self.x = int(x)
260
+ self.y = int(y)
261
+ self.bin_size = max(1, int(bin_size))
262
+ self.frame_start = max(0, int(frame_start))
263
+ self.frame_end = int(frame_end) if frame_end is not None else int(shape[0])
264
+ self.frame_end = max(self.frame_start + 1, min(self.frame_end, int(shape[0])))
265
+ self.signals = PixelTraceWorkerSignals()
266
+
267
+ def run(self) -> None:
268
+ mmap = np.memmap(self.filepath, mode='r',
269
+ dtype=np.dtype(self.dtype_str), shape=self.shape)
270
+ n_frames, h, w = self.shape
271
+ half = self.bin_size // 2
272
+ y0 = max(0, self.y - half)
273
+ x0 = max(0, self.x - half)
274
+ y1 = min(h, y0 + self.bin_size)
275
+ x1 = min(w, x0 + self.bin_size)
276
+ s, e = self.frame_start, self.frame_end
277
+ if self.bin_size == 1:
278
+ series = np.asarray(mmap[s:e, self.y, self.x], dtype=float)
279
+ else:
280
+ block = mmap[s:e, y0:y1, x0:x1]
281
+ series = block.mean(axis=(1, 2)).astype(float)
282
+ self.signals.finished.emit(self.x, self.y, self.bin_size, series)
283
+
284
+
285
+ class PixelTraceWindow(QWidget):
286
+ """Pop-up window showing a single pixel/bin time-series. Closable."""
287
+ closed = pyqtSignal(object) # emits self on close
288
+
289
+ def __init__(self, x: int, y: int, bin_size: int, series: np.ndarray,
290
+ color: str = 'y', parent: Optional[QWidget] = None):
291
+ super().__init__(parent)
292
+ self.setWindowTitle(f"Pixel ({x}, {y}) {bin_size}x{bin_size}")
293
+ self.setWindowFlag(Qt.WindowType.Window, True)
294
+ self.resize(640, 320)
295
+
296
+ layout = QVBoxLayout(self)
297
+ header = QHBoxLayout()
298
+ header.addWidget(QLabel(
299
+ f"<b>Pixel</b> ({x}, {y}) &nbsp; <b>Bin</b> {bin_size}x{bin_size} "
300
+ f"&nbsp; <b>Frames</b> {len(series)}"
301
+ ))
302
+ header.addStretch(1)
303
+ self.btn_close = QPushButton("Close")
304
+ self.btn_close.clicked.connect(self.close)
305
+ header.addWidget(self.btn_close)
306
+ layout.addLayout(header)
307
+
308
+ self.plot = pg.PlotWidget()
309
+ self.plot.plot(np.arange(len(series)), series, pen=color)
310
+ self.plot.setLabel('bottom', 'Frame')
311
+ self.plot.setLabel('left', 'Intensity')
312
+ layout.addWidget(self.plot)
313
+
314
+ def closeEvent(self, event):
315
+ self.closed.emit(self)
316
+ super().closeEvent(event)
317
+
318
+
319
+ class TiffViewer(QWidget):
320
+ """
321
+ Main application widget for interactive TIFF viewing and ROI analysis.
322
+ """
323
+ def __init__(self, initial_dir: Optional[str] = None, procedure=None):
324
+ super().__init__()
325
+ self.filepath: Optional[str] = None
326
+ self.mmap: Optional[np.memmap] = None
327
+ self.rois: List[Tuple[str, pg.ROI]] = []
328
+ self.results: dict = {}
329
+ self.df_f_results: dict = {}
330
+ self.colors = ['r', 'g', 'b', 'c', 'm']
331
+ # Optional reference to the running Procedure so we can refuse to open
332
+ # files that are part of an in-progress recording.
333
+ self._procedure = procedure
334
+ self._initial_dir: Optional[str] = initial_dir
335
+ # Pixel-trace state
336
+ self._pixel_windows: List[PixelTraceWindow] = []
337
+ self._pixel_marker = None # transient pg.RectROI showing last pick
338
+
339
+ self._setup_ui()
340
+ self._connect_signals()
341
+ self.setWindowTitle("TIFF ROI Viewer with ΔF/F Analysis")
342
+
343
+ def _setup_ui(self) -> None:
344
+ """Initialize and arrange all UI components."""
345
+ main_layout = QVBoxLayout(self)
346
+
347
+ # --- Control panel ---
348
+ ctrl_panel = QWidget()
349
+ ctrl_layout = QHBoxLayout(ctrl_panel)
350
+
351
+ # File controls
352
+ self.btn_open = QPushButton("Open TIFF…")
353
+ ctrl_layout.addWidget(self.btn_open)
354
+
355
+ # ROI controls
356
+ self.combo_shape = QComboBox()
357
+ self.combo_shape.addItems(["Rect", "Ellipse", "Polygon"])
358
+ self.btn_add_roi = QPushButton("Add ROI")
359
+ self.btn_clear_roi = QPushButton("Clear ROIs")
360
+ self.btn_save_roi = QPushButton("Save ROIs…")
361
+ self.btn_load_roi = QPushButton("Load ROIs…")
362
+
363
+ for widget in [self.combo_shape, self.btn_add_roi, self.btn_clear_roi,
364
+ self.btn_save_roi, self.btn_load_roi]:
365
+ ctrl_layout.addWidget(widget)
366
+
367
+ # Analysis controls
368
+ self.btn_compute = QPushButton("Compute ROIs")
369
+ self.btn_export_svg = QPushButton("Export SVG…")
370
+ self.btn_export_svg.setEnabled(False) # Disabled until traces are computed
371
+ self.chk_df_f = QCheckBox("ΔF/F Analysis")
372
+ self.chk_df_f.setChecked(True)
373
+ self.chk_corr = QCheckBox("Compute Correlation")
374
+ self.chk_corr.setChecked(True)
375
+ self.lbl_align = QLabel("")
376
+
377
+ # Pixel-trace controls
378
+ self.btn_pick_pixel = QPushButton("Pick Pixel")
379
+ self.btn_pick_pixel.setCheckable(True)
380
+ self.btn_pick_pixel.setToolTip(
381
+ "Click a pixel on the image to plot its time-series.\n"
382
+ "Use the bin selector to average an NxN window around the click."
383
+ )
384
+ self.combo_bin = QComboBox()
385
+ for n in (1, 2, 3, 5, 7, 9):
386
+ self.combo_bin.addItem(f"{n}x{n}", n)
387
+ self.combo_bin.setCurrentIndex(2) # default 3x3
388
+
389
+ for widget in [self.btn_compute, self.btn_export_svg, self.chk_df_f, self.chk_corr,
390
+ self.btn_pick_pixel, QLabel("Bin:"), self.combo_bin, self.lbl_align]:
391
+ ctrl_layout.addWidget(widget)
392
+
393
+ main_layout.addWidget(ctrl_panel)
394
+
395
+ # --- Analysis parameters ---
396
+ params_group = QGroupBox("Analysis Parameters")
397
+ params_layout = QGridLayout(params_group)
398
+
399
+ # Baseline frames
400
+ params_layout.addWidget(QLabel("Baseline Frames:"), 0, 0)
401
+ self.spin_baseline = QSpinBox()
402
+ self.spin_baseline.setRange(10, 1000)
403
+ self.spin_baseline.setValue(100)
404
+ params_layout.addWidget(self.spin_baseline, 0, 1)
405
+
406
+ # Frame range (1-indexed, inclusive on both ends) — applied to all
407
+ # ROI / ΔF/F / pixel-trace computations.
408
+ params_layout.addWidget(QLabel("First Frame:"), 0, 2)
409
+ self.spin_first_frame = QSpinBox()
410
+ self.spin_first_frame.setRange(1, 1)
411
+ self.spin_first_frame.setValue(1)
412
+ params_layout.addWidget(self.spin_first_frame, 0, 3)
413
+
414
+ params_layout.addWidget(QLabel("Last Frame:"), 0, 4)
415
+ self.spin_last_frame = QSpinBox()
416
+ self.spin_last_frame.setRange(1, 1)
417
+ self.spin_last_frame.setValue(1)
418
+ params_layout.addWidget(self.spin_last_frame, 0, 5)
419
+
420
+ main_layout.addWidget(params_group)
421
+
422
+ # --- Image display ---
423
+ self.img_view = pg.ImageView()
424
+ self.view_box = self.img_view.getView()
425
+ self.img_item = self.img_view.getImageItem()
426
+ main_layout.addWidget(self.img_view)
427
+
428
+ # --- Slider and progress bar ---
429
+ playback_layout = QHBoxLayout()
430
+ self.btn_play = QPushButton("Play")
431
+ self.btn_play.setCheckable(True)
432
+ self.btn_play.setEnabled(False)
433
+ self.spin_fps = QDoubleSpinBox()
434
+ self.spin_fps.setRange(0.1, 240.0)
435
+ self.spin_fps.setDecimals(1)
436
+ self.spin_fps.setValue(30.0)
437
+ self.spin_fps.setSuffix(" fps")
438
+ playback_layout.addWidget(self.btn_play)
439
+ playback_layout.addWidget(self.spin_fps)
440
+ self.slider = QSlider(Qt.Orientation.Horizontal)
441
+ self.slider.setEnabled(False)
442
+ playback_layout.addWidget(self.slider, 1)
443
+ main_layout.addLayout(playback_layout)
444
+
445
+ # Playback timer
446
+ self._play_timer = QTimer(self)
447
+ self._play_timer.timeout.connect(self._advance_frame)
448
+
449
+ self.progress = QProgressBar()
450
+ self.progress.setVisible(False)
451
+ main_layout.addWidget(self.progress)
452
+
453
+ # --- Time-series plot ---
454
+ self.plot_widget = pg.PlotWidget()
455
+ self.plot_widget.addLegend()
456
+ self.plot_widget.setVisible(False)
457
+ main_layout.addWidget(self.plot_widget)
458
+
459
+ # --- Correlation plot ---
460
+ self.corr_widget = pg.PlotWidget(title="Cross-Correlation")
461
+ self.corr_widget.setVisible(False)
462
+ main_layout.addWidget(self.corr_widget)
463
+
464
+ def _connect_signals(self) -> None:
465
+ """Wire UI events to their handlers."""
466
+ self.btn_open.clicked.connect(self.open_file)
467
+ self.slider.valueChanged.connect(self.display_frame)
468
+ self.btn_add_roi.clicked.connect(self.add_roi)
469
+ self.btn_clear_roi.clicked.connect(self.clear_rois)
470
+ self.btn_save_roi.clicked.connect(self.save_rois)
471
+ self.btn_load_roi.clicked.connect(self.load_rois)
472
+ self.btn_compute.clicked.connect(self.compute_rois)
473
+ self.btn_export_svg.clicked.connect(self.export_svg)
474
+ self.btn_play.toggled.connect(self._on_play_toggled)
475
+ self.spin_fps.valueChanged.connect(self._on_fps_changed)
476
+ self.btn_pick_pixel.toggled.connect(self._on_pick_pixel_toggled)
477
+ # Frame-range interlock so first <= last.
478
+ self.spin_first_frame.valueChanged.connect(self._on_first_frame_changed)
479
+ self.spin_last_frame.valueChanged.connect(self._on_last_frame_changed)
480
+ # Mouse click in the image scene -> pixel trace (when picking enabled).
481
+ # pyqtgraph's GraphicsScene exposes sigMouseClicked at runtime even
482
+ # though Qt's QGraphicsScene type stubs do not advertise it.
483
+ self.view_box.scene().sigMouseClicked.connect(self._on_image_clicked) # type: ignore[attr-defined]
484
+
485
+ def _is_recording_active(self) -> bool:
486
+ """Return True if any camera attached to the procedure is running."""
487
+ proc = self._procedure
488
+ if proc is None:
489
+ return False
490
+ try:
491
+ cams = proc.config.hardware.cameras
492
+ except Exception:
493
+ return False
494
+ for cam in cams or ():
495
+ if getattr(cam, "is_running", False):
496
+ return True
497
+ return False
498
+
499
+ def _recording_dir(self) -> Optional[str]:
500
+ """Return the active experiment's output directory, if any."""
501
+ proc = self._procedure
502
+ if proc is None:
503
+ return None
504
+ cfg = getattr(proc, "config", None)
505
+ if cfg is None:
506
+ return None
507
+ for attr in ("bids_dir", "save_dir", "data_dir"):
508
+ d = getattr(cfg, attr, None)
509
+ if d:
510
+ try:
511
+ return os.path.abspath(d)
512
+ except Exception:
513
+ return None
514
+ return None
515
+
516
+ def _is_inside_recording_dir(self, path: str) -> bool:
517
+ rec_dir = self._recording_dir()
518
+ if not rec_dir:
519
+ return False
520
+ try:
521
+ abs_path = os.path.abspath(path)
522
+ return os.path.normcase(abs_path).startswith(
523
+ os.path.normcase(rec_dir + os.sep)
524
+ )
525
+ except Exception:
526
+ return False
527
+
528
+ def open_file(self) -> None:
529
+ """Open a TIFF file and memory-map it for fast access."""
530
+ start_dir = self._initial_dir or ""
531
+ path, _ = QFileDialog.getOpenFileName(
532
+ self, "Select TIFF", start_dir, "*.tif *.tiff"
533
+ )
534
+ if not path:
535
+ return
536
+
537
+ # Refuse to open files that belong to an in-progress recording.
538
+ # The active experiment's writer may still hold the file open and
539
+ # mapping/reading it concurrently risks corruption or a crash.
540
+ if self._is_recording_active() and self._is_inside_recording_dir(path):
541
+ QMessageBox.warning(
542
+ self,
543
+ "Recording in progress",
544
+ "This file is inside the active experiment's output directory "
545
+ "while a recording is running.\n\n"
546
+ "Wait until acquisition finishes before opening it.",
547
+ )
548
+ return
549
+
550
+ # Clear stale ROIs/results and release the previous memmap before
551
+ # loading a new stack. ROIs reference the old image item and the
552
+ # previous numpy.memmap holds a file handle that must be released
553
+ # (especially on Windows) to avoid a crash on reopen.
554
+ self.clear_rois()
555
+ if self.mmap is not None:
556
+ try:
557
+ del self.mmap
558
+ except Exception:
559
+ pass
560
+ self.mmap = None
561
+
562
+ self.filepath = path
563
+ # Read-only memmap so we never collide with an experiment writer.
564
+ self.mmap = tifffile.memmap(path, mode='r')
565
+ self._initial_dir = os.path.dirname(path)
566
+ total_frames = self.mmap.shape[0]
567
+ # Reset so the first frame of the new stack auto-ranges/levels once.
568
+ self._first_shown = False
569
+ # Block slider signals while reconfiguring its range to avoid
570
+ # display_frame firing with a transient/out-of-range index.
571
+ self.slider.blockSignals(True)
572
+ self.slider.setRange(1, total_frames - 1)
573
+ self.slider.setValue(1)
574
+ self.slider.setEnabled(True)
575
+ self.slider.blockSignals(False)
576
+ self.btn_play.setEnabled(True)
577
+ # Configure frame-range spinboxes for the new stack.
578
+ # Default: skip the first frame (matches previous behavior) and
579
+ # include everything through the last.
580
+ self.spin_first_frame.blockSignals(True)
581
+ self.spin_last_frame.blockSignals(True)
582
+ self.spin_first_frame.setRange(1, total_frames)
583
+ self.spin_last_frame.setRange(1, total_frames)
584
+ self.spin_first_frame.setValue(min(2, total_frames))
585
+ self.spin_last_frame.setValue(total_frames)
586
+ self.spin_first_frame.blockSignals(False)
587
+ self.spin_last_frame.blockSignals(False)
588
+ self.display_frame(1)
589
+
590
+ def display_frame(self, index: int) -> None:
591
+ """Display a single frame from the TIFF stack."""
592
+ if self.mmap is None:
593
+ return
594
+ image = np.asarray(self.mmap[index])
595
+ first = (index == 1) and not getattr(self, "_first_shown", False)
596
+ # Preserve the user's current zoom/pan and contrast on subsequent
597
+ # frames; only auto-range/level on the very first frame of a stack.
598
+ self.img_view.setImage(
599
+ image,
600
+ autoLevels=first,
601
+ autoRange=first,
602
+ autoHistogramRange=first,
603
+ )
604
+ if first:
605
+ self._first_shown = True
606
+
607
+ def _on_play_toggled(self, playing: bool) -> None:
608
+ if playing and self.mmap is not None:
609
+ self._play_timer.start(max(1, int(1000.0 / self.spin_fps.value())))
610
+ self.btn_play.setText("Pause")
611
+ else:
612
+ self._play_timer.stop()
613
+ self.btn_play.setText("Play")
614
+
615
+ def _on_fps_changed(self, fps: float) -> None:
616
+ if self._play_timer.isActive():
617
+ self._play_timer.start(max(1, int(1000.0 / fps)))
618
+
619
+ def _advance_frame(self) -> None:
620
+ if self.mmap is None:
621
+ return
622
+ nxt = self.slider.value() + 1
623
+ if nxt > self.slider.maximum():
624
+ nxt = self.slider.minimum()
625
+ self.slider.setValue(nxt)
626
+
627
+ # ---- Pixel-trace picking ----------------------------------------------
628
+ def _on_pick_pixel_toggled(self, on: bool) -> None:
629
+ """Switch the image cursor to indicate pick mode."""
630
+ if on:
631
+ self.img_view.setCursor(Qt.CursorShape.CrossCursor)
632
+ else:
633
+ self.img_view.unsetCursor()
634
+
635
+ def _on_first_frame_changed(self, val: int) -> None:
636
+ if val > self.spin_last_frame.value():
637
+ self.spin_last_frame.setValue(val)
638
+
639
+ def _on_last_frame_changed(self, val: int) -> None:
640
+ if val < self.spin_first_frame.value():
641
+ self.spin_first_frame.setValue(val)
642
+
643
+ def _frame_range(self) -> Tuple[int, int]:
644
+ """Return (frame_start, frame_end) as 0-indexed half-open range."""
645
+ s = max(0, self.spin_first_frame.value() - 1)
646
+ e = self.spin_last_frame.value() # 1-indexed inclusive == 0-indexed exclusive
647
+ if self.mmap is not None:
648
+ n = int(self.mmap.shape[0])
649
+ e = min(e, n)
650
+ s = min(s, max(0, e - 1))
651
+ return s, e
652
+
653
+ def _on_image_clicked(self, ev) -> None:
654
+ """Handle a click on the image while pixel-pick mode is active."""
655
+ if not self.btn_pick_pixel.isChecked():
656
+ return
657
+ if self.mmap is None or self.filepath is None:
658
+ return
659
+ if ev.button() != Qt.MouseButton.LeftButton:
660
+ return
661
+ # Map scene click -> image (pixel) coords
662
+ try:
663
+ scene_pos = ev.scenePos()
664
+ img_pt = self.img_item.mapFromScene(scene_pos)
665
+ except Exception:
666
+ return
667
+ x = int(img_pt.x())
668
+ y = int(img_pt.y())
669
+ h, w = self.mmap.shape[1], self.mmap.shape[2]
670
+ if not (0 <= x < w and 0 <= y < h):
671
+ return
672
+ ev.accept()
673
+
674
+ bin_size = int(self.combo_bin.currentData() or 1)
675
+
676
+ # Draw a marker showing the picked region on the image.
677
+ if self._pixel_marker is not None:
678
+ try:
679
+ self.view_box.removeItem(self._pixel_marker)
680
+ except Exception:
681
+ pass
682
+ self._pixel_marker = None
683
+ half = bin_size // 2
684
+ rx = max(0, x - half)
685
+ ry = max(0, y - half)
686
+ marker = pg.RectROI([rx, ry], [bin_size, bin_size],
687
+ pen=pg.mkPen('y', width=1), movable=False, resizable=False)
688
+ # Disable the default handle so it's just a visual marker.
689
+ for handle in list(marker.getHandles()):
690
+ marker.removeHandle(handle)
691
+ self.view_box.addItem(marker)
692
+ self._pixel_marker = marker
693
+
694
+ # Compute trace off the GUI thread.
695
+ fs, fe = self._frame_range()
696
+ worker = PixelTraceWorker(
697
+ self.filepath, self.mmap.dtype.str, self.mmap.shape,
698
+ x, y, bin_size,
699
+ frame_start=fs, frame_end=fe,
700
+ )
701
+ worker.signals.finished.connect(self._on_pixel_trace_ready)
702
+ thread_pool.start(worker)
703
+
704
+ def _on_pixel_trace_ready(self, x: int, y: int, bin_size: int,
705
+ series: np.ndarray) -> None:
706
+ """Show the pixel trace in a closable popup window."""
707
+ color = self.colors[len(self._pixel_windows) % len(self.colors)]
708
+ win = PixelTraceWindow(x, y, bin_size, series, color=color, parent=self)
709
+ win.closed.connect(self._on_pixel_window_closed)
710
+ win.show()
711
+ self._pixel_windows.append(win)
712
+
713
+ def _on_pixel_window_closed(self, win) -> None:
714
+ if win in self._pixel_windows:
715
+ self._pixel_windows.remove(win)
716
+ # -----------------------------------------------------------------------
717
+
718
+ def add_roi(self) -> None:
719
+ """Add a new ROI of the selected shape to the image view."""
720
+ shape_type = self.combo_shape.currentText()
721
+ idx = len(self.rois)
722
+ color = self.colors[idx % len(self.colors)]
723
+
724
+ if shape_type == "Rect":
725
+ roi = pg.RectROI([20, 20], [100, 100], pen=color)
726
+ elif shape_type == "Ellipse":
727
+ roi = pg.EllipseROI([20, 20], [100, 100], pen=color)
728
+ else:
729
+ pts = [[20, 20], [120, 20], [120, 120], [20, 120]]
730
+ roi = pg.PolyLineROI(pts, closed=True, pen=color)
731
+
732
+ self.view_box.addItem(roi)
733
+ self.rois.append((shape_type, roi))
734
+
735
+ def clear_rois(self) -> None:
736
+ """Remove all ROIs and reset plots/labels."""
737
+ for _, roi in self.rois:
738
+ self.view_box.removeItem(roi)
739
+ self.rois.clear()
740
+ # Also remove the transient pixel-pick marker, if any.
741
+ if self._pixel_marker is not None:
742
+ try:
743
+ self.view_box.removeItem(self._pixel_marker)
744
+ except Exception:
745
+ pass
746
+ self._pixel_marker = None
747
+ self.plot_widget.clear()
748
+ self.plot_widget.setVisible(False)
749
+ self.corr_widget.clear()
750
+ self.corr_widget.setVisible(False)
751
+ self.results.clear()
752
+ self.df_f_results.clear()
753
+ self.lbl_align.clear()
754
+ self.progress.setVisible(False)
755
+ self.btn_export_svg.setEnabled(False)
756
+
757
+ def save_rois(self) -> None:
758
+ """Export ROI definitions to a JSON file."""
759
+ if not self.rois:
760
+ return
761
+ path, _ = QFileDialog.getSaveFileName(
762
+ self, "Save ROIs", "rois.json", "JSON Files (*.json)"
763
+ )
764
+ if not path:
765
+ return
766
+
767
+ export_data = []
768
+ for shape_type, roi in self.rois:
769
+ info: dict = {'type': shape_type}
770
+ if shape_type in ("Rect", "Ellipse"):
771
+ pos = roi.pos()
772
+ size = roi.size()
773
+ info['pos'] = [float(pos.x()), float(pos.y())]
774
+ info['size'] = [float(size.x()), float(size.y())]
775
+ else:
776
+ info['points'] = roi.getState()['points']
777
+ export_data.append(info)
778
+
779
+ with open(path, 'w') as f:
780
+ json.dump(export_data, f, indent=2)
781
+
782
+ logger.info(f"Saved {len(export_data)} ROIs to {path}")
783
+
784
+ def load_rois(self) -> None:
785
+ """Import ROI definitions from a JSON file and render them."""
786
+ if self.mmap is None:
787
+ return
788
+ path, _ = QFileDialog.getOpenFileName(
789
+ self, "Load ROIs", "", "JSON Files (*.json)"
790
+ )
791
+ if not path:
792
+ return
793
+ self.clear_rois()
794
+
795
+ with open(path) as f:
796
+ roi_list = json.load(f)
797
+
798
+ for entry in roi_list:
799
+ shape_type = entry['type']
800
+ idx = len(self.rois)
801
+ color = self.colors[idx % len(self.colors)]
802
+
803
+ if shape_type in ("Rect", "Ellipse"):
804
+ x, y = entry['pos']
805
+ w, h = entry['size']
806
+ if shape_type == "Rect":
807
+ roi = pg.RectROI([x, y], [w, h], pen=color)
808
+ else:
809
+ roi = pg.EllipseROI([x, y], [w, h], pen=color)
810
+ else:
811
+ pts = entry['points']
812
+ roi = pg.PolyLineROI(pts, closed=True, pen=color)
813
+
814
+ self.view_box.addItem(roi)
815
+ self.rois.append((shape_type, roi))
816
+
817
+ logger.info(f"Loaded {len(self.rois)} ROIs from {path}")
818
+
819
+ def export_svg(self) -> None:
820
+ """Export individual ROI traces as minimalistic SVG plots for use in Illustrator."""
821
+ if not self.results and not self.df_f_results:
822
+ logger.warning("No ROI traces to export. Compute ROIs first.")
823
+ return
824
+
825
+ # Let user choose a directory instead of a single file
826
+ directory = QFileDialog.getExistingDirectory(
827
+ self, "Select Directory for SVG Export"
828
+ )
829
+ if not directory:
830
+ return
831
+
832
+ try:
833
+ import matplotlib.pyplot as plt
834
+ import matplotlib
835
+ import os
836
+
837
+ # Set up matplotlib for clean, minimalistic SVG export
838
+ matplotlib.rcParams['svg.fonttype'] = 'none' # Keep fonts as text for editing
839
+ matplotlib.rcParams['font.family'] = 'Arial'
840
+ matplotlib.rcParams['font.size'] = 10
841
+ matplotlib.rcParams['axes.linewidth'] = 1.0
842
+ matplotlib.rcParams['lines.linewidth'] = 1.5
843
+ matplotlib.rcParams['axes.spines.top'] = False
844
+ matplotlib.rcParams['axes.spines.right'] = False
845
+ matplotlib.rcParams['xtick.direction'] = 'out'
846
+ matplotlib.rcParams['ytick.direction'] = 'out'
847
+
848
+ # Determine which data to plot
849
+ use_df_f = self.chk_df_f.isChecked() and self.df_f_results
850
+ data_dict = self.df_f_results if use_df_f else self.results
851
+ y_label = "ΔF/F" if use_df_f else "Intensity"
852
+
853
+ # Color mapping to match pyqtgraph colors
854
+ color_map = {
855
+ 'r': '#E74C3C', # Softer red
856
+ 'g': '#27AE60', # Softer green
857
+ 'b': '#3498DB', # Softer blue
858
+ 'c': '#17A2B8', # Softer cyan
859
+ 'm': '#8E44AD' # Softer magenta
860
+ }
861
+
862
+ exported_files = []
863
+
864
+ # Create individual plots for each ROI
865
+ for idx in sorted(data_dict.keys()):
866
+ series = data_dict[idx]
867
+ x_data = np.arange(len(series))
868
+ color_key = self.colors[idx % len(self.colors)]
869
+ color_hex = color_map.get(color_key, '#2C3E50')
870
+
871
+ # Create minimalistic figure
872
+ fig, ax = plt.subplots(figsize=(4, 2.5), dpi=300)
873
+
874
+ # Plot the trace with clean styling
875
+ ax.plot(x_data, series,
876
+ color=color_hex,
877
+ linewidth=1.5,
878
+ alpha=0.8)
879
+
880
+ # Minimalistic styling
881
+ ax.set_xlabel("Frame", fontsize=10, color='#2C3E50')
882
+ ax.set_ylabel(y_label, fontsize=10, color='#2C3E50')
883
+
884
+ # Remove top and right spines (already set in rcParams but ensuring)
885
+ ax.spines['top'].set_visible(False)
886
+ ax.spines['right'].set_visible(False)
887
+ ax.spines['left'].set_color('#7F8C8D')
888
+ ax.spines['bottom'].set_color('#7F8C8D')
889
+ ax.spines['left'].set_linewidth(0.8)
890
+ ax.spines['bottom'].set_linewidth(0.8)
891
+
892
+ # Clean tick styling
893
+ ax.tick_params(colors='#7F8C8D', labelsize=9, width=0.8, length=4)
894
+ ax.tick_params(axis='both', which='minor', length=2, width=0.5)
895
+
896
+ # Minimal grid (very subtle)
897
+ ax.grid(True, alpha=0.15, linestyle='-', linewidth=0.3, color='#BDC3C7')
898
+
899
+ # Tight layout with minimal margins
900
+ plt.tight_layout(pad=0.3)
901
+
902
+ # Save individual SVG
903
+ filename = f"ROI_{idx+1:02d}_trace.svg"
904
+ filepath = os.path.join(directory, filename)
905
+ plt.savefig(filepath, format='svg', bbox_inches='tight',
906
+ facecolor='white', edgecolor='none',
907
+ pad_inches=0.05)
908
+ plt.close()
909
+
910
+ exported_files.append(filename)
911
+
912
+ logger.info(f"Exported {len(exported_files)} individual ROI traces to {directory}")
913
+ logger.info(f"Files: {', '.join(exported_files)}")
914
+
915
+ except ImportError:
916
+ logger.error("matplotlib is required for SVG export. Please install: pip install matplotlib")
917
+ except Exception as e:
918
+ logger.error(f"Failed to export SVG: {e}")
919
+
920
+ def compute_rois(self) -> None:
921
+ """Run ROI mean-series calculations with ΔF/F and optional correlation."""
922
+ if self.mmap is None or not self.rois or self.filepath is None:
923
+ return
924
+
925
+ self.progress.setVisible(True)
926
+ self.progress.setRange(0, 100)
927
+ self.progress.setValue(0)
928
+
929
+ self.plot_widget.clear()
930
+ self.plot_widget.setVisible(True)
931
+ self.results.clear()
932
+ self.df_f_results.clear()
933
+
934
+ baseline_frames = self.spin_baseline.value()
935
+ fs, fe = self._frame_range()
936
+
937
+ for idx, (_, roi) in enumerate(self.rois):
938
+ h, w = self.mmap.shape[1], self.mmap.shape[2]
939
+ ones = np.ones((h, w), dtype=np.uint8)
940
+ mask_array = roi.getArrayRegion(
941
+ ones, self.img_view.getImageItem(), returnMappedCoords=False
942
+ )
943
+ # Convert to boolean mask
944
+ if isinstance(mask_array, tuple):
945
+ mask = np.asarray(mask_array[0]).astype(bool)
946
+ else:
947
+ mask = np.asarray(mask_array).astype(bool)
948
+
949
+ x0 = int(roi.pos().x())
950
+ y0 = int(roi.pos().y())
951
+
952
+ if self.chk_df_f.isChecked():
953
+ worker = EnhancedROIWorker(
954
+ idx, self.filepath, self.mmap.dtype.str,
955
+ self.mmap.shape, x0, y0, mask,
956
+ baseline_frames=baseline_frames,
957
+ frame_start=fs, frame_end=fe,
958
+ )
959
+ worker.signals.progress.connect(
960
+ lambda _, pct: self.progress.setValue(pct)
961
+ )
962
+ worker.signals.finished.connect(self._on_enhanced_roi_finished)
963
+ else:
964
+ worker = ROIWorker(
965
+ idx, self.filepath, self.mmap.dtype.str,
966
+ self.mmap.shape, x0, y0, mask,
967
+ frame_start=fs, frame_end=fe,
968
+ )
969
+ worker.signals.progress.connect(
970
+ lambda _, pct: self.progress.setValue(pct)
971
+ )
972
+ worker.signals.finished.connect(self._on_roi_finished)
973
+
974
+ thread_pool.start(worker)
975
+
976
+ def _on_enhanced_roi_finished(self, idx: int, raw_series: np.ndarray,
977
+ df_f_series: np.ndarray) -> None:
978
+ """Handle completion of an enhanced ROIWorker."""
979
+ color = self.colors[idx % len(self.colors)]
980
+
981
+ # Plot ΔF/F line
982
+ x_data = np.arange(len(df_f_series))
983
+
984
+ # Plot mean line
985
+ self.plot_widget.plot(
986
+ x_data, df_f_series, pen=color,
987
+ name=f"ROI {idx+1} ΔF/F"
988
+ )
989
+
990
+ self.results[idx] = raw_series
991
+ self.df_f_results[idx] = df_f_series
992
+
993
+ all_done = len(self.results) == len(self.rois)
994
+ if all_done:
995
+ self.btn_export_svg.setEnabled(True)
996
+
997
+ if all_done and self.chk_corr.isChecked() and len(self.rois) > 1:
998
+ self.progress.setRange(0, 0)
999
+ # Use ΔF/F data for correlation if available
1000
+ data_for_corr = [self.df_f_results.get(0, self.results[0]),
1001
+ self.df_f_results.get(1, self.results[1])]
1002
+ align_worker = AlignmentWorker(data_for_corr)
1003
+ align_worker.signals.result.connect(self._on_aligned)
1004
+ thread_pool.start(align_worker)
1005
+ elif all_done:
1006
+ self.progress.setVisible(False)
1007
+
1008
+ def _on_roi_finished(self, idx: int, series: np.ndarray) -> None:
1009
+ """Handle completion of a basic ROIWorker."""
1010
+ self.plot_widget.plot(
1011
+ series, pen=self.colors[idx % len(self.colors)],
1012
+ name=f"ROI {idx+1}"
1013
+ )
1014
+ self.results[idx] = series
1015
+
1016
+ all_done = len(self.results) == len(self.rois)
1017
+ if all_done:
1018
+ self.btn_export_svg.setEnabled(True)
1019
+
1020
+ if all_done and self.chk_corr.isChecked() and len(self.rois) > 1:
1021
+ self.progress.setRange(0, 0)
1022
+ align_worker = AlignmentWorker(
1023
+ [self.results[0], self.results[1]]
1024
+ )
1025
+ align_worker.signals.result.connect(self._on_aligned)
1026
+ thread_pool.start(align_worker)
1027
+ elif all_done:
1028
+ self.progress.setVisible(False)
1029
+
1030
+ def _on_aligned(self, lags: np.ndarray, corr: np.ndarray) -> None:
1031
+ """Plot correlation results and display peak lag."""
1032
+ self.corr_widget.clear()
1033
+ self.corr_widget.plot(lags, corr, pen='y')
1034
+ peak = lags[corr.argmax()]
1035
+ self.lbl_align.setText(f"Lag = {peak} frames")
1036
+ self.progress.setRange(0, 100)
1037
+ self.progress.setValue(100)
1038
+
1039
+
1040
+ def main() -> None:
1041
+ from mesofield.gui import theme
1042
+ app = QApplication([])
1043
+ theme.apply_theme(app)
1044
+ viewer = TiffViewer()
1045
+ viewer.show()
1046
+ app.exec()
1047
+
1048
+
1049
+ if __name__ == "__main__":
1050
+ main()