TwoPy 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.
- twopy/__init__.py +168 -0
- twopy/_segments.py +53 -0
- twopy/analysis/__init__.py +101 -0
- twopy/analysis/background_subtraction.py +672 -0
- twopy/analysis/dff.py +524 -0
- twopy/analysis/epoch_mapping.py +223 -0
- twopy/analysis/motion.py +66 -0
- twopy/analysis/persistence.py +975 -0
- twopy/analysis/response_processing/__init__.py +55 -0
- twopy/analysis/response_processing/apply.py +298 -0
- twopy/analysis/response_processing/options.py +192 -0
- twopy/analysis/response_processing/qc.py +270 -0
- twopy/analysis/response_processing/signals.py +200 -0
- twopy/analysis/responses.py +345 -0
- twopy/analysis/trials.py +341 -0
- twopy/analysis/workflow.py +457 -0
- twopy/api.py +78 -0
- twopy/config.py +200 -0
- twopy/conversion/__init__.py +40 -0
- twopy/conversion/alignment_crop.py +329 -0
- twopy/conversion/api.py +113 -0
- twopy/conversion/frame_ranges.py +35 -0
- twopy/conversion/hdf5_writing.py +362 -0
- twopy/conversion/matlab_values.py +123 -0
- twopy/conversion/source_loading.py +403 -0
- twopy/conversion/stimulus_code.py +248 -0
- twopy/conversion/types.py +295 -0
- twopy/converted.py +681 -0
- twopy/database/__init__.py +33 -0
- twopy/database/cache.py +166 -0
- twopy/database/catalog.py +90 -0
- twopy/database/connection.py +27 -0
- twopy/database/generic.py +254 -0
- twopy/database/modeled.py +419 -0
- twopy/database/sql.py +46 -0
- twopy/database/types.py +116 -0
- twopy/inspection.py +492 -0
- twopy/matlab.py +398 -0
- twopy/napari/__init__.py +27 -0
- twopy/napari/__main__.py +10 -0
- twopy/napari/constants.py +13 -0
- twopy/napari/controls.py +739 -0
- twopy/napari/display.py +181 -0
- twopy/napari/display_paths.py +197 -0
- twopy/napari/interactive.py +310 -0
- twopy/napari/launcher.py +175 -0
- twopy/napari/layout.py +147 -0
- twopy/napari/loading.py +207 -0
- twopy/napari/movie.py +71 -0
- twopy/napari/paths.py +181 -0
- twopy/napari/plotting/__init__.py +21 -0
- twopy/napari/plotting/data.py +266 -0
- twopy/napari/plotting/docks.py +1052 -0
- twopy/napari/plotting/export.py +803 -0
- twopy/napari/plotting/export_controls.py +209 -0
- twopy/napari/plotting/label_visibility.py +260 -0
- twopy/napari/plotting/options.py +269 -0
- twopy/napari/plotting/panels.py +119 -0
- twopy/napari/plotting/widgets.py +767 -0
- twopy/napari/protocols.py +90 -0
- twopy/napari/responses.py +91 -0
- twopy/napari/roi.py +238 -0
- twopy/napari/session.py +381 -0
- twopy/napari/state.py +106 -0
- twopy/napari/text.py +26 -0
- twopy/napari/types.py +39 -0
- twopy/napari/viewer.py +405 -0
- twopy/parity/__init__.py +30 -0
- twopy/parity/dff.py +399 -0
- twopy/parity/roi.py +145 -0
- twopy/parity/saved_analysis.py +174 -0
- twopy/photodiode.py +375 -0
- twopy/photodiode_classification.py +484 -0
- twopy/roi.py +462 -0
- twopy/session.py +167 -0
- twopy/spatial.py +154 -0
- twopy/stimulus.py +233 -0
- twopy/synchronization.py +235 -0
- twopy/typing_guards.py +255 -0
- twopy-0.1.0.dist-info/METADATA +311 -0
- twopy-0.1.0.dist-info/RECORD +84 -0
- twopy-0.1.0.dist-info/WHEEL +5 -0
- twopy-0.1.0.dist-info/entry_points.txt +2 -0
- twopy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Group ROI responses by stimulus epoch and trial.
|
|
2
|
+
|
|
3
|
+
Inputs: computed ROI dF/F traces and stimulus-labeled frame windows.
|
|
4
|
+
Outputs: per-trial response arrays and compact per-ROI summary rows.
|
|
5
|
+
|
|
6
|
+
This module keeps response grouping independent from plotting, napari, and file
|
|
7
|
+
storage. It only works with converted twopy analysis objects that already have
|
|
8
|
+
explicit frame coordinates.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import numpy.typing as npt
|
|
16
|
+
|
|
17
|
+
from twopy.analysis.dff import RoiDeltaFOverF
|
|
18
|
+
from twopy.analysis.trials import EpochFrameWindow
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"GroupedRoiResponseSummary",
|
|
22
|
+
"GroupedRoiResponses",
|
|
23
|
+
"RoiResponseSummary",
|
|
24
|
+
"RoiResponseTrial",
|
|
25
|
+
"group_delta_f_over_f_by_epoch",
|
|
26
|
+
"is_gray_epoch_name",
|
|
27
|
+
"summarize_epoch_roi_responses",
|
|
28
|
+
"summarize_grouped_responses",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RoiResponseTrial:
|
|
34
|
+
"""One ROI response matrix for one stimulus trial.
|
|
35
|
+
|
|
36
|
+
Inputs: one stimulus-labeled frame window and a slice of ROI dF/F.
|
|
37
|
+
Outputs: frame-by-ROI values plus absolute frame numbers and relative time.
|
|
38
|
+
|
|
39
|
+
``trial_index`` is one-based within each epoch because that is the number a
|
|
40
|
+
scientist naturally uses when checking repeated presentations.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
epoch_number: int
|
|
44
|
+
epoch_name: str
|
|
45
|
+
trial_index: int
|
|
46
|
+
window_index: int
|
|
47
|
+
start_frame: int
|
|
48
|
+
stop_frame: int
|
|
49
|
+
frame_numbers: npt.NDArray[np.int64]
|
|
50
|
+
time_seconds: npt.NDArray[np.float64]
|
|
51
|
+
values: npt.NDArray[np.float64]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class GroupedRoiResponses:
|
|
56
|
+
"""ROI responses grouped by epoch and trial.
|
|
57
|
+
|
|
58
|
+
Inputs: one dF/F result plus epoch windows.
|
|
59
|
+
Outputs: trial response objects, ROI labels, and the frame rate used for
|
|
60
|
+
relative trial time.
|
|
61
|
+
|
|
62
|
+
The values stay as one matrix per trial instead of being padded into a
|
|
63
|
+
larger cube. This avoids fake samples when trial lengths differ.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
roi_labels: tuple[str, ...]
|
|
67
|
+
data_rate_hz: float
|
|
68
|
+
trials: tuple[RoiResponseTrial, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class RoiResponseSummary:
|
|
73
|
+
"""Compact response metrics for one ROI in one trial.
|
|
74
|
+
|
|
75
|
+
Inputs: one trial response matrix.
|
|
76
|
+
Outputs: mean, peak, minimum, and frame metadata for one ROI.
|
|
77
|
+
|
|
78
|
+
These rows are intentionally simple so they can be written to CSV and
|
|
79
|
+
inspected without custom Python code.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
epoch_number: int
|
|
83
|
+
epoch_name: str
|
|
84
|
+
trial_index: int
|
|
85
|
+
window_index: int
|
|
86
|
+
roi_label: str
|
|
87
|
+
start_frame: int
|
|
88
|
+
stop_frame: int
|
|
89
|
+
frame_count: int
|
|
90
|
+
mean_response: float
|
|
91
|
+
peak_response: float
|
|
92
|
+
min_response: float
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class GroupedRoiResponseSummary:
|
|
97
|
+
"""Response metrics collapsed across trials for one epoch and ROI.
|
|
98
|
+
|
|
99
|
+
Inputs: trial-level response summaries for one ``(epoch, ROI)`` pair.
|
|
100
|
+
Outputs: mean response, SEM, and the trial count behind those values.
|
|
101
|
+
|
|
102
|
+
A single usable trial gets SEM ``0`` because there is no across-trial spread
|
|
103
|
+
to estimate. Empty or all-NaN groups are not emitted.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
epoch_number: int
|
|
107
|
+
epoch_name: str
|
|
108
|
+
roi_label: str
|
|
109
|
+
mean_response: float
|
|
110
|
+
sem_response: float
|
|
111
|
+
n_trials: int
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def group_delta_f_over_f_by_epoch(
|
|
115
|
+
dff: RoiDeltaFOverF,
|
|
116
|
+
epoch_windows: Sequence[EpochFrameWindow],
|
|
117
|
+
*,
|
|
118
|
+
data_rate_hz: float,
|
|
119
|
+
pre_window_seconds: float = 0.0,
|
|
120
|
+
post_window_seconds: float = 0.0,
|
|
121
|
+
) -> GroupedRoiResponses:
|
|
122
|
+
"""Group dF/F values by stimulus epoch windows.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
dff: Computed ROI dF/F with absolute frame coordinates.
|
|
126
|
+
epoch_windows: Stimulus-labeled windows to slice.
|
|
127
|
+
data_rate_hz: Imaging frame rate in hertz for relative trial time.
|
|
128
|
+
pre_window_seconds: Seconds before each epoch window to include. This
|
|
129
|
+
is useful for plots that show the gray interleave baseline before
|
|
130
|
+
stimulus onset.
|
|
131
|
+
post_window_seconds: Seconds after each epoch window to include. This
|
|
132
|
+
is useful for plots that show the response returning into the next
|
|
133
|
+
gray interleave.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
``GroupedRoiResponses`` with one response object per input window.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: If the dF/F shape, frame rate, or windows are invalid.
|
|
140
|
+
|
|
141
|
+
The function only groups existing dF/F values. It does not decide which
|
|
142
|
+
windows are trials and does not recompute fluorescence.
|
|
143
|
+
"""
|
|
144
|
+
_validate_grouping_inputs(
|
|
145
|
+
dff,
|
|
146
|
+
epoch_windows,
|
|
147
|
+
data_rate_hz,
|
|
148
|
+
pre_window_seconds,
|
|
149
|
+
post_window_seconds,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
trials: list[RoiResponseTrial] = []
|
|
153
|
+
epoch_counts: dict[tuple[int, str], int] = {}
|
|
154
|
+
pre_window_frames = int(round(pre_window_seconds * data_rate_hz))
|
|
155
|
+
post_window_frames = int(round(post_window_seconds * data_rate_hz))
|
|
156
|
+
for epoch_window in epoch_windows:
|
|
157
|
+
key = (epoch_window.epoch_number, epoch_window.epoch_name)
|
|
158
|
+
epoch_counts[key] = epoch_counts.get(key, 0) + 1
|
|
159
|
+
window = epoch_window.window
|
|
160
|
+
response_start_frame = max(
|
|
161
|
+
window.start_frame - pre_window_frames, dff.start_frame
|
|
162
|
+
)
|
|
163
|
+
response_stop_frame = min(
|
|
164
|
+
window.stop_frame + post_window_frames,
|
|
165
|
+
dff.stop_frame,
|
|
166
|
+
)
|
|
167
|
+
local_start = response_start_frame - dff.start_frame
|
|
168
|
+
local_stop = response_stop_frame - dff.start_frame
|
|
169
|
+
frame_numbers = np.arange(
|
|
170
|
+
response_start_frame, response_stop_frame, dtype=np.int64
|
|
171
|
+
)
|
|
172
|
+
time_seconds = (frame_numbers - window.start_frame) / data_rate_hz
|
|
173
|
+
trials.append(
|
|
174
|
+
RoiResponseTrial(
|
|
175
|
+
epoch_number=epoch_window.epoch_number,
|
|
176
|
+
epoch_name=epoch_window.epoch_name,
|
|
177
|
+
trial_index=epoch_counts[key],
|
|
178
|
+
window_index=window.index,
|
|
179
|
+
start_frame=response_start_frame,
|
|
180
|
+
stop_frame=response_stop_frame,
|
|
181
|
+
frame_numbers=frame_numbers,
|
|
182
|
+
time_seconds=time_seconds.astype(np.float64, copy=False),
|
|
183
|
+
values=dff.values[local_start:local_stop, :],
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return GroupedRoiResponses(
|
|
188
|
+
roi_labels=dff.labels,
|
|
189
|
+
data_rate_hz=float(data_rate_hz),
|
|
190
|
+
trials=tuple(trials),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def summarize_grouped_responses(
|
|
195
|
+
grouped: GroupedRoiResponses,
|
|
196
|
+
) -> tuple[RoiResponseSummary, ...]:
|
|
197
|
+
"""Summarize grouped responses with one row per trial and ROI.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
grouped: Grouped response object.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Tuple of compact response summary rows.
|
|
204
|
+
|
|
205
|
+
The summary uses NaN-aware metrics so motion-masked dF/F frames do not make
|
|
206
|
+
an entire trial unusable.
|
|
207
|
+
"""
|
|
208
|
+
summaries: list[RoiResponseSummary] = []
|
|
209
|
+
for trial in grouped.trials:
|
|
210
|
+
if trial.values.shape[1] != len(grouped.roi_labels):
|
|
211
|
+
msg = (
|
|
212
|
+
"Trial response width does not match ROI labels: "
|
|
213
|
+
f"{trial.values.shape[1]} values, {len(grouped.roi_labels)} labels"
|
|
214
|
+
)
|
|
215
|
+
raise ValueError(msg)
|
|
216
|
+
for roi_index, roi_label in enumerate(grouped.roi_labels):
|
|
217
|
+
values = trial.values[:, roi_index]
|
|
218
|
+
summaries.append(
|
|
219
|
+
RoiResponseSummary(
|
|
220
|
+
epoch_number=trial.epoch_number,
|
|
221
|
+
epoch_name=trial.epoch_name,
|
|
222
|
+
trial_index=trial.trial_index,
|
|
223
|
+
window_index=trial.window_index,
|
|
224
|
+
roi_label=roi_label,
|
|
225
|
+
start_frame=trial.start_frame,
|
|
226
|
+
stop_frame=trial.stop_frame,
|
|
227
|
+
frame_count=trial.stop_frame - trial.start_frame,
|
|
228
|
+
mean_response=float(np.nanmean(values)),
|
|
229
|
+
peak_response=float(np.nanmax(values)),
|
|
230
|
+
min_response=float(np.nanmin(values)),
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
return tuple(summaries)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def summarize_epoch_roi_responses(
|
|
237
|
+
grouped: GroupedRoiResponses,
|
|
238
|
+
) -> tuple[GroupedRoiResponseSummary, ...]:
|
|
239
|
+
"""Summarize responses across trials with one row per epoch and ROI.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
grouped: Grouped response object.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Tuple of grouped summary rows.
|
|
246
|
+
|
|
247
|
+
The grouped mean and SEM are computed from trial-level mean responses, not
|
|
248
|
+
from every frame pooled together. That keeps each trial at equal weight even
|
|
249
|
+
if window lengths differ slightly.
|
|
250
|
+
"""
|
|
251
|
+
trial_summaries = summarize_grouped_responses(grouped)
|
|
252
|
+
grouped_values: dict[tuple[int, str, str], list[float]] = {}
|
|
253
|
+
for summary in trial_summaries:
|
|
254
|
+
if np.isnan(summary.mean_response):
|
|
255
|
+
continue
|
|
256
|
+
key = (summary.epoch_number, summary.epoch_name, summary.roi_label)
|
|
257
|
+
grouped_values.setdefault(key, []).append(summary.mean_response)
|
|
258
|
+
|
|
259
|
+
rows: list[GroupedRoiResponseSummary] = []
|
|
260
|
+
for key, values in grouped_values.items():
|
|
261
|
+
epoch_number, epoch_name, roi_label = key
|
|
262
|
+
response_values = np.asarray(values, dtype=np.float64)
|
|
263
|
+
trial_count = int(response_values.size)
|
|
264
|
+
sem = (
|
|
265
|
+
float(np.nanstd(response_values, ddof=1) / np.sqrt(trial_count))
|
|
266
|
+
if trial_count > 1
|
|
267
|
+
else 0.0
|
|
268
|
+
)
|
|
269
|
+
rows.append(
|
|
270
|
+
GroupedRoiResponseSummary(
|
|
271
|
+
epoch_number=epoch_number,
|
|
272
|
+
epoch_name=epoch_name,
|
|
273
|
+
roi_label=roi_label,
|
|
274
|
+
mean_response=float(np.nanmean(response_values)),
|
|
275
|
+
sem_response=sem,
|
|
276
|
+
n_trials=trial_count,
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
return tuple(rows)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_gray_epoch_name(epoch_name: str) -> bool:
|
|
283
|
+
"""Return whether an epoch name looks like a gray interleave.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
epoch_name: Stimulus epoch name.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
``True`` for ``gray`` or ``grey`` spelling.
|
|
290
|
+
"""
|
|
291
|
+
lowered = epoch_name.lower()
|
|
292
|
+
return "gray" in lowered or "grey" in lowered
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _validate_grouping_inputs(
|
|
296
|
+
dff: RoiDeltaFOverF,
|
|
297
|
+
epoch_windows: Sequence[EpochFrameWindow],
|
|
298
|
+
data_rate_hz: float,
|
|
299
|
+
pre_window_seconds: float,
|
|
300
|
+
post_window_seconds: float,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Validate response grouping inputs.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
dff: Candidate dF/F result.
|
|
306
|
+
epoch_windows: Candidate windows.
|
|
307
|
+
data_rate_hz: Candidate frame rate.
|
|
308
|
+
pre_window_seconds: Candidate prestimulus duration.
|
|
309
|
+
post_window_seconds: Candidate poststimulus duration.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
None.
|
|
313
|
+
"""
|
|
314
|
+
if dff.values.ndim != 2:
|
|
315
|
+
msg = f"dF/F values must have shape (frames, rois); got {dff.values.shape}"
|
|
316
|
+
raise ValueError(msg)
|
|
317
|
+
if dff.values.shape[1] != len(dff.labels):
|
|
318
|
+
msg = (
|
|
319
|
+
f"dF/F labels length {len(dff.labels)} does not match "
|
|
320
|
+
f"{dff.values.shape[1]} ROIs"
|
|
321
|
+
)
|
|
322
|
+
raise ValueError(msg)
|
|
323
|
+
if data_rate_hz <= 0:
|
|
324
|
+
msg = f"data_rate_hz must be positive; got {data_rate_hz}"
|
|
325
|
+
raise ValueError(msg)
|
|
326
|
+
if pre_window_seconds < 0:
|
|
327
|
+
msg = f"pre_window_seconds must be nonnegative; got {pre_window_seconds}"
|
|
328
|
+
raise ValueError(msg)
|
|
329
|
+
if post_window_seconds < 0:
|
|
330
|
+
msg = f"post_window_seconds must be nonnegative; got {post_window_seconds}"
|
|
331
|
+
raise ValueError(msg)
|
|
332
|
+
for epoch_window in epoch_windows:
|
|
333
|
+
window = epoch_window.window
|
|
334
|
+
if window.start_frame < dff.start_frame or window.stop_frame > dff.stop_frame:
|
|
335
|
+
msg = (
|
|
336
|
+
f"Window [{window.start_frame}, {window.stop_frame}) is outside "
|
|
337
|
+
f"dF/F range [{dff.start_frame}, {dff.stop_frame})"
|
|
338
|
+
)
|
|
339
|
+
raise ValueError(msg)
|
|
340
|
+
if window.start_frame >= window.stop_frame:
|
|
341
|
+
msg = (
|
|
342
|
+
f"Window [{window.start_frame}, {window.stop_frame}) must have "
|
|
343
|
+
"positive duration"
|
|
344
|
+
)
|
|
345
|
+
raise ValueError(msg)
|
twopy/analysis/trials.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Split ROI traces into frame windows for response analysis.
|
|
2
|
+
|
|
3
|
+
Inputs: converted recordings, ROI traces, frame boundaries, and photodiode
|
|
4
|
+
alignment.
|
|
5
|
+
Outputs: frame windows, stimulus-epoch labels, and windowed ROI response
|
|
6
|
+
arrays.
|
|
7
|
+
|
|
8
|
+
This module keeps trial slicing independent from the GUI and from source files.
|
|
9
|
+
Stimulus-specific labels can be added when the stimulus metadata has been
|
|
10
|
+
decoded.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import numpy.typing as npt
|
|
18
|
+
|
|
19
|
+
from twopy.converted import RecordingData
|
|
20
|
+
from twopy.photodiode_classification import classify_recording_photodiode_events
|
|
21
|
+
from twopy.roi import RoiTraces
|
|
22
|
+
from twopy.synchronization import PhotodiodeAlignment
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"EpochFrameWindow",
|
|
26
|
+
"FrameWindow",
|
|
27
|
+
"WindowedRoiResponse",
|
|
28
|
+
"frame_windows_from_photodiode_alignment",
|
|
29
|
+
"make_frame_windows",
|
|
30
|
+
"map_stimulus_epochs_to_frame_windows",
|
|
31
|
+
"select_epoch_frame_windows",
|
|
32
|
+
"split_traces_by_frame_windows",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class FrameWindow:
|
|
38
|
+
"""One half-open frame interval.
|
|
39
|
+
|
|
40
|
+
Inputs: start and stop frame indices in aligned movie coordinates.
|
|
41
|
+
Outputs: a labeled interval that can slice ROI traces.
|
|
42
|
+
|
|
43
|
+
``start_frame`` is inclusive and ``stop_frame`` is exclusive, matching
|
|
44
|
+
Python slicing and HDF5 frame reads.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
index: int
|
|
48
|
+
start_frame: int
|
|
49
|
+
stop_frame: int
|
|
50
|
+
label: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class EpochFrameWindow:
|
|
55
|
+
"""One photodiode-aligned frame window with stimulus epoch metadata.
|
|
56
|
+
|
|
57
|
+
Inputs: one frame window and the matching stimulus epoch run.
|
|
58
|
+
Outputs: frame bounds plus the epoch number and epoch name.
|
|
59
|
+
|
|
60
|
+
This object is the bridge between stimulus-computer rows and imaging-frame
|
|
61
|
+
movie slices. It is intentionally small so later response code can inspect
|
|
62
|
+
exactly which epoch each frame window came from.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
window: FrameWindow
|
|
66
|
+
epoch_number: int
|
|
67
|
+
epoch_name: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class WindowedRoiResponse:
|
|
72
|
+
"""ROI traces for one frame window.
|
|
73
|
+
|
|
74
|
+
Inputs: one ``FrameWindow`` and the matching slice of ``RoiTraces``.
|
|
75
|
+
Outputs: frame-by-ROI values plus ROI labels.
|
|
76
|
+
|
|
77
|
+
The values remain in frame order so callers can compute baselines,
|
|
78
|
+
peak responses, averages, or plots without losing timing.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
window: FrameWindow
|
|
82
|
+
values: npt.NDArray[np.float64]
|
|
83
|
+
roi_labels: tuple[str, ...]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_frame_windows(
|
|
87
|
+
boundary_frames: Sequence[int],
|
|
88
|
+
*,
|
|
89
|
+
frame_count: int,
|
|
90
|
+
labels: Sequence[str] | None = None,
|
|
91
|
+
) -> tuple[FrameWindow, ...]:
|
|
92
|
+
"""Create frame windows from explicit frame boundaries.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
boundary_frames: Ordered frame boundaries. Each neighboring pair forms
|
|
96
|
+
one window.
|
|
97
|
+
frame_count: Total number of aligned movie frames.
|
|
98
|
+
labels: Optional label for each output window.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Tuple of ``FrameWindow`` objects.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If boundaries are out of range, not increasing, or too few.
|
|
105
|
+
|
|
106
|
+
The caller owns what the boundaries mean: trials, epochs, baseline periods,
|
|
107
|
+
or any other stimulus-derived interval.
|
|
108
|
+
"""
|
|
109
|
+
boundaries = tuple(int(frame) for frame in boundary_frames)
|
|
110
|
+
if len(boundaries) < 2:
|
|
111
|
+
msg = "At least two frame boundaries are required to make windows"
|
|
112
|
+
raise ValueError(msg)
|
|
113
|
+
if any(frame < 0 or frame > frame_count for frame in boundaries):
|
|
114
|
+
msg = f"Frame boundaries must be within [0, {frame_count}]: {boundaries}"
|
|
115
|
+
raise ValueError(msg)
|
|
116
|
+
if any(
|
|
117
|
+
stop <= start for start, stop in zip(boundaries, boundaries[1:], strict=False)
|
|
118
|
+
):
|
|
119
|
+
msg = f"Frame boundaries must be strictly increasing: {boundaries}"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
|
|
122
|
+
window_count = len(boundaries) - 1
|
|
123
|
+
resolved_labels = _resolve_window_labels(window_count, labels)
|
|
124
|
+
return tuple(
|
|
125
|
+
FrameWindow(
|
|
126
|
+
index=index,
|
|
127
|
+
start_frame=boundaries[index],
|
|
128
|
+
stop_frame=boundaries[index + 1],
|
|
129
|
+
label=resolved_labels[index],
|
|
130
|
+
)
|
|
131
|
+
for index in range(window_count)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def frame_windows_from_photodiode_alignment(
|
|
136
|
+
alignment: PhotodiodeAlignment,
|
|
137
|
+
*,
|
|
138
|
+
frame_count: int,
|
|
139
|
+
labels: Sequence[str] | None = None,
|
|
140
|
+
) -> tuple[FrameWindow, ...]:
|
|
141
|
+
"""Create frame windows from paired photodiode event frames.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
alignment: Photodiode events paired to imaging frames.
|
|
145
|
+
frame_count: Total number of aligned movie frames.
|
|
146
|
+
labels: Optional label for each neighboring event interval.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Frame windows between consecutive paired photodiode events.
|
|
150
|
+
|
|
151
|
+
This turns decoded photodiode boundaries into frame intervals without
|
|
152
|
+
assuming what each event means in a stimulus-specific protocol.
|
|
153
|
+
"""
|
|
154
|
+
return make_frame_windows(
|
|
155
|
+
tuple(event.imaging_frame for event in alignment.paired_events),
|
|
156
|
+
frame_count=frame_count,
|
|
157
|
+
labels=labels,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def map_stimulus_epochs_to_frame_windows(
|
|
162
|
+
recording: RecordingData,
|
|
163
|
+
alignment: PhotodiodeAlignment,
|
|
164
|
+
) -> tuple[EpochFrameWindow, ...]:
|
|
165
|
+
"""Map stimulus epoch runs onto photodiode-aligned imaging-frame windows.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
recording: Loaded converted recording with ``stimulus/data`` and epoch
|
|
169
|
+
parameter metadata.
|
|
170
|
+
alignment: Photodiode events paired to imaging frames.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
One ``EpochFrameWindow`` per nonzero stimulus epoch run.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ValueError: If the stimulus epoch runs and photodiode windows do not
|
|
177
|
+
have the same count.
|
|
178
|
+
|
|
179
|
+
This wraps the stricter photodiode classifier and returns the older
|
|
180
|
+
``EpochFrameWindow`` shape used by response helpers.
|
|
181
|
+
"""
|
|
182
|
+
timing = classify_recording_photodiode_events(recording, alignment)
|
|
183
|
+
return tuple(
|
|
184
|
+
EpochFrameWindow(
|
|
185
|
+
window=FrameWindow(
|
|
186
|
+
index=window.index,
|
|
187
|
+
start_frame=window.start_frame,
|
|
188
|
+
stop_frame=window.stop_frame,
|
|
189
|
+
label=_epoch_window_label(window.epoch_number, window.epoch_name),
|
|
190
|
+
),
|
|
191
|
+
epoch_number=window.epoch_number,
|
|
192
|
+
epoch_name=window.epoch_name,
|
|
193
|
+
)
|
|
194
|
+
for window in timing.windows
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def select_epoch_frame_windows(
|
|
199
|
+
epoch_windows: tuple[EpochFrameWindow, ...],
|
|
200
|
+
*,
|
|
201
|
+
epoch_name: str | None = None,
|
|
202
|
+
epoch_number: int | None = None,
|
|
203
|
+
) -> tuple[FrameWindow, ...]:
|
|
204
|
+
"""Select frame windows for one stimulus epoch.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
epoch_windows: Stimulus-labeled frame windows.
|
|
208
|
+
epoch_name: Optional epoch name to match exactly.
|
|
209
|
+
epoch_number: Optional epoch number to match exactly.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Matching ``FrameWindow`` objects.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
ValueError: If neither selector is supplied.
|
|
216
|
+
|
|
217
|
+
This is the small script-facing helper used for baseline windows such as
|
|
218
|
+
``Gray Interleave`` without making dF/F code know stimulus-table details.
|
|
219
|
+
"""
|
|
220
|
+
if epoch_name is None and epoch_number is None:
|
|
221
|
+
msg = "Select by epoch_name, epoch_number, or both"
|
|
222
|
+
raise ValueError(msg)
|
|
223
|
+
|
|
224
|
+
return tuple(
|
|
225
|
+
epoch_window.window
|
|
226
|
+
for epoch_window in epoch_windows
|
|
227
|
+
if _matches_epoch_window(
|
|
228
|
+
epoch_window,
|
|
229
|
+
epoch_name=epoch_name,
|
|
230
|
+
epoch_number=epoch_number,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def split_traces_by_frame_windows(
|
|
236
|
+
traces: RoiTraces,
|
|
237
|
+
windows: Sequence[FrameWindow],
|
|
238
|
+
) -> tuple[WindowedRoiResponse, ...]:
|
|
239
|
+
"""Split ROI traces into explicit frame windows.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
traces: ROI traces with known start and stop frame indices.
|
|
243
|
+
windows: Frame windows to slice.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
One ``WindowedRoiResponse`` per window.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ValueError: If a window falls outside the trace frame range.
|
|
250
|
+
|
|
251
|
+
This function only slices; it does not compute response metrics. Keeping
|
|
252
|
+
those steps separate makes later baseline and trial logic auditable.
|
|
253
|
+
"""
|
|
254
|
+
responses: list[WindowedRoiResponse] = []
|
|
255
|
+
for window in windows:
|
|
256
|
+
_validate_window_inside_traces(window, traces)
|
|
257
|
+
start = window.start_frame - traces.start_frame
|
|
258
|
+
stop = window.stop_frame - traces.start_frame
|
|
259
|
+
responses.append(
|
|
260
|
+
WindowedRoiResponse(
|
|
261
|
+
window=window,
|
|
262
|
+
values=traces.values[start:stop, :],
|
|
263
|
+
roi_labels=traces.labels,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return tuple(responses)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _epoch_window_label(epoch_number: int, epoch_name: str) -> str:
|
|
271
|
+
"""Create a readable label for a stimulus epoch frame window.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
epoch_number: One-indexed stimulus epoch number.
|
|
275
|
+
epoch_name: Human-readable epoch name from converted stimulus metadata.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Label suitable for plots and logs.
|
|
279
|
+
"""
|
|
280
|
+
safe_name = epoch_name.strip().replace(" ", "_") or "unnamed"
|
|
281
|
+
return f"epoch_{epoch_number:04d}_{safe_name}"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _matches_epoch_window(
|
|
285
|
+
epoch_window: EpochFrameWindow,
|
|
286
|
+
*,
|
|
287
|
+
epoch_name: str | None,
|
|
288
|
+
epoch_number: int | None,
|
|
289
|
+
) -> bool:
|
|
290
|
+
"""Check whether one epoch window matches optional selectors.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
epoch_window: Candidate stimulus-labeled window.
|
|
294
|
+
epoch_name: Optional epoch name selector.
|
|
295
|
+
epoch_number: Optional epoch number selector.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
``True`` when all supplied selectors match.
|
|
299
|
+
"""
|
|
300
|
+
if epoch_name is not None and epoch_window.epoch_name != epoch_name:
|
|
301
|
+
return False
|
|
302
|
+
return epoch_number is None or epoch_window.epoch_number == epoch_number
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_window_labels(
|
|
306
|
+
window_count: int,
|
|
307
|
+
labels: Sequence[str] | None,
|
|
308
|
+
) -> tuple[str, ...]:
|
|
309
|
+
"""Resolve optional frame-window labels.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
window_count: Number of windows being created.
|
|
313
|
+
labels: Optional caller labels.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
One label per window.
|
|
317
|
+
"""
|
|
318
|
+
if labels is None:
|
|
319
|
+
return tuple(f"window_{index + 1:04d}" for index in range(window_count))
|
|
320
|
+
if len(labels) != window_count:
|
|
321
|
+
msg = f"Expected {window_count} labels; got {len(labels)}"
|
|
322
|
+
raise ValueError(msg)
|
|
323
|
+
return tuple(labels)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _validate_window_inside_traces(window: FrameWindow, traces: RoiTraces) -> None:
|
|
327
|
+
"""Confirm one frame window can slice a trace matrix.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
window: Frame window to check.
|
|
331
|
+
traces: ROI traces with available frame bounds.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
None.
|
|
335
|
+
"""
|
|
336
|
+
if window.start_frame < traces.start_frame or window.stop_frame > traces.stop_frame:
|
|
337
|
+
msg = (
|
|
338
|
+
f"Window [{window.start_frame}, {window.stop_frame}) is outside "
|
|
339
|
+
f"trace range [{traces.start_frame}, {traces.stop_frame})"
|
|
340
|
+
)
|
|
341
|
+
raise ValueError(msg)
|