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,174 @@
|
|
|
1
|
+
"""Read prior lab saved-analysis outputs for parity checks.
|
|
2
|
+
|
|
3
|
+
Inputs: optional ``savedAnalysis/*.mat`` files produced by prior analysis.
|
|
4
|
+
Outputs: typed NumPy arrays that tests or audit scripts can compare to twopy
|
|
5
|
+
outputs.
|
|
6
|
+
|
|
7
|
+
This module is read-only. It does not make source MATLAB files part of the
|
|
8
|
+
twopy analysis path; it exists only to compare twopy-owned converted analysis
|
|
9
|
+
against previous saved outputs when those files are available.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import cast
|
|
15
|
+
|
|
16
|
+
import h5py
|
|
17
|
+
import numpy as np
|
|
18
|
+
import numpy.typing as npt
|
|
19
|
+
|
|
20
|
+
__all__ = ["SavedAnalysisLastRoi", "load_saved_analysis_last_roi"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SavedAnalysisLastRoi:
|
|
25
|
+
"""Selected fields from one saved ``lastRoi`` object.
|
|
26
|
+
|
|
27
|
+
Inputs: one HDF5-backed MATLAB ``savedAnalysis`` file.
|
|
28
|
+
Outputs: ROI masks, epoch labels, epoch windows, and saved ROI traces.
|
|
29
|
+
|
|
30
|
+
These fields are enough to build numeric parity checks without coupling
|
|
31
|
+
normal twopy analysis to MATLAB-owned files.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
path: Path
|
|
35
|
+
roi_mask_initial: npt.NDArray[np.float64]
|
|
36
|
+
epoch_list: npt.NDArray[np.int64]
|
|
37
|
+
epoch_start_times: tuple[npt.NDArray[np.int64], ...]
|
|
38
|
+
epoch_durations: tuple[npt.NDArray[np.int64], ...]
|
|
39
|
+
time_by_rois_initial: tuple[npt.NDArray[np.float64], ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_saved_analysis_last_roi(path: Path) -> SavedAnalysisLastRoi:
|
|
43
|
+
"""Load selected ``lastRoi`` outputs from a saved-analysis MAT file.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: Path to an HDF5-backed MATLAB saved-analysis file.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
``SavedAnalysisLastRoi`` with arrays ready for parity checks.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If the file does not contain the expected ``lastRoi``
|
|
53
|
+
fields.
|
|
54
|
+
|
|
55
|
+
MATLAB v7.3 files store cell contents as HDF5 object references. This reader
|
|
56
|
+
resolves those references but keeps the output small and explicit.
|
|
57
|
+
"""
|
|
58
|
+
input_path = path.expanduser()
|
|
59
|
+
with h5py.File(input_path, "r") as h5_file:
|
|
60
|
+
if "lastRoi" not in h5_file:
|
|
61
|
+
msg = f"Saved analysis file has no 'lastRoi' group: {input_path}"
|
|
62
|
+
raise ValueError(msg)
|
|
63
|
+
roi_group = h5_file["lastRoi"]
|
|
64
|
+
return SavedAnalysisLastRoi(
|
|
65
|
+
path=input_path,
|
|
66
|
+
roi_mask_initial=cast(
|
|
67
|
+
npt.NDArray[np.float64],
|
|
68
|
+
roi_group["roiMaskInitial"][()],
|
|
69
|
+
),
|
|
70
|
+
epoch_list=np.ravel(roi_group["epochList"][()]).astype(np.int64),
|
|
71
|
+
epoch_start_times=_read_int_cell_arrays(
|
|
72
|
+
h5_file,
|
|
73
|
+
roi_group,
|
|
74
|
+
"epochStartTimes",
|
|
75
|
+
),
|
|
76
|
+
epoch_durations=_read_int_cell_arrays(
|
|
77
|
+
h5_file,
|
|
78
|
+
roi_group,
|
|
79
|
+
"epochDurations",
|
|
80
|
+
),
|
|
81
|
+
time_by_rois_initial=_read_trace_cell_arrays(
|
|
82
|
+
h5_file,
|
|
83
|
+
roi_group,
|
|
84
|
+
"timeByRoisInitial",
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_int_cell_arrays(
|
|
90
|
+
h5_file: h5py.File,
|
|
91
|
+
group: h5py.Group,
|
|
92
|
+
dataset_name: str,
|
|
93
|
+
) -> tuple[npt.NDArray[np.int64], ...]:
|
|
94
|
+
"""Read a MATLAB cell array of integer vectors.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
h5_file: Open HDF5 file.
|
|
98
|
+
group: Group containing the cell dataset.
|
|
99
|
+
dataset_name: Name of the cell dataset.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tuple of one-dimensional integer arrays.
|
|
103
|
+
"""
|
|
104
|
+
arrays = _read_cell_arrays(h5_file, group, dataset_name)
|
|
105
|
+
return tuple(np.ravel(array).astype(np.int64) for array in arrays)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_trace_cell_arrays(
|
|
109
|
+
h5_file: h5py.File,
|
|
110
|
+
group: h5py.Group,
|
|
111
|
+
dataset_name: str,
|
|
112
|
+
) -> tuple[npt.NDArray[np.float64], ...]:
|
|
113
|
+
"""Read a MATLAB cell array of ROI trace matrices.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
h5_file: Open HDF5 file.
|
|
117
|
+
group: Group containing the cell dataset.
|
|
118
|
+
dataset_name: Name of the cell dataset.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple of trace arrays shaped ``(frames, rois)`` when the saved matrix is
|
|
122
|
+
two-dimensional.
|
|
123
|
+
"""
|
|
124
|
+
arrays = _read_cell_arrays(h5_file, group, dataset_name)
|
|
125
|
+
return tuple(_matlab_trace_matrix_to_frames_by_rois(array) for array in arrays)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_cell_arrays(
|
|
129
|
+
h5_file: h5py.File,
|
|
130
|
+
group: h5py.Group,
|
|
131
|
+
dataset_name: str,
|
|
132
|
+
) -> tuple[npt.NDArray[np.float64], ...]:
|
|
133
|
+
"""Resolve a MATLAB cell array stored as HDF5 references.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
h5_file: Open HDF5 file.
|
|
137
|
+
group: Group containing the cell dataset.
|
|
138
|
+
dataset_name: Name of the cell dataset.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Tuple of referenced numeric arrays.
|
|
142
|
+
"""
|
|
143
|
+
if dataset_name not in group:
|
|
144
|
+
msg = f"Saved analysis lastRoi is missing {dataset_name!r}"
|
|
145
|
+
raise ValueError(msg)
|
|
146
|
+
references = group[dataset_name][()]
|
|
147
|
+
arrays: list[npt.NDArray[np.float64]] = []
|
|
148
|
+
for reference in np.ravel(references):
|
|
149
|
+
if not isinstance(reference, h5py.Reference) or not reference:
|
|
150
|
+
continue
|
|
151
|
+
arrays.append(cast(npt.NDArray[np.float64], h5_file[reference][()]))
|
|
152
|
+
return tuple(arrays)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _matlab_trace_matrix_to_frames_by_rois(
|
|
156
|
+
array: npt.NDArray[np.float64],
|
|
157
|
+
) -> npt.NDArray[np.float64]:
|
|
158
|
+
"""Return saved trace matrices as ``(frames, rois)``.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
array: Referenced MATLAB trace matrix.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Trace matrix with frame axis first.
|
|
165
|
+
|
|
166
|
+
MATLAB writes column-major arrays into HDF5 with dimensions that appear
|
|
167
|
+
reversed to h5py. Saved ROI traces are usually ``rois x frames`` when read
|
|
168
|
+
directly, so this helper transposes two-dimensional trace matrices into the
|
|
169
|
+
twopy convention.
|
|
170
|
+
"""
|
|
171
|
+
values = np.asarray(array, dtype=np.float64)
|
|
172
|
+
if values.ndim == 2:
|
|
173
|
+
return values.T
|
|
174
|
+
return values
|
twopy/photodiode.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Shared photodiode timing helpers.
|
|
2
|
+
|
|
3
|
+
Inputs: converted or source photodiode vectors plus imaging-frame metadata.
|
|
4
|
+
Outputs: thresholded flash events and stimulus-bounded imaging-frame ranges.
|
|
5
|
+
|
|
6
|
+
The photodiode is the common timing evidence between the stimulus computer and
|
|
7
|
+
the imaging computer. This module keeps that low-level contract in one place so
|
|
8
|
+
conversion and response analysis do not drift into different threshold or
|
|
9
|
+
end-flash rules.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import numpy.typing as npt
|
|
17
|
+
|
|
18
|
+
from twopy._segments import true_segments
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DEFAULT_END_FLASH_PROJECTOR_FRAME_THRESHOLD",
|
|
22
|
+
"DEFAULT_PHOTODIODE_THRESHOLD_FRACTION",
|
|
23
|
+
"DEFAULT_PROJECTOR_HZ",
|
|
24
|
+
"PhotodiodeEvent",
|
|
25
|
+
"PhotodiodePolarity",
|
|
26
|
+
"detect_photodiode_event_segments",
|
|
27
|
+
"photodiode_threshold",
|
|
28
|
+
"stimulus_frame_bounds_from_photodiode",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
PhotodiodePolarity = Literal["above", "below"]
|
|
32
|
+
|
|
33
|
+
DEFAULT_PHOTODIODE_THRESHOLD_FRACTION = 0.3
|
|
34
|
+
DEFAULT_PROJECTOR_HZ = 60.0
|
|
35
|
+
DEFAULT_END_FLASH_PROJECTOR_FRAME_THRESHOLD = 19.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class PhotodiodeEvent:
|
|
40
|
+
"""One contiguous photodiode flash segment.
|
|
41
|
+
|
|
42
|
+
Inputs: one thresholded photodiode signal.
|
|
43
|
+
Outputs: start, stop, center, duration, and peak for one detected event.
|
|
44
|
+
|
|
45
|
+
``start_sample`` is inclusive and ``stop_sample`` is exclusive. When the
|
|
46
|
+
source signal is ``imaging_res_pd``, sample indices are imaging-frame
|
|
47
|
+
indices. When the source signal is ``high_res_pd``, sample indices are
|
|
48
|
+
high-rate photodiode samples.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
start_sample: int
|
|
52
|
+
stop_sample: int
|
|
53
|
+
center_sample: float
|
|
54
|
+
duration_samples: int
|
|
55
|
+
peak_value: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def photodiode_threshold(
|
|
59
|
+
signal: npt.ArrayLike,
|
|
60
|
+
*,
|
|
61
|
+
fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
|
|
62
|
+
) -> float:
|
|
63
|
+
"""Compute the default active-flash threshold for a photodiode signal.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
signal: One-dimensional photodiode samples.
|
|
67
|
+
fraction: Fraction of the signal range above the minimum used as the
|
|
68
|
+
threshold.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
``min(signal) + fraction * (max(signal) - min(signal))``.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If the signal is not one-dimensional or ``fraction`` is
|
|
75
|
+
outside ``[0, 1]``.
|
|
76
|
+
|
|
77
|
+
The default fraction is 0.3 to match the lab analysis convention. This
|
|
78
|
+
lower-than-midpoint threshold detects flash boundaries closer to the first
|
|
79
|
+
rising edge while still using only local signal range information.
|
|
80
|
+
"""
|
|
81
|
+
values = _photodiode_values(signal)
|
|
82
|
+
if not 0.0 <= fraction <= 1.0:
|
|
83
|
+
msg = f"Photodiode threshold fraction must be in [0, 1]; got {fraction}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
if values.size == 0:
|
|
86
|
+
return 0.0
|
|
87
|
+
|
|
88
|
+
minimum = float(np.nanmin(values))
|
|
89
|
+
maximum = float(np.nanmax(values))
|
|
90
|
+
return minimum + (fraction * (maximum - minimum))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detect_photodiode_event_segments(
|
|
94
|
+
signal: npt.ArrayLike,
|
|
95
|
+
*,
|
|
96
|
+
threshold: float | None = None,
|
|
97
|
+
threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
|
|
98
|
+
polarity: PhotodiodePolarity = "above",
|
|
99
|
+
minimum_width_samples: int = 1,
|
|
100
|
+
merge_gap_samples: int = 0,
|
|
101
|
+
) -> tuple[tuple[PhotodiodeEvent, ...], float]:
|
|
102
|
+
"""Detect event segments and return the threshold used.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
signal: One-dimensional photodiode samples.
|
|
106
|
+
threshold: Optional explicit event threshold. When omitted, the
|
|
107
|
+
range-fraction threshold is used.
|
|
108
|
+
threshold_fraction: Fraction used when ``threshold`` is omitted.
|
|
109
|
+
polarity: Which side of threshold marks an event.
|
|
110
|
+
minimum_width_samples: Drop events shorter than this width.
|
|
111
|
+
merge_gap_samples: Merge neighboring events separated by this width.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
``(events, threshold)``.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: If signal shape or width arguments are invalid.
|
|
118
|
+
"""
|
|
119
|
+
values = _photodiode_values(signal)
|
|
120
|
+
if minimum_width_samples < 1:
|
|
121
|
+
msg = "minimum_width_samples must be at least 1"
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
if merge_gap_samples < 0:
|
|
124
|
+
msg = "merge_gap_samples cannot be negative"
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
|
|
127
|
+
resolved_threshold = (
|
|
128
|
+
photodiode_threshold(values, fraction=threshold_fraction)
|
|
129
|
+
if threshold is None
|
|
130
|
+
else float(threshold)
|
|
131
|
+
)
|
|
132
|
+
if values.size == 0:
|
|
133
|
+
return (), resolved_threshold
|
|
134
|
+
|
|
135
|
+
active = _threshold_signal(
|
|
136
|
+
values=values,
|
|
137
|
+
threshold=resolved_threshold,
|
|
138
|
+
polarity=polarity,
|
|
139
|
+
)
|
|
140
|
+
segments = true_segments(active)
|
|
141
|
+
merged_segments = _merge_close_segments(segments, merge_gap_samples)
|
|
142
|
+
events = tuple(
|
|
143
|
+
_event_from_segment(values, start, stop)
|
|
144
|
+
for start, stop in merged_segments
|
|
145
|
+
if stop - start >= minimum_width_samples
|
|
146
|
+
)
|
|
147
|
+
return events, resolved_threshold
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def stimulus_frame_bounds_from_photodiode(
|
|
151
|
+
high_res_pd: npt.ArrayLike,
|
|
152
|
+
*,
|
|
153
|
+
movie_frame_count: int,
|
|
154
|
+
frame_rate_hz: float,
|
|
155
|
+
threshold: float | None = None,
|
|
156
|
+
threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
|
|
157
|
+
projector_hz: float = DEFAULT_PROJECTOR_HZ,
|
|
158
|
+
end_flash_projector_frame_threshold: float = (
|
|
159
|
+
DEFAULT_END_FLASH_PROJECTOR_FRAME_THRESHOLD
|
|
160
|
+
),
|
|
161
|
+
) -> tuple[int, int]:
|
|
162
|
+
"""Return imaging-frame bounds for the stimulus presentation window.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
high_res_pd: High-rate photodiode signal.
|
|
166
|
+
movie_frame_count: Number of aligned movie frames.
|
|
167
|
+
frame_rate_hz: Imaging frame rate.
|
|
168
|
+
threshold: Optional explicit event threshold.
|
|
169
|
+
threshold_fraction: Range fraction used when ``threshold`` is omitted.
|
|
170
|
+
projector_hz: Stimulus projector refresh rate.
|
|
171
|
+
end_flash_projector_frame_threshold: Long-flash duration threshold in
|
|
172
|
+
projector frames.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Half-open imaging-frame range ``(start, stop)``.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If no start/end flash can be found or if long end flashes
|
|
179
|
+
are ambiguous.
|
|
180
|
+
|
|
181
|
+
The first detected flash marks stimulus start. The selected long flash marks
|
|
182
|
+
stimulus end, and the end frame is the frame immediately before that flash.
|
|
183
|
+
"""
|
|
184
|
+
events, _ = detect_photodiode_event_segments(
|
|
185
|
+
high_res_pd,
|
|
186
|
+
threshold=threshold,
|
|
187
|
+
threshold_fraction=threshold_fraction,
|
|
188
|
+
)
|
|
189
|
+
if len(events) == 0:
|
|
190
|
+
msg = "Cannot find stimulus frame bounds without photodiode flashes"
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
|
|
193
|
+
samples_per_frame = _high_res_samples_per_frame(
|
|
194
|
+
high_res_sample_count=_photodiode_values(high_res_pd).size,
|
|
195
|
+
movie_frame_count=movie_frame_count,
|
|
196
|
+
)
|
|
197
|
+
end_index = _end_flash_event_index(
|
|
198
|
+
events=events,
|
|
199
|
+
high_res_samples_per_frame=samples_per_frame,
|
|
200
|
+
frame_rate_hz=frame_rate_hz,
|
|
201
|
+
projector_hz=projector_hz,
|
|
202
|
+
end_flash_projector_frame_threshold=end_flash_projector_frame_threshold,
|
|
203
|
+
)
|
|
204
|
+
epoch_begin = events[0].start_sample / samples_per_frame
|
|
205
|
+
epoch_end = (events[end_index].start_sample - 1) / samples_per_frame
|
|
206
|
+
start = int(np.floor(epoch_begin))
|
|
207
|
+
stop = int(np.floor(epoch_end)) + 1
|
|
208
|
+
if start < 0 or stop > movie_frame_count or start >= stop:
|
|
209
|
+
msg = (
|
|
210
|
+
f"Invalid stimulus frame bounds [{start}, {stop}) for "
|
|
211
|
+
f"{movie_frame_count} frames"
|
|
212
|
+
)
|
|
213
|
+
raise ValueError(msg)
|
|
214
|
+
return start, stop
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _photodiode_values(signal: npt.ArrayLike) -> npt.NDArray[np.float64]:
|
|
218
|
+
"""Return a validated one-dimensional photodiode vector.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
signal: Candidate photodiode samples.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Float64 one-dimensional values.
|
|
225
|
+
"""
|
|
226
|
+
values = np.asarray(signal, dtype=np.float64)
|
|
227
|
+
if values.ndim != 1:
|
|
228
|
+
msg = f"Photodiode signal must be one-dimensional; got {values.shape}"
|
|
229
|
+
raise ValueError(msg)
|
|
230
|
+
return values
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _high_res_samples_per_frame(
|
|
234
|
+
*,
|
|
235
|
+
high_res_sample_count: int,
|
|
236
|
+
movie_frame_count: int,
|
|
237
|
+
) -> int:
|
|
238
|
+
"""Return high-rate photodiode samples per imaging frame.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
high_res_sample_count: Number of high-rate photodiode samples.
|
|
242
|
+
movie_frame_count: Number of aligned movie frames.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Integer high-rate samples per imaging frame.
|
|
246
|
+
"""
|
|
247
|
+
if movie_frame_count <= 0:
|
|
248
|
+
msg = f"movie_frame_count must be positive; got {movie_frame_count}"
|
|
249
|
+
raise ValueError(msg)
|
|
250
|
+
if high_res_sample_count % movie_frame_count != 0:
|
|
251
|
+
msg = (
|
|
252
|
+
"high-rate photodiode sample count must be an integer multiple of "
|
|
253
|
+
f"movie frames: {high_res_sample_count} samples for "
|
|
254
|
+
f"{movie_frame_count} frames"
|
|
255
|
+
)
|
|
256
|
+
raise ValueError(msg)
|
|
257
|
+
return high_res_sample_count // movie_frame_count
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _end_flash_event_index(
|
|
261
|
+
*,
|
|
262
|
+
events: tuple[PhotodiodeEvent, ...],
|
|
263
|
+
high_res_samples_per_frame: int,
|
|
264
|
+
frame_rate_hz: float,
|
|
265
|
+
projector_hz: float,
|
|
266
|
+
end_flash_projector_frame_threshold: float,
|
|
267
|
+
) -> int:
|
|
268
|
+
"""Identify the long photodiode event that marks stimulus end.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
events: High-rate photodiode events.
|
|
272
|
+
high_res_samples_per_frame: High-rate samples per imaging frame.
|
|
273
|
+
frame_rate_hz: Imaging frame rate.
|
|
274
|
+
projector_hz: Stimulus projector refresh rate.
|
|
275
|
+
end_flash_projector_frame_threshold: Minimum projector-frame duration
|
|
276
|
+
for an end flash.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Index of the flash whose start bounds the stimulus end.
|
|
280
|
+
"""
|
|
281
|
+
durations = np.asarray(
|
|
282
|
+
[event.duration_samples for event in events],
|
|
283
|
+
dtype=np.float64,
|
|
284
|
+
)
|
|
285
|
+
projector_frames = (
|
|
286
|
+
durations / float(high_res_samples_per_frame) / frame_rate_hz * projector_hz
|
|
287
|
+
)
|
|
288
|
+
end_candidates = np.flatnonzero(
|
|
289
|
+
projector_frames > end_flash_projector_frame_threshold,
|
|
290
|
+
)
|
|
291
|
+
if end_candidates.size == 0:
|
|
292
|
+
msg = "Cannot find stimulus end because no long end flash was detected"
|
|
293
|
+
raise ValueError(msg)
|
|
294
|
+
if end_candidates.size == 2 and end_candidates[1] - end_candidates[0] > 3:
|
|
295
|
+
return int(end_candidates[1])
|
|
296
|
+
if end_candidates.size > 2:
|
|
297
|
+
msg = (
|
|
298
|
+
"Ambiguous stimulus end: more than two long photodiode flashes were "
|
|
299
|
+
f"detected at event indices {end_candidates.tolist()}"
|
|
300
|
+
)
|
|
301
|
+
raise ValueError(msg)
|
|
302
|
+
return int(end_candidates[0])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _threshold_signal(
|
|
306
|
+
*,
|
|
307
|
+
values: npt.NDArray[np.float64],
|
|
308
|
+
threshold: float,
|
|
309
|
+
polarity: PhotodiodePolarity,
|
|
310
|
+
) -> npt.NDArray[np.bool_]:
|
|
311
|
+
"""Convert signal values into an active-event mask.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
values: One-dimensional signal values.
|
|
315
|
+
threshold: Event threshold.
|
|
316
|
+
polarity: Which side of threshold is active.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Boolean mask with one value per input sample.
|
|
320
|
+
"""
|
|
321
|
+
if polarity == "above":
|
|
322
|
+
return values > threshold
|
|
323
|
+
return values < threshold
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _merge_close_segments(
|
|
327
|
+
segments: tuple[tuple[int, int], ...],
|
|
328
|
+
merge_gap_samples: int,
|
|
329
|
+
) -> tuple[tuple[int, int], ...]:
|
|
330
|
+
"""Merge event segments separated by short inactive gaps.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
segments: Event start/stop pairs.
|
|
334
|
+
merge_gap_samples: Maximum inactive gap to merge.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Merged start/stop pairs.
|
|
338
|
+
"""
|
|
339
|
+
if len(segments) <= 1 or merge_gap_samples == 0:
|
|
340
|
+
return segments
|
|
341
|
+
|
|
342
|
+
merged: list[tuple[int, int]] = [segments[0]]
|
|
343
|
+
for start, stop in segments[1:]:
|
|
344
|
+
last_start, last_stop = merged[-1]
|
|
345
|
+
if start - last_stop <= merge_gap_samples:
|
|
346
|
+
merged[-1] = (last_start, stop)
|
|
347
|
+
else:
|
|
348
|
+
merged.append((start, stop))
|
|
349
|
+
|
|
350
|
+
return tuple(merged)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _event_from_segment(
|
|
354
|
+
values: npt.NDArray[np.float64],
|
|
355
|
+
start: int,
|
|
356
|
+
stop: int,
|
|
357
|
+
) -> PhotodiodeEvent:
|
|
358
|
+
"""Summarize one active event segment.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
values: Full one-dimensional photodiode signal.
|
|
362
|
+
start: Inclusive event start sample.
|
|
363
|
+
stop: Exclusive event stop sample.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
``PhotodiodeEvent`` for the segment.
|
|
367
|
+
"""
|
|
368
|
+
event_values = values[start:stop]
|
|
369
|
+
return PhotodiodeEvent(
|
|
370
|
+
start_sample=start,
|
|
371
|
+
stop_sample=stop,
|
|
372
|
+
center_sample=float(start + (stop - start - 1) / 2.0),
|
|
373
|
+
duration_samples=stop - start,
|
|
374
|
+
peak_value=float(np.nanmax(event_values)),
|
|
375
|
+
)
|