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
twopy/spatial.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Spatial crop contracts for converted two-photon recordings.
|
|
2
|
+
|
|
3
|
+
Inputs: movie spatial shapes, alignment-derived crop bounds, and ROI masks.
|
|
4
|
+
Outputs: typed crop objects and helpers that keep full-frame and cropped
|
|
5
|
+
analysis coordinates explicit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import numpy.typing as npt
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SpatialCrop",
|
|
16
|
+
"SpatialDomain",
|
|
17
|
+
"full_frame_crop",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
SpatialDomain = Literal["alignment_valid_crop", "full_frame"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SpatialCrop:
|
|
25
|
+
"""A rectangular spatial domain inside a full movie frame.
|
|
26
|
+
|
|
27
|
+
Inputs: half-open bounds along the two spatial movie axes plus the original
|
|
28
|
+
full-frame shape.
|
|
29
|
+
Outputs: validated crop metadata and array slicing helpers.
|
|
30
|
+
|
|
31
|
+
``axis0`` and ``axis1`` name NumPy movie axes directly. twopy avoids
|
|
32
|
+
top/left/x/y here because source files and display tools can disagree about
|
|
33
|
+
image orientation, while the array axes are unambiguous.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
axis0_start: int
|
|
37
|
+
axis0_stop: int
|
|
38
|
+
axis1_start: int
|
|
39
|
+
axis1_stop: int
|
|
40
|
+
original_shape: tuple[int, int]
|
|
41
|
+
source: str
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
"""Validate crop bounds immediately after construction.
|
|
45
|
+
|
|
46
|
+
Inputs: dataclass field values.
|
|
47
|
+
Outputs: ``None`` or a clear ``ValueError``.
|
|
48
|
+
"""
|
|
49
|
+
axis0_length, axis1_length = self.original_shape
|
|
50
|
+
if (
|
|
51
|
+
self.axis0_start < 0
|
|
52
|
+
or self.axis1_start < 0
|
|
53
|
+
or self.axis0_stop > axis0_length
|
|
54
|
+
or self.axis1_stop > axis1_length
|
|
55
|
+
or self.axis0_start >= self.axis0_stop
|
|
56
|
+
or self.axis1_start >= self.axis1_stop
|
|
57
|
+
):
|
|
58
|
+
msg = (
|
|
59
|
+
"Invalid spatial crop "
|
|
60
|
+
f"axis0=[{self.axis0_start}, {self.axis0_stop}), "
|
|
61
|
+
f"axis1=[{self.axis1_start}, {self.axis1_stop}) for "
|
|
62
|
+
f"shape {self.original_shape}"
|
|
63
|
+
)
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def shape(self) -> tuple[int, int]:
|
|
68
|
+
"""Return the spatial shape of this crop.
|
|
69
|
+
|
|
70
|
+
Inputs: crop bounds.
|
|
71
|
+
Outputs: ``(axis0_length, axis1_length)``.
|
|
72
|
+
"""
|
|
73
|
+
return (
|
|
74
|
+
self.axis0_stop - self.axis0_start,
|
|
75
|
+
self.axis1_stop - self.axis1_start,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_full_frame(self) -> bool:
|
|
80
|
+
"""Return whether this crop covers the whole source frame.
|
|
81
|
+
|
|
82
|
+
Inputs: crop bounds and original shape.
|
|
83
|
+
Outputs: ``True`` for full-frame domains, otherwise ``False``.
|
|
84
|
+
"""
|
|
85
|
+
return (
|
|
86
|
+
self.axis0_start == 0
|
|
87
|
+
and self.axis1_start == 0
|
|
88
|
+
and self.axis0_stop == self.original_shape[0]
|
|
89
|
+
and self.axis1_stop == self.original_shape[1]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def crop_image(
|
|
93
|
+
self,
|
|
94
|
+
image: npt.NDArray[Any],
|
|
95
|
+
) -> npt.NDArray[Any]:
|
|
96
|
+
"""Return the crop view from a full-frame image.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
image: Two-dimensional full-frame image with ``original_shape``.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
View of ``image`` inside this crop.
|
|
103
|
+
"""
|
|
104
|
+
if image.shape != self.original_shape:
|
|
105
|
+
msg = f"Image shape {image.shape} does not match {self.original_shape}"
|
|
106
|
+
raise ValueError(msg)
|
|
107
|
+
return image[
|
|
108
|
+
self.axis0_start : self.axis0_stop,
|
|
109
|
+
self.axis1_start : self.axis1_stop,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def crop_masks(
|
|
113
|
+
self,
|
|
114
|
+
masks: npt.NDArray[np.bool_],
|
|
115
|
+
) -> npt.NDArray[np.bool_]:
|
|
116
|
+
"""Return ROI masks restricted to this crop.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
masks: Boolean masks shaped ``(rois, axis0, axis1)`` in full-frame
|
|
120
|
+
movie coordinates.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Boolean mask view shaped ``(rois, cropped_axis0, cropped_axis1)``.
|
|
124
|
+
"""
|
|
125
|
+
if masks.ndim != 3 or masks.shape[1:] != self.original_shape:
|
|
126
|
+
msg = (
|
|
127
|
+
f"ROI mask shape {masks.shape} does not match crop source "
|
|
128
|
+
f"shape {self.original_shape}"
|
|
129
|
+
)
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
return masks[
|
|
132
|
+
:,
|
|
133
|
+
self.axis0_start : self.axis0_stop,
|
|
134
|
+
self.axis1_start : self.axis1_stop,
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def full_frame_crop(spatial_shape: tuple[int, int]) -> SpatialCrop:
|
|
139
|
+
"""Create a crop object that covers an entire movie frame.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
spatial_shape: Full movie spatial shape.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
``SpatialCrop`` spanning the full frame.
|
|
146
|
+
"""
|
|
147
|
+
return SpatialCrop(
|
|
148
|
+
axis0_start=0,
|
|
149
|
+
axis0_stop=spatial_shape[0],
|
|
150
|
+
axis1_start=0,
|
|
151
|
+
axis1_stop=spatial_shape[1],
|
|
152
|
+
original_shape=spatial_shape,
|
|
153
|
+
source="full_frame",
|
|
154
|
+
)
|
twopy/stimulus.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Helpers for interpreting converted stimulus data columns.
|
|
2
|
+
|
|
3
|
+
Inputs: a loaded twopy ``RecordingData`` object.
|
|
4
|
+
Outputs: small typed mappings from stable ``stimulus/data`` column names to the
|
|
5
|
+
experiment-specific MATLAB code that wrote those columns.
|
|
6
|
+
|
|
7
|
+
The stable HDF5 column name stays generic because different ``stimtype`` values
|
|
8
|
+
can reuse the same slot for different meanings. These helpers make that
|
|
9
|
+
per-``stimtype`` mapping explicit for scripts and analysis code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from twopy.converted import RecordingData
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"StimulusSpecificColumnMapping",
|
|
19
|
+
"map_stimulus_specific_column",
|
|
20
|
+
"stimulus_column_index",
|
|
21
|
+
"stimulus_epoch_names_by_number",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class StimulusSpecificColumnMapping:
|
|
27
|
+
"""Meaning of one stable stimulus-specific column for one ``stimtype``.
|
|
28
|
+
|
|
29
|
+
Inputs: decoded metadata from ``stimulus/stimulus_specific_columns_json``.
|
|
30
|
+
Outputs: a script-friendly record containing the stable column name, the
|
|
31
|
+
``stimtype``, the backed-up MATLAB function, and the source assignment.
|
|
32
|
+
|
|
33
|
+
``specific_name`` is the most readable available label: an inline MATLAB
|
|
34
|
+
comment when present, otherwise the right-hand source expression.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
stable_column_name: str
|
|
38
|
+
stimtype: str
|
|
39
|
+
specific_name: str
|
|
40
|
+
source_expression: str
|
|
41
|
+
source_comment: str | None
|
|
42
|
+
source_line: int
|
|
43
|
+
source_path: str
|
|
44
|
+
stimulus_function: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def map_stimulus_specific_column(
|
|
48
|
+
recording: RecordingData,
|
|
49
|
+
stable_column_name: str,
|
|
50
|
+
*,
|
|
51
|
+
stimtype: int | str | None = None,
|
|
52
|
+
) -> tuple[StimulusSpecificColumnMapping, ...]:
|
|
53
|
+
"""Map a stable stimulus column to experiment-specific meanings.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
recording: Loaded converted recording.
|
|
57
|
+
stable_column_name: Stable name such as ``stimulus_specific_04``.
|
|
58
|
+
stimtype: Optional stimulus type. When omitted, mappings for all
|
|
59
|
+
``stimtype`` values in the recording are returned.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
One mapping per matching ``stimtype``. An unused stimulus-specific slot
|
|
63
|
+
returns an empty tuple.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If ``stable_column_name`` is not a stimulus-specific column.
|
|
67
|
+
KeyError: If the requested ``stimtype`` is not present in the recording.
|
|
68
|
+
|
|
69
|
+
This function exists so analysis code can use the simple stable column
|
|
70
|
+
names for data access while still showing the exact experiment-specific
|
|
71
|
+
meaning from the backed-up MATLAB code.
|
|
72
|
+
"""
|
|
73
|
+
if not stable_column_name.startswith("stimulus_specific_"):
|
|
74
|
+
msg = f"Expected stimulus_specific_* column; got {stable_column_name!r}"
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
|
|
77
|
+
selected_stimtypes = _selected_stimtypes(recording, stimtype)
|
|
78
|
+
mappings: list[StimulusSpecificColumnMapping] = []
|
|
79
|
+
for stimtype_key in selected_stimtypes:
|
|
80
|
+
metadata = recording.stimulus_specific_columns[stimtype_key]
|
|
81
|
+
mappings.extend(
|
|
82
|
+
_mappings_from_metadata(
|
|
83
|
+
stable_column_name=stable_column_name,
|
|
84
|
+
stimtype=stimtype_key,
|
|
85
|
+
metadata=metadata,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
return tuple(mappings)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def stimulus_column_index(recording: RecordingData, column_name: str) -> int:
|
|
92
|
+
"""Return the index for one converted stimulus data column.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
recording: Loaded converted recording.
|
|
96
|
+
column_name: Required column name from ``stimulus/data_column_names``.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Integer column index.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ValueError: If the column is not present.
|
|
103
|
+
|
|
104
|
+
Keeping this lookup in one helper avoids slightly different error handling
|
|
105
|
+
in timing, classification, and script-facing analysis code.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
return recording.stimulus_data_column_names.index(column_name)
|
|
109
|
+
except ValueError as error:
|
|
110
|
+
msg = f"stimulus/data is missing required column {column_name!r}"
|
|
111
|
+
raise ValueError(msg) from error
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def stimulus_epoch_names_by_number(recording: RecordingData) -> dict[int, str]:
|
|
115
|
+
"""Map one-based stimulus epoch numbers to readable epoch names.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
recording: Loaded converted recording with stimulus parameter metadata.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Dictionary mapping epoch number to name.
|
|
122
|
+
|
|
123
|
+
Epoch numbers in ``stimulus/data`` are one-based because they come from the
|
|
124
|
+
stimulus computer. Missing names fall back to ``epoch_N`` at the call site.
|
|
125
|
+
"""
|
|
126
|
+
names: dict[int, str] = {}
|
|
127
|
+
for index, parameters in enumerate(recording.stimulus_parameters, start=1):
|
|
128
|
+
epoch_name = parameters.get("epochName", f"epoch_{index}")
|
|
129
|
+
names[index] = str(epoch_name)
|
|
130
|
+
return names
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _selected_stimtypes(
|
|
134
|
+
recording: RecordingData,
|
|
135
|
+
stimtype: int | str | None,
|
|
136
|
+
) -> tuple[str, ...]:
|
|
137
|
+
"""Choose which ``stimtype`` entries to inspect.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
recording: Loaded converted recording.
|
|
141
|
+
stimtype: Optional requested stimulus type.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Tuple of stimulus type keys present in ``recording``.
|
|
145
|
+
"""
|
|
146
|
+
if stimtype is None:
|
|
147
|
+
return tuple(recording.stimulus_specific_columns)
|
|
148
|
+
|
|
149
|
+
stimtype_key = str(int(stimtype)) if isinstance(stimtype, int) else str(stimtype)
|
|
150
|
+
if stimtype_key not in recording.stimulus_specific_columns:
|
|
151
|
+
msg = f"stimtype {stimtype_key!r} is not present in this recording"
|
|
152
|
+
raise KeyError(msg)
|
|
153
|
+
return (stimtype_key,)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _mappings_from_metadata(
|
|
157
|
+
*,
|
|
158
|
+
stable_column_name: str,
|
|
159
|
+
stimtype: str,
|
|
160
|
+
metadata: dict[str, object],
|
|
161
|
+
) -> tuple[StimulusSpecificColumnMapping, ...]:
|
|
162
|
+
"""Build mappings from one decoded stimulus-function metadata block.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
stable_column_name: Stable HDF5 column name to find.
|
|
166
|
+
stimtype: Stimulus type key for this metadata block.
|
|
167
|
+
metadata: Decoded JSON dictionary for one stimulus function.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Matching column mappings.
|
|
171
|
+
"""
|
|
172
|
+
columns = metadata.get("columns")
|
|
173
|
+
if not isinstance(columns, list):
|
|
174
|
+
msg = f"stimulus metadata for stimtype {stimtype!r} has no column list"
|
|
175
|
+
raise ValueError(msg)
|
|
176
|
+
|
|
177
|
+
mappings: list[StimulusSpecificColumnMapping] = []
|
|
178
|
+
for column in columns:
|
|
179
|
+
if not isinstance(column, dict):
|
|
180
|
+
msg = f"stimulus metadata for stimtype {stimtype!r} is malformed"
|
|
181
|
+
raise ValueError(msg)
|
|
182
|
+
column_metadata = cast(dict[str, object], column)
|
|
183
|
+
if column_metadata.get("column_name") != stable_column_name:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
source_comment = _optional_text(column_metadata.get("source_comment"))
|
|
187
|
+
source_expression = str(column_metadata["source_expression"])
|
|
188
|
+
source_line = _required_int(column_metadata["source_line"])
|
|
189
|
+
mappings.append(
|
|
190
|
+
StimulusSpecificColumnMapping(
|
|
191
|
+
stable_column_name=stable_column_name,
|
|
192
|
+
stimtype=stimtype,
|
|
193
|
+
specific_name=source_comment or source_expression,
|
|
194
|
+
source_expression=source_expression,
|
|
195
|
+
source_comment=source_comment,
|
|
196
|
+
source_line=source_line,
|
|
197
|
+
source_path=str(metadata["source_path"]),
|
|
198
|
+
stimulus_function=str(metadata["function"]),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
return tuple(mappings)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _optional_text(value: object) -> str | None:
|
|
205
|
+
"""Return optional metadata text as ``str | None``.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
value: Raw decoded JSON value.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Text when present, otherwise ``None``.
|
|
212
|
+
"""
|
|
213
|
+
if value is None:
|
|
214
|
+
return None
|
|
215
|
+
return str(value)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _required_int(value: object) -> int:
|
|
219
|
+
"""Return a required JSON metadata integer.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
value: Raw decoded JSON value.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Integer value.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If the value cannot honestly be read as an integer.
|
|
229
|
+
"""
|
|
230
|
+
if isinstance(value, bool) or not isinstance(value, int | str):
|
|
231
|
+
msg = f"Expected integer metadata value; got {value!r}"
|
|
232
|
+
raise ValueError(msg)
|
|
233
|
+
return int(value)
|
twopy/synchronization.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Detect photodiode events and map them to imaging frames.
|
|
2
|
+
|
|
3
|
+
Inputs: converted photodiode signals from ``RecordingData``.
|
|
4
|
+
Outputs: photodiode event segments and high-resolution events paired with
|
|
5
|
+
frame-resolution imaging events.
|
|
6
|
+
|
|
7
|
+
The stimulus and imaging computers have separate clocks. This module uses the
|
|
8
|
+
photodiode signals as the shared evidence for alignment instead of relying on
|
|
9
|
+
nominal frame rates.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import numpy.typing as npt
|
|
16
|
+
|
|
17
|
+
from twopy.converted import RecordingData
|
|
18
|
+
from twopy.photodiode import (
|
|
19
|
+
DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
|
|
20
|
+
PhotodiodeEvent,
|
|
21
|
+
PhotodiodePolarity,
|
|
22
|
+
detect_photodiode_event_segments,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AlignedPhotodiodeEvent",
|
|
27
|
+
"PhotodiodeAlignment",
|
|
28
|
+
"PhotodiodeEvent",
|
|
29
|
+
"PhotodiodeEventSet",
|
|
30
|
+
"PhotodiodePolarity",
|
|
31
|
+
"detect_photodiode_events",
|
|
32
|
+
"detect_recording_photodiode_events",
|
|
33
|
+
"pair_photodiode_events_to_imaging_frames",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
PhotodiodeSignalName = Literal["high_res_pd", "imaging_res_pd"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class PhotodiodeEventSet:
|
|
41
|
+
"""Detected events for one photodiode signal.
|
|
42
|
+
|
|
43
|
+
Inputs: signal name, thresholding options, and detected event segments.
|
|
44
|
+
Outputs: auditable event list plus the threshold that produced it.
|
|
45
|
+
|
|
46
|
+
Keeping the threshold with the events makes later trial alignment easier to
|
|
47
|
+
inspect and reproduce.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
signal_name: PhotodiodeSignalName
|
|
51
|
+
events: tuple[PhotodiodeEvent, ...]
|
|
52
|
+
threshold: float
|
|
53
|
+
polarity: PhotodiodePolarity
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AlignedPhotodiodeEvent:
|
|
58
|
+
"""One high-resolution event paired with one imaging-frame event.
|
|
59
|
+
|
|
60
|
+
Inputs: detected high-resolution and imaging-resolution photodiode events.
|
|
61
|
+
Outputs: the imaging frame assigned to the high-resolution event.
|
|
62
|
+
|
|
63
|
+
The imaging frame is the first frame-resolution sample where the paired
|
|
64
|
+
photodiode event is detected. This is the conservative frame boundary used
|
|
65
|
+
before stimulus-specific event classification exists.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
high_res_event: PhotodiodeEvent
|
|
69
|
+
imaging_res_event: PhotodiodeEvent
|
|
70
|
+
imaging_frame: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class PhotodiodeAlignment:
|
|
75
|
+
"""Detected and paired photodiode events for one recording.
|
|
76
|
+
|
|
77
|
+
Inputs: converted high-resolution and imaging-resolution photodiode signals.
|
|
78
|
+
Outputs: event sets plus ordered high-res-to-frame pairings.
|
|
79
|
+
|
|
80
|
+
This is the first timing object response analysis should consume. It still
|
|
81
|
+
does not classify stimulus-specific flash patterns.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
high_res: PhotodiodeEventSet
|
|
85
|
+
imaging_res: PhotodiodeEventSet
|
|
86
|
+
paired_events: tuple[AlignedPhotodiodeEvent, ...]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def detect_photodiode_events(
|
|
90
|
+
signal: npt.ArrayLike,
|
|
91
|
+
*,
|
|
92
|
+
signal_name: PhotodiodeSignalName = "high_res_pd",
|
|
93
|
+
threshold: float | None = None,
|
|
94
|
+
threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
|
|
95
|
+
polarity: PhotodiodePolarity = "above",
|
|
96
|
+
minimum_width_samples: int = 1,
|
|
97
|
+
merge_gap_samples: int = 0,
|
|
98
|
+
) -> PhotodiodeEventSet:
|
|
99
|
+
"""Detect contiguous photodiode events in one signal.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
signal: One-dimensional photodiode samples.
|
|
103
|
+
signal_name: Name of the converted photodiode signal being decoded.
|
|
104
|
+
threshold: Optional threshold. When omitted, twopy uses
|
|
105
|
+
``min + threshold_fraction * range``.
|
|
106
|
+
threshold_fraction: Signal-range fraction used when ``threshold`` is
|
|
107
|
+
omitted. Defaults to the lab analysis convention, 0.3.
|
|
108
|
+
polarity: ``above`` detects samples greater than threshold; ``below``
|
|
109
|
+
detects samples less than threshold.
|
|
110
|
+
minimum_width_samples: Drop events shorter than this width.
|
|
111
|
+
merge_gap_samples: Merge neighboring events separated by this many
|
|
112
|
+
samples or fewer.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Event set with the requested signal name.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If the signal is not one-dimensional or widths are invalid.
|
|
119
|
+
|
|
120
|
+
This function segments flashes. It does not classify stimulus-specific flash
|
|
121
|
+
patterns because that requires stimulus protocol rules.
|
|
122
|
+
"""
|
|
123
|
+
events, resolved_threshold = detect_photodiode_event_segments(
|
|
124
|
+
signal=signal,
|
|
125
|
+
threshold=threshold,
|
|
126
|
+
threshold_fraction=threshold_fraction,
|
|
127
|
+
polarity=polarity,
|
|
128
|
+
minimum_width_samples=minimum_width_samples,
|
|
129
|
+
merge_gap_samples=merge_gap_samples,
|
|
130
|
+
)
|
|
131
|
+
return PhotodiodeEventSet(
|
|
132
|
+
signal_name=signal_name,
|
|
133
|
+
events=events,
|
|
134
|
+
threshold=resolved_threshold,
|
|
135
|
+
polarity=polarity,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def detect_recording_photodiode_events(
|
|
140
|
+
recording: RecordingData,
|
|
141
|
+
*,
|
|
142
|
+
high_res_threshold: float | None = None,
|
|
143
|
+
imaging_res_threshold: float | None = None,
|
|
144
|
+
threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
|
|
145
|
+
polarity: PhotodiodePolarity = "above",
|
|
146
|
+
minimum_width_samples: int = 1,
|
|
147
|
+
merge_gap_samples: int = 0,
|
|
148
|
+
) -> PhotodiodeAlignment:
|
|
149
|
+
"""Detect and pair photodiode events for one converted recording.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
recording: Loaded converted recording.
|
|
153
|
+
high_res_threshold: Optional threshold for ``high_res_pd``.
|
|
154
|
+
imaging_res_threshold: Optional threshold for ``imaging_res_pd``.
|
|
155
|
+
threshold_fraction: Signal-range fraction used for either signal when
|
|
156
|
+
its explicit threshold is omitted.
|
|
157
|
+
polarity: Which side of threshold marks a photodiode flash.
|
|
158
|
+
minimum_width_samples: Drop events shorter than this width in each
|
|
159
|
+
signal.
|
|
160
|
+
merge_gap_samples: Merge neighboring events separated by this many
|
|
161
|
+
samples or fewer in each signal.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
``PhotodiodeAlignment`` with high-resolution events paired to imaging
|
|
165
|
+
frames by event order.
|
|
166
|
+
|
|
167
|
+
Pairing by order uses the photodiode evidence in both signals. It avoids
|
|
168
|
+
assuming the two computers share a frame rate or clock.
|
|
169
|
+
"""
|
|
170
|
+
high_res = detect_photodiode_events(
|
|
171
|
+
recording.high_res_pd,
|
|
172
|
+
signal_name="high_res_pd",
|
|
173
|
+
threshold=high_res_threshold,
|
|
174
|
+
threshold_fraction=threshold_fraction,
|
|
175
|
+
polarity=polarity,
|
|
176
|
+
minimum_width_samples=minimum_width_samples,
|
|
177
|
+
merge_gap_samples=merge_gap_samples,
|
|
178
|
+
)
|
|
179
|
+
imaging_res = detect_photodiode_events(
|
|
180
|
+
recording.imaging_res_pd,
|
|
181
|
+
signal_name="imaging_res_pd",
|
|
182
|
+
threshold=imaging_res_threshold,
|
|
183
|
+
threshold_fraction=threshold_fraction,
|
|
184
|
+
polarity=polarity,
|
|
185
|
+
minimum_width_samples=minimum_width_samples,
|
|
186
|
+
merge_gap_samples=merge_gap_samples,
|
|
187
|
+
)
|
|
188
|
+
return PhotodiodeAlignment(
|
|
189
|
+
high_res=high_res,
|
|
190
|
+
imaging_res=imaging_res,
|
|
191
|
+
paired_events=pair_photodiode_events_to_imaging_frames(
|
|
192
|
+
high_res.events,
|
|
193
|
+
imaging_res.events,
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def pair_photodiode_events_to_imaging_frames(
|
|
199
|
+
high_res_events: tuple[PhotodiodeEvent, ...],
|
|
200
|
+
imaging_res_events: tuple[PhotodiodeEvent, ...],
|
|
201
|
+
) -> tuple[AlignedPhotodiodeEvent, ...]:
|
|
202
|
+
"""Pair high-resolution photodiode events to imaging-frame events.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
high_res_events: Events detected from ``high_res_pd``.
|
|
206
|
+
imaging_res_events: Events detected from ``imaging_res_pd``.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Ordered event pairs with assigned imaging-frame indices.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ValueError: If event counts differ.
|
|
213
|
+
|
|
214
|
+
Matching counts are required for this first implementation because silently
|
|
215
|
+
dropping or inventing event pairings would corrupt trial alignment.
|
|
216
|
+
"""
|
|
217
|
+
if len(high_res_events) != len(imaging_res_events):
|
|
218
|
+
msg = (
|
|
219
|
+
"Cannot pair photodiode events with different counts: "
|
|
220
|
+
f"high_res={len(high_res_events)}, imaging_res={len(imaging_res_events)}"
|
|
221
|
+
)
|
|
222
|
+
raise ValueError(msg)
|
|
223
|
+
|
|
224
|
+
return tuple(
|
|
225
|
+
AlignedPhotodiodeEvent(
|
|
226
|
+
high_res_event=high_res_event,
|
|
227
|
+
imaging_res_event=imaging_res_event,
|
|
228
|
+
imaging_frame=imaging_res_event.start_sample,
|
|
229
|
+
)
|
|
230
|
+
for high_res_event, imaging_res_event in zip(
|
|
231
|
+
high_res_events,
|
|
232
|
+
imaging_res_events,
|
|
233
|
+
strict=True,
|
|
234
|
+
)
|
|
235
|
+
)
|