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/config.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Load twopy configuration from a small YAML file.
|
|
2
|
+
|
|
3
|
+
Inputs: a YAML config file with lab paths and database access mode.
|
|
4
|
+
Outputs: a typed ``TwopyConfig`` object with normalized values.
|
|
5
|
+
|
|
6
|
+
The config layer keeps machine-specific paths out of analysis code and makes the
|
|
7
|
+
GUI easy to point at the right database and recording roots.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal, cast
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from twopy.database.types import DatabaseAccess
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_CONFIG_PATH",
|
|
20
|
+
"AnalysisOutputMode",
|
|
21
|
+
"TwopyConfig",
|
|
22
|
+
"load_config",
|
|
23
|
+
"resolve_analysis_output_dir",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
DEFAULT_CONFIG_PATH = Path("config.yml")
|
|
27
|
+
AnalysisOutputMode = Path | Literal["source"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TwopyConfig:
|
|
32
|
+
"""Machine-local paths used by twopy.
|
|
33
|
+
|
|
34
|
+
Inputs: lab paths, DB access mode, and analysis output from ``config.yml``.
|
|
35
|
+
Outputs: immutable config values that GUI and analysis code can pass around.
|
|
36
|
+
|
|
37
|
+
The paths are not required to exist during parsing so tests and machines
|
|
38
|
+
without mounted lab drives can still load and inspect configuration.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
database_path: Path
|
|
42
|
+
data_path: Path
|
|
43
|
+
database_access: DatabaseAccess
|
|
44
|
+
analysis_output: AnalysisOutputMode
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> TwopyConfig:
|
|
48
|
+
"""Load twopy configuration from YAML.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path: YAML config file path. Defaults to ``config.yml`` in the current
|
|
52
|
+
working directory.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A typed configuration object with paths, DB access, and output mode.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
FileNotFoundError: If the config file is absent.
|
|
59
|
+
ValueError: If the YAML is not a mapping or required keys are missing.
|
|
60
|
+
|
|
61
|
+
YAML is used because the file is edited by people and benefits from comments.
|
|
62
|
+
"""
|
|
63
|
+
config_path = path.expanduser()
|
|
64
|
+
if not config_path.is_file():
|
|
65
|
+
msg = f"Missing twopy config file: {config_path}"
|
|
66
|
+
raise FileNotFoundError(msg)
|
|
67
|
+
|
|
68
|
+
with config_path.open("r", encoding="utf-8") as config_file:
|
|
69
|
+
loaded: object = yaml.safe_load(config_file)
|
|
70
|
+
|
|
71
|
+
if not isinstance(loaded, dict):
|
|
72
|
+
msg = f"twopy config must be a YAML mapping: {config_path}"
|
|
73
|
+
raise ValueError(msg)
|
|
74
|
+
|
|
75
|
+
raw_config = cast(dict[object, object], loaded)
|
|
76
|
+
|
|
77
|
+
return TwopyConfig(
|
|
78
|
+
database_path=_required_path(raw_config, "database_path", config_path),
|
|
79
|
+
data_path=_required_path(raw_config, "data_path", config_path),
|
|
80
|
+
database_access=_database_access(raw_config, config_path),
|
|
81
|
+
analysis_output=_analysis_output(raw_config, config_path),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_analysis_output_dir(config: TwopyConfig, recording_dir: Path) -> Path:
|
|
86
|
+
"""Resolve the output directory for one recording.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
config: Loaded twopy configuration.
|
|
90
|
+
recording_dir: Recording folder being analyzed.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Directory where twopy should write analysis output for the recording.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: If ``analysis_output`` is a root path and the recording is
|
|
97
|
+
not inside ``config.data_path``.
|
|
98
|
+
|
|
99
|
+
``analysis_output: source`` writes into ``recording_dir/twopy``. A path
|
|
100
|
+
value mirrors the recording's path relative to ``data_path`` under that
|
|
101
|
+
output root.
|
|
102
|
+
"""
|
|
103
|
+
source_dir = recording_dir.expanduser()
|
|
104
|
+
if config.analysis_output == "source":
|
|
105
|
+
return source_dir / "twopy"
|
|
106
|
+
|
|
107
|
+
data_root = config.data_path.expanduser()
|
|
108
|
+
try:
|
|
109
|
+
relative_recording = source_dir.relative_to(data_root)
|
|
110
|
+
except ValueError as error:
|
|
111
|
+
msg = (
|
|
112
|
+
f"Recording {source_dir} is not inside configured data_path "
|
|
113
|
+
f"{data_root}; cannot mirror analysis output directory"
|
|
114
|
+
)
|
|
115
|
+
raise ValueError(msg) from error
|
|
116
|
+
|
|
117
|
+
return config.analysis_output / relative_recording
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _required_path(config: dict[object, object], key: str, config_path: Path) -> Path:
|
|
121
|
+
"""Read one required path value from parsed YAML.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config: Parsed YAML mapping.
|
|
125
|
+
key: Required key to read.
|
|
126
|
+
config_path: Source config file path, used for clear errors.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Expanded ``Path`` value for the requested key.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If the key is missing or is not a string.
|
|
133
|
+
|
|
134
|
+
This helper keeps config validation explicit and avoids hidden defaults.
|
|
135
|
+
"""
|
|
136
|
+
value = config.get(key)
|
|
137
|
+
if not isinstance(value, str) or value == "":
|
|
138
|
+
msg = f"twopy config key {key!r} must be a non-empty string: {config_path}"
|
|
139
|
+
raise ValueError(msg)
|
|
140
|
+
|
|
141
|
+
return Path(value).expanduser()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _database_access(
|
|
145
|
+
config: dict[object, object],
|
|
146
|
+
config_path: Path,
|
|
147
|
+
) -> DatabaseAccess:
|
|
148
|
+
"""Read the optional database access mode from parsed YAML.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
config: Parsed YAML mapping.
|
|
152
|
+
config_path: Source config file path, used for clear errors.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
``copy`` or ``direct``. Missing values default to ``copy``.
|
|
156
|
+
|
|
157
|
+
The default favors local DB copies because database queries over the network
|
|
158
|
+
can be slow, but transferring the DB file locally is usually fast. twopy
|
|
159
|
+
verifies whether a copy is stale before copying again.
|
|
160
|
+
"""
|
|
161
|
+
value = config.get("database_access", "copy")
|
|
162
|
+
if value in {"copy", "direct"}:
|
|
163
|
+
return cast(DatabaseAccess, value)
|
|
164
|
+
|
|
165
|
+
msg = (
|
|
166
|
+
f"twopy config key 'database_access' must be 'copy' or 'direct': {config_path}"
|
|
167
|
+
)
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _analysis_output(
|
|
172
|
+
config: dict[object, object],
|
|
173
|
+
config_path: Path,
|
|
174
|
+
) -> AnalysisOutputMode:
|
|
175
|
+
"""Read the required analysis output setting from parsed YAML.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
config: Parsed YAML mapping.
|
|
179
|
+
config_path: Source config file path, used for clear errors.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
``source`` or an expanded output root path.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ValueError: If the key is absent or not a non-empty string.
|
|
186
|
+
|
|
187
|
+
Keeping this explicit avoids accidental writes to unclear locations.
|
|
188
|
+
"""
|
|
189
|
+
value = config.get("analysis_output")
|
|
190
|
+
if not isinstance(value, str) or value == "":
|
|
191
|
+
msg = (
|
|
192
|
+
"twopy config key 'analysis_output' must be 'source' or a "
|
|
193
|
+
f"non-empty path string: {config_path}"
|
|
194
|
+
)
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
|
|
197
|
+
if value == "source":
|
|
198
|
+
return "source"
|
|
199
|
+
|
|
200
|
+
return Path(value).expanduser()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Convert microscope source files into twopy-owned HDF5 files.
|
|
2
|
+
|
|
3
|
+
Inputs: one validated two-photon recording session directory.
|
|
4
|
+
Outputs: HDF5 files containing twopy-owned datasets for later analysis.
|
|
5
|
+
|
|
6
|
+
This package keeps the public conversion API small while separating source
|
|
7
|
+
loading, HDF5 writing, and data-object definitions into focused modules.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from twopy.conversion.api import convert_recording_to_twopy
|
|
11
|
+
from twopy.conversion.source_loading import load_source_conversion_inputs
|
|
12
|
+
from twopy.conversion.types import (
|
|
13
|
+
AcquisitionMetadata,
|
|
14
|
+
AlignedMovieSource,
|
|
15
|
+
AlignmentCropAudit,
|
|
16
|
+
ConvertedRecording,
|
|
17
|
+
FrameCountAudit,
|
|
18
|
+
PhotodiodeSignals,
|
|
19
|
+
RunMetadata,
|
|
20
|
+
SourceConversionInputs,
|
|
21
|
+
StimulusCodeMetadata,
|
|
22
|
+
StimulusData,
|
|
23
|
+
StimulusParameters,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AcquisitionMetadata",
|
|
28
|
+
"AlignmentCropAudit",
|
|
29
|
+
"AlignedMovieSource",
|
|
30
|
+
"ConvertedRecording",
|
|
31
|
+
"FrameCountAudit",
|
|
32
|
+
"PhotodiodeSignals",
|
|
33
|
+
"RunMetadata",
|
|
34
|
+
"SourceConversionInputs",
|
|
35
|
+
"StimulusCodeMetadata",
|
|
36
|
+
"StimulusParameters",
|
|
37
|
+
"StimulusData",
|
|
38
|
+
"convert_recording_to_twopy",
|
|
39
|
+
"load_source_conversion_inputs",
|
|
40
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Compute alignment-valid spatial crops during source conversion.
|
|
2
|
+
|
|
3
|
+
Inputs: alignment text offsets, acquisition metadata, movie shape, and
|
|
4
|
+
photodiode signals.
|
|
5
|
+
Outputs: a typed crop audit object saved into converted HDF5 metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import numpy.typing as npt
|
|
12
|
+
|
|
13
|
+
from twopy.conversion.types import (
|
|
14
|
+
AcquisitionMetadata,
|
|
15
|
+
AlignedMovieSource,
|
|
16
|
+
AlignmentCropAudit,
|
|
17
|
+
PhotodiodeSignals,
|
|
18
|
+
)
|
|
19
|
+
from twopy.photodiode import stimulus_frame_bounds_from_photodiode
|
|
20
|
+
from twopy.spatial import SpatialCrop
|
|
21
|
+
|
|
22
|
+
__all__ = ["load_alignment_valid_crop"]
|
|
23
|
+
|
|
24
|
+
ONE_X_PIXELS_PER_MICRON_AT_256_X = 0.4
|
|
25
|
+
DEFAULT_MOTION_THRESHOLD_PERCENT = 150.0
|
|
26
|
+
DEFAULT_PLANE_MOTION_LIMIT_MICRONS = 5.0
|
|
27
|
+
DEFAULT_LINESCAN_MOTION_LIMIT_MICRONS = 15.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_alignment_valid_crop(
|
|
31
|
+
*,
|
|
32
|
+
alignment_path: Path,
|
|
33
|
+
aligned_movie: AlignedMovieSource,
|
|
34
|
+
acquisition: AcquisitionMetadata,
|
|
35
|
+
photodiode: PhotodiodeSignals,
|
|
36
|
+
) -> AlignmentCropAudit:
|
|
37
|
+
"""Compute the alignment-valid crop used by crop-domain analysis.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
alignment_path: Text file with per-frame alignment offsets.
|
|
41
|
+
aligned_movie: Aligned movie source metadata.
|
|
42
|
+
acquisition: Acquisition metadata with zoom and frame-rate fields.
|
|
43
|
+
photodiode: Photodiode vectors used to find stimulus-bounded frames.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Crop bounds plus audit values.
|
|
47
|
+
|
|
48
|
+
The source analysis path estimates this crop from alignment rows inside the
|
|
49
|
+
stimulus presentation window, then applies background correction inside that
|
|
50
|
+
crop. twopy saves the same contract while keeping the converted movie
|
|
51
|
+
full-frame.
|
|
52
|
+
"""
|
|
53
|
+
alignment = np.asarray(np.loadtxt(alignment_path), dtype=np.float64)
|
|
54
|
+
if alignment.ndim != 2 or alignment.shape[1] < 2:
|
|
55
|
+
msg = f"Alignment text must be a 2D table with x/y offsets: {alignment_path}"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
|
|
58
|
+
frame_start, frame_stop = _stimulus_alignment_frame_range(
|
|
59
|
+
aligned_movie=aligned_movie,
|
|
60
|
+
acquisition=acquisition,
|
|
61
|
+
photodiode=photodiode,
|
|
62
|
+
)
|
|
63
|
+
if frame_stop > alignment.shape[0]:
|
|
64
|
+
msg = (
|
|
65
|
+
"Stimulus-bounded alignment range exceeds alignment rows: "
|
|
66
|
+
f"[{frame_start}, {frame_stop}) for {alignment.shape[0]} rows"
|
|
67
|
+
)
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
|
|
70
|
+
alignment_window = alignment[frame_start:frame_stop, :]
|
|
71
|
+
line_scan = _is_line_scan(acquisition)
|
|
72
|
+
motion_threshold_pixels = _motion_threshold_pixels(
|
|
73
|
+
acquisition=acquisition,
|
|
74
|
+
spatial_shape=_movie_spatial_shape(aligned_movie),
|
|
75
|
+
line_scan=line_scan,
|
|
76
|
+
)
|
|
77
|
+
alignment_shift_pixels = _alignment_shift_pixels(
|
|
78
|
+
alignment[: aligned_movie.shape[0], :],
|
|
79
|
+
line_scan=line_scan,
|
|
80
|
+
)
|
|
81
|
+
motion_artifact_mask = alignment_shift_pixels > motion_threshold_pixels
|
|
82
|
+
kept_alignment, over_moved_count = _remove_over_moved_alignment_rows(
|
|
83
|
+
alignment_window,
|
|
84
|
+
line_scan=line_scan,
|
|
85
|
+
motion_threshold_pixels=motion_threshold_pixels,
|
|
86
|
+
)
|
|
87
|
+
crop, x_cutoff, y_cutoff = _alignment_crop_from_offsets(
|
|
88
|
+
kept_alignment,
|
|
89
|
+
spatial_shape=_movie_spatial_shape(aligned_movie),
|
|
90
|
+
line_scan=line_scan,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return AlignmentCropAudit(
|
|
94
|
+
crop=crop,
|
|
95
|
+
alignment_frame_start=frame_start,
|
|
96
|
+
alignment_frame_stop=frame_stop,
|
|
97
|
+
x_cutoff_pixels=x_cutoff,
|
|
98
|
+
y_cutoff_pixels=y_cutoff,
|
|
99
|
+
over_moved_frame_count=over_moved_count,
|
|
100
|
+
motion_threshold_pixels=motion_threshold_pixels,
|
|
101
|
+
alignment_shift_pixels=alignment_shift_pixels,
|
|
102
|
+
motion_artifact_mask=motion_artifact_mask,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _stimulus_alignment_frame_range(
|
|
107
|
+
*,
|
|
108
|
+
aligned_movie: AlignedMovieSource,
|
|
109
|
+
acquisition: AcquisitionMetadata,
|
|
110
|
+
photodiode: PhotodiodeSignals,
|
|
111
|
+
) -> tuple[int, int]:
|
|
112
|
+
"""Find the alignment rows that cover stimulus presentation.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
aligned_movie: Movie metadata that gives frame count.
|
|
116
|
+
acquisition: Acquisition metadata with frame rate and line-scan flag.
|
|
117
|
+
photodiode: High-resolution photodiode signal.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Half-open frame range used for the alignment-valid crop.
|
|
121
|
+
"""
|
|
122
|
+
if _is_line_scan(acquisition):
|
|
123
|
+
return (0, aligned_movie.shape[0])
|
|
124
|
+
|
|
125
|
+
return stimulus_frame_bounds_from_photodiode(
|
|
126
|
+
photodiode.high_res_pd,
|
|
127
|
+
movie_frame_count=aligned_movie.shape[0],
|
|
128
|
+
frame_rate_hz=_required_float_field(acquisition, "acq.frameRate"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _motion_threshold_pixels(
|
|
133
|
+
*,
|
|
134
|
+
acquisition: AcquisitionMetadata,
|
|
135
|
+
spatial_shape: tuple[int, int],
|
|
136
|
+
line_scan: bool,
|
|
137
|
+
) -> float:
|
|
138
|
+
"""Convert the motion rejection threshold from microns to pixels.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
acquisition: Acquisition metadata with zoom.
|
|
142
|
+
spatial_shape: Full movie spatial shape in twopy array axes.
|
|
143
|
+
line_scan: Whether this recording is a line scan.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Maximum allowed alignment movement in pixels.
|
|
147
|
+
"""
|
|
148
|
+
micron_limit = (
|
|
149
|
+
DEFAULT_LINESCAN_MOTION_LIMIT_MICRONS
|
|
150
|
+
if line_scan
|
|
151
|
+
else DEFAULT_PLANE_MOTION_LIMIT_MICRONS
|
|
152
|
+
)
|
|
153
|
+
zoom = _required_float_field(acquisition, "acq.zoomFactor")
|
|
154
|
+
x_axis_conversion = 256.0 / float(spatial_shape[0])
|
|
155
|
+
return micron_limit * ONE_X_PIXELS_PER_MICRON_AT_256_X * zoom * x_axis_conversion
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _remove_over_moved_alignment_rows(
|
|
159
|
+
alignment: npt.NDArray[np.float64],
|
|
160
|
+
*,
|
|
161
|
+
line_scan: bool,
|
|
162
|
+
motion_threshold_pixels: float,
|
|
163
|
+
) -> tuple[npt.NDArray[np.float64], int]:
|
|
164
|
+
"""Drop alignment rows with motion beyond the allowed threshold.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
alignment: Stimulus-bounded alignment table.
|
|
168
|
+
line_scan: Whether this recording is a line scan.
|
|
169
|
+
motion_threshold_pixels: Maximum allowed movement in pixels.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Filtered alignment rows and the number of dropped rows.
|
|
173
|
+
"""
|
|
174
|
+
full_shift = _alignment_shift_pixels(alignment, line_scan=line_scan)
|
|
175
|
+
over_moved = full_shift > motion_threshold_pixels
|
|
176
|
+
over_moved_percent = (
|
|
177
|
+
100.0 * float(np.count_nonzero(over_moved)) / alignment.shape[0]
|
|
178
|
+
)
|
|
179
|
+
if over_moved_percent > DEFAULT_MOTION_THRESHOLD_PERCENT:
|
|
180
|
+
return alignment, 0
|
|
181
|
+
|
|
182
|
+
kept = alignment[~over_moved, :]
|
|
183
|
+
if kept.size == 0:
|
|
184
|
+
msg = (
|
|
185
|
+
"Alignment-valid crop cannot be computed; all alignment rows moved too far"
|
|
186
|
+
)
|
|
187
|
+
raise ValueError(msg)
|
|
188
|
+
return kept, int(np.count_nonzero(over_moved))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _alignment_shift_pixels(
|
|
192
|
+
alignment: npt.NDArray[np.float64],
|
|
193
|
+
*,
|
|
194
|
+
line_scan: bool,
|
|
195
|
+
) -> npt.NDArray[np.float64]:
|
|
196
|
+
"""Return per-frame alignment shift magnitudes in pixels.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
alignment: Alignment table with x/y offset columns.
|
|
200
|
+
line_scan: Whether this recording is a line scan.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
One shift magnitude per alignment row.
|
|
204
|
+
"""
|
|
205
|
+
if line_scan:
|
|
206
|
+
return np.abs(alignment[:, 0])
|
|
207
|
+
return np.sqrt(alignment[:, 0] ** 2 + alignment[:, 1] ** 2)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _alignment_crop_from_offsets(
|
|
211
|
+
alignment: npt.NDArray[np.float64],
|
|
212
|
+
*,
|
|
213
|
+
spatial_shape: tuple[int, int],
|
|
214
|
+
line_scan: bool,
|
|
215
|
+
) -> tuple[SpatialCrop, int, int]:
|
|
216
|
+
"""Convert alignment offsets into twopy movie-axis crop bounds.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
alignment: Filtered alignment table.
|
|
220
|
+
spatial_shape: Full movie spatial shape in twopy array axes.
|
|
221
|
+
line_scan: Whether this recording is a line scan.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Spatial crop plus x/y cutoff values.
|
|
225
|
+
|
|
226
|
+
Source alignment columns are x/y motion offsets. twopy arrays store x along
|
|
227
|
+
spatial axis 0 and y along spatial axis 1, so the crop is mapped directly to
|
|
228
|
+
those axes.
|
|
229
|
+
"""
|
|
230
|
+
x_cutoff = _ceil_abs_offset(alignment[:, 0])
|
|
231
|
+
y_cutoff = 1 if line_scan else _ceil_abs_offset(alignment[:, 1])
|
|
232
|
+
axis0_start, axis0_stop = _crop_bounds_from_cutoff(
|
|
233
|
+
length=spatial_shape[0],
|
|
234
|
+
cutoff=x_cutoff,
|
|
235
|
+
)
|
|
236
|
+
axis1_start, axis1_stop = _crop_bounds_from_cutoff(
|
|
237
|
+
length=spatial_shape[1],
|
|
238
|
+
cutoff=y_cutoff,
|
|
239
|
+
)
|
|
240
|
+
return (
|
|
241
|
+
SpatialCrop(
|
|
242
|
+
axis0_start=axis0_start,
|
|
243
|
+
axis0_stop=axis0_stop,
|
|
244
|
+
axis1_start=axis1_start,
|
|
245
|
+
axis1_stop=axis1_stop,
|
|
246
|
+
original_shape=spatial_shape,
|
|
247
|
+
source="alignment_valid_crop",
|
|
248
|
+
),
|
|
249
|
+
x_cutoff,
|
|
250
|
+
y_cutoff,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _ceil_abs_offset(values: npt.NDArray[np.float64]) -> int:
|
|
255
|
+
"""Return the ceiling of the largest absolute alignment offset.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
values: One alignment offset column.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Nonnegative integer cutoff.
|
|
262
|
+
"""
|
|
263
|
+
return int(np.ceil(float(np.nanmax(np.abs(values)))))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _crop_bounds_from_cutoff(*, length: int, cutoff: int) -> tuple[int, int]:
|
|
267
|
+
"""Convert a source cutoff value into Python half-open bounds.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
length: Axis length.
|
|
271
|
+
cutoff: One-based cutoff convention from the source crop code.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
``(start, stop)`` bounds.
|
|
275
|
+
"""
|
|
276
|
+
if cutoff <= 1:
|
|
277
|
+
return (0, length)
|
|
278
|
+
start = cutoff - 1
|
|
279
|
+
stop = length - cutoff + 1
|
|
280
|
+
if start >= stop:
|
|
281
|
+
msg = f"Alignment cutoff {cutoff} leaves no valid pixels for length {length}"
|
|
282
|
+
raise ValueError(msg)
|
|
283
|
+
return start, stop
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _movie_spatial_shape(aligned_movie: AlignedMovieSource) -> tuple[int, int]:
|
|
287
|
+
"""Return the two spatial dimensions of the aligned source movie.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
aligned_movie: Source movie metadata.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
``(axis0, axis1)`` spatial shape.
|
|
294
|
+
"""
|
|
295
|
+
if len(aligned_movie.shape) != 3:
|
|
296
|
+
msg = f"Aligned movie must be 3D; got {aligned_movie.shape}"
|
|
297
|
+
raise ValueError(msg)
|
|
298
|
+
return (aligned_movie.shape[1], aligned_movie.shape[2])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _required_float_field(acquisition: AcquisitionMetadata, key: str) -> float:
|
|
302
|
+
"""Read one required numeric acquisition metadata field.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
acquisition: Loaded acquisition metadata.
|
|
306
|
+
key: Field name to read.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Field value as a float.
|
|
310
|
+
"""
|
|
311
|
+
value = acquisition.fields[key]
|
|
312
|
+
if isinstance(value, str | bytes | int | float | np.generic):
|
|
313
|
+
return float(value)
|
|
314
|
+
|
|
315
|
+
value_type = type(value).__name__
|
|
316
|
+
msg = f"Acquisition field {key!r} must be numeric, got {value_type}"
|
|
317
|
+
raise ValueError(msg)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _is_line_scan(acquisition: AcquisitionMetadata) -> bool:
|
|
321
|
+
"""Return whether acquisition metadata describes a line scan.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
acquisition: Loaded acquisition metadata.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
``True`` for line-scan recordings.
|
|
328
|
+
"""
|
|
329
|
+
return _required_float_field(acquisition, "acq.scanAngleMultiplierSlow") == 0.0
|
twopy/conversion/api.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Public conversion workflow from microscope source files to twopy files.
|
|
2
|
+
|
|
3
|
+
Inputs: one source recording folder plus one output folder.
|
|
4
|
+
Outputs: a ``ConvertedRecording`` pointing at twopy-owned HDF5 files.
|
|
5
|
+
|
|
6
|
+
The workflow reads MATLAB/TIFF-derived source data only during conversion.
|
|
7
|
+
Analysis code should operate on the converted HDF5 files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from twopy.config import DEFAULT_CONFIG_PATH, load_config, resolve_analysis_output_dir
|
|
13
|
+
from twopy.conversion.frame_ranges import normalize_frame_range
|
|
14
|
+
from twopy.conversion.hdf5_writing import (
|
|
15
|
+
CONVERTED_ALIGNED_MOVIE_FILENAME,
|
|
16
|
+
CONVERTED_RECORDING_FILENAME,
|
|
17
|
+
write_aligned_movie_file,
|
|
18
|
+
write_recording_data_file,
|
|
19
|
+
)
|
|
20
|
+
from twopy.conversion.source_loading import load_source_conversion_inputs
|
|
21
|
+
from twopy.conversion.types import ConvertedRecording
|
|
22
|
+
|
|
23
|
+
__all__ = ["convert_recording_to_twopy"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def convert_recording_to_twopy(
|
|
27
|
+
session_dir: Path,
|
|
28
|
+
output_dir: Path | None = None,
|
|
29
|
+
*,
|
|
30
|
+
mean_start_frame: int | None = None,
|
|
31
|
+
mean_stop_frame: int | None = None,
|
|
32
|
+
config_path: Path = DEFAULT_CONFIG_PATH,
|
|
33
|
+
) -> ConvertedRecording:
|
|
34
|
+
"""Convert one recording folder into twopy HDF5 files.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
session_dir: Source recording folder.
|
|
38
|
+
output_dir: Optional directory that receives converted twopy HDF5 files.
|
|
39
|
+
When omitted, twopy uses ``analysis_output`` from config.
|
|
40
|
+
mean_start_frame: Optional first frame for the mean image.
|
|
41
|
+
mean_stop_frame: Optional exclusive stop frame for the mean image.
|
|
42
|
+
config_path: YAML config file used when ``output_dir`` is omitted.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Summary of the converted HDF5 files.
|
|
46
|
+
|
|
47
|
+
The conversion copies source data into twopy-owned datasets. The aligned
|
|
48
|
+
movie gets its own file because it dominates size, while the recording data
|
|
49
|
+
file keeps metadata, stimulus, photodiode, and the quick-look mean image.
|
|
50
|
+
"""
|
|
51
|
+
inputs = load_source_conversion_inputs(session_dir)
|
|
52
|
+
start_frame, stop_frame = normalize_frame_range(
|
|
53
|
+
frame_count=inputs.aligned_movie.shape[0],
|
|
54
|
+
start_frame=mean_start_frame,
|
|
55
|
+
stop_frame=mean_stop_frame,
|
|
56
|
+
)
|
|
57
|
+
mean_image = inputs.aligned_movie.mean_image(
|
|
58
|
+
start_frame=start_frame,
|
|
59
|
+
stop_frame=stop_frame,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
destination_dir = _resolve_conversion_output_dir(
|
|
63
|
+
session_dir=inputs.session_files.session_dir,
|
|
64
|
+
output_dir=output_dir,
|
|
65
|
+
config_path=config_path,
|
|
66
|
+
)
|
|
67
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
recording_data_path = destination_dir / CONVERTED_RECORDING_FILENAME
|
|
69
|
+
movie_path = destination_dir / CONVERTED_ALIGNED_MOVIE_FILENAME
|
|
70
|
+
|
|
71
|
+
write_aligned_movie_file(inputs, movie_path)
|
|
72
|
+
write_recording_data_file(
|
|
73
|
+
inputs=inputs,
|
|
74
|
+
output_path=recording_data_path,
|
|
75
|
+
mean_image=mean_image,
|
|
76
|
+
mean_start_frame=start_frame,
|
|
77
|
+
mean_stop_frame=stop_frame,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return ConvertedRecording(
|
|
81
|
+
path=recording_data_path,
|
|
82
|
+
movie_path=movie_path,
|
|
83
|
+
source_session_dir=inputs.session_files.session_dir,
|
|
84
|
+
movie_shape=inputs.aligned_movie.shape,
|
|
85
|
+
mean_image_start_frame=start_frame,
|
|
86
|
+
mean_image_stop_frame=stop_frame,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_conversion_output_dir(
|
|
91
|
+
*,
|
|
92
|
+
session_dir: Path,
|
|
93
|
+
output_dir: Path | None,
|
|
94
|
+
config_path: Path,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Choose where conversion output should be written.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
session_dir: Source recording folder.
|
|
100
|
+
output_dir: Caller-provided output directory, if any.
|
|
101
|
+
config_path: Config file used when ``output_dir`` is omitted.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Expanded output directory path.
|
|
105
|
+
|
|
106
|
+
Passing ``output_dir`` is an explicit override. Otherwise conversion follows
|
|
107
|
+
the same ``analysis_output`` routing that the rest of twopy uses.
|
|
108
|
+
"""
|
|
109
|
+
if output_dir is not None:
|
|
110
|
+
return output_dir.expanduser()
|
|
111
|
+
|
|
112
|
+
config = load_config(config_path)
|
|
113
|
+
return resolve_analysis_output_dir(config, session_dir)
|