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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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}) <b>Bin</b> {bin_size}x{bin_size} "
|
|
300
|
+
f" <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()
|