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/napari/launcher.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Command-line launcher for the twopy napari app.
|
|
2
|
+
|
|
3
|
+
Inputs: optional ``recording_data.h5`` path and preview/ROI command-line
|
|
4
|
+
arguments.
|
|
5
|
+
Outputs: a napari viewer, either empty or populated with a converted recording.
|
|
6
|
+
|
|
7
|
+
The launcher is the application entry point. It should stay thin: query and
|
|
8
|
+
analysis workflows belong in dedicated helpers that the controls call.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from argparse import ArgumentParser, Namespace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from twopy.napari.controls import add_twopy_magicgui_controls
|
|
15
|
+
from twopy.napari.layout import place_response_options_after_recording_list
|
|
16
|
+
from twopy.napari.loading import resolve_or_convert_launch_recording
|
|
17
|
+
from twopy.napari.types import NapariRecordingView
|
|
18
|
+
from twopy.napari.viewer import create_viewer, open_recording_in_napari
|
|
19
|
+
|
|
20
|
+
__all__ = ["launch_napari", "main"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def launch_napari(
|
|
24
|
+
recording_data_path: Path | None = None,
|
|
25
|
+
*,
|
|
26
|
+
roi_file_to_load: Path | None = None,
|
|
27
|
+
roi_save_file: Path | None = None,
|
|
28
|
+
movie_start_frame: int = 0,
|
|
29
|
+
movie_end_frame: int | None = None,
|
|
30
|
+
load_movie: bool = True,
|
|
31
|
+
) -> NapariRecordingView | None:
|
|
32
|
+
"""Launch napari and start the event loop.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
recording_data_path: Optional path to a source recording, converted
|
|
36
|
+
folder, or ``recording_data.h5``. When omitted, twopy opens the
|
|
37
|
+
current directory or ``./twopy`` recording if one exists; otherwise
|
|
38
|
+
it opens an empty viewer with folder-based recording selection.
|
|
39
|
+
roi_file_to_load: Optional saved ROI HDF5 path to reopen.
|
|
40
|
+
roi_save_file: Optional ROI output path for the Save ROIs button.
|
|
41
|
+
movie_start_frame: First movie preview frame.
|
|
42
|
+
movie_end_frame: Last movie frame to load. ``None`` means the final
|
|
43
|
+
movie frame.
|
|
44
|
+
load_movie: Whether to add the aligned movie layer.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
``NapariRecordingView`` when a recording is loaded at startup,
|
|
48
|
+
otherwise ``None``.
|
|
49
|
+
|
|
50
|
+
This is the script-facing launcher. It may convert source data before
|
|
51
|
+
opening napari, but all analysis still starts from converted HDF5 files.
|
|
52
|
+
"""
|
|
53
|
+
resolved_recording = resolve_or_convert_launch_recording(recording_data_path)
|
|
54
|
+
movie_range = (int(movie_start_frame), movie_end_frame) if load_movie else None
|
|
55
|
+
viewer = create_viewer()
|
|
56
|
+
if resolved_recording is None:
|
|
57
|
+
from twopy.napari.plotting import (
|
|
58
|
+
add_twopy_response_options_widget,
|
|
59
|
+
add_twopy_response_plot_widget,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
response_plot_widget, _response_plot_dock = add_twopy_response_plot_widget(
|
|
63
|
+
viewer,
|
|
64
|
+
recording=None,
|
|
65
|
+
)
|
|
66
|
+
control_docks = add_twopy_magicgui_controls(
|
|
67
|
+
viewer,
|
|
68
|
+
roi_labels_layer=None,
|
|
69
|
+
roi_save_file=(
|
|
70
|
+
roi_save_file.expanduser()
|
|
71
|
+
if roi_save_file is not None
|
|
72
|
+
else Path.cwd() / "rois.h5"
|
|
73
|
+
),
|
|
74
|
+
response_plot_widget=response_plot_widget,
|
|
75
|
+
)
|
|
76
|
+
_response_options_widget, response_options_dock = (
|
|
77
|
+
add_twopy_response_options_widget(viewer, response_plot_widget)
|
|
78
|
+
)
|
|
79
|
+
place_response_options_after_recording_list(
|
|
80
|
+
viewer,
|
|
81
|
+
control_docks.loaded_recordings_dock_widget,
|
|
82
|
+
response_options_dock,
|
|
83
|
+
)
|
|
84
|
+
view = None
|
|
85
|
+
else:
|
|
86
|
+
paths = resolved_recording.paths
|
|
87
|
+
view = open_recording_in_napari(
|
|
88
|
+
paths.recording_data_path,
|
|
89
|
+
viewer=viewer,
|
|
90
|
+
roi_set=roi_file_to_load,
|
|
91
|
+
roi_save_file=roi_save_file,
|
|
92
|
+
movie_path=paths.movie_path,
|
|
93
|
+
movie_frame_range=movie_range,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
import napari
|
|
97
|
+
|
|
98
|
+
napari.run()
|
|
99
|
+
return view
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_launch_args() -> Namespace:
|
|
103
|
+
"""Parse command-line arguments for ``python -m twopy.napari``.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
None.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Parsed argument namespace.
|
|
110
|
+
"""
|
|
111
|
+
parser = ArgumentParser(
|
|
112
|
+
description="Launch napari for a twopy-converted recording.",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"recording_data_path",
|
|
116
|
+
nargs="?",
|
|
117
|
+
type=Path,
|
|
118
|
+
help=(
|
|
119
|
+
"Path to a source recording, converted folder, or recording_data.h5. "
|
|
120
|
+
"If omitted, twopy checks the current directory and ./twopy output, "
|
|
121
|
+
"then opens an empty viewer if neither exists."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--roi-file-to-load",
|
|
126
|
+
dest="roi_file_to_load",
|
|
127
|
+
type=Path,
|
|
128
|
+
default=None,
|
|
129
|
+
help="Optional existing ROI HDF5 file to load into the Labels layer.",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--roi-save-file",
|
|
133
|
+
dest="roi_save_file",
|
|
134
|
+
type=Path,
|
|
135
|
+
default=None,
|
|
136
|
+
help="Optional ROI HDF5 output path for the Save ROIs button.",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--movie-start",
|
|
140
|
+
type=int,
|
|
141
|
+
default=0,
|
|
142
|
+
help="First frame for the movie preview.",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--movie-end",
|
|
146
|
+
type=int,
|
|
147
|
+
default=None,
|
|
148
|
+
help="Last frame for the movie preview. Defaults to the final frame.",
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--no-movie",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="Skip loading movie preview frames and show only the mean image.",
|
|
154
|
+
)
|
|
155
|
+
return parser.parse_args()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
"""Run the twopy napari launcher from the command line.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
None.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
None.
|
|
166
|
+
"""
|
|
167
|
+
args = parse_launch_args()
|
|
168
|
+
launch_napari(
|
|
169
|
+
args.recording_data_path,
|
|
170
|
+
roi_file_to_load=args.roi_file_to_load,
|
|
171
|
+
roi_save_file=args.roi_save_file,
|
|
172
|
+
movie_start_frame=args.movie_start,
|
|
173
|
+
movie_end_frame=args.movie_end,
|
|
174
|
+
load_movie=not args.no_movie,
|
|
175
|
+
)
|
twopy/napari/layout.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Napari dock-layout helpers for twopy widgets.
|
|
2
|
+
|
|
3
|
+
Inputs: napari viewer and dock-widget objects.
|
|
4
|
+
Outputs: best-effort Qt dock placement.
|
|
5
|
+
|
|
6
|
+
These helpers own visual placement only. If napari or Qt internals change, the
|
|
7
|
+
widgets still exist and continue to work; only the preferred layout is skipped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from twopy.napari.protocols import NapariViewer
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"place_loaded_recordings_dock_after_load",
|
|
14
|
+
"place_response_options_after_recording_list",
|
|
15
|
+
"place_save_rois_dock_before_layer_list",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def place_save_rois_dock_before_layer_list(
|
|
20
|
+
viewer: NapariViewer,
|
|
21
|
+
save_rois_dock_widget: object,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Place Save ROIs between napari layer controls and layer list.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
viewer: Napari viewer that owns the dock widgets.
|
|
27
|
+
save_rois_dock_widget: Dock widget returned for the Save ROIs panel.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
None.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
from qtpy.QtCore import Qt
|
|
34
|
+
except ImportError:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
window = viewer.window
|
|
38
|
+
qt_window = getattr(window, "_qt_window", None)
|
|
39
|
+
qt_viewer = getattr(window, "_qt_viewer", None)
|
|
40
|
+
if qt_window is None or qt_viewer is None:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
layer_controls = getattr(qt_viewer, "dockLayerControls", None)
|
|
44
|
+
layer_list = getattr(qt_viewer, "dockLayerList", None)
|
|
45
|
+
if layer_controls is None or layer_list is None:
|
|
46
|
+
return
|
|
47
|
+
if not hasattr(qt_window, "splitDockWidget") or not hasattr(
|
|
48
|
+
qt_window,
|
|
49
|
+
"resizeDocks",
|
|
50
|
+
):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
qt_window.splitDockWidget(
|
|
54
|
+
layer_controls,
|
|
55
|
+
save_rois_dock_widget,
|
|
56
|
+
Qt.Orientation.Vertical,
|
|
57
|
+
)
|
|
58
|
+
qt_window.resizeDocks(
|
|
59
|
+
[layer_controls, save_rois_dock_widget, layer_list],
|
|
60
|
+
[layer_controls.minimumHeight(), 120, 10000],
|
|
61
|
+
Qt.Orientation.Vertical,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def place_loaded_recordings_dock_after_load(
|
|
66
|
+
viewer: NapariViewer,
|
|
67
|
+
load_dock_widget: object,
|
|
68
|
+
loaded_recordings_dock_widget: object,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Stack Loaded Recordings under the Load Recording dock.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
viewer: Napari viewer that owns the dock widgets.
|
|
74
|
+
load_dock_widget: Dock widget returned for load controls.
|
|
75
|
+
loaded_recordings_dock_widget: Dock widget returned for the loaded
|
|
76
|
+
recordings panel.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
None.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
from qtpy.QtCore import Qt
|
|
83
|
+
except ImportError:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
qt_window = getattr(viewer.window, "_qt_window", None)
|
|
87
|
+
if qt_window is None:
|
|
88
|
+
return
|
|
89
|
+
if not hasattr(qt_window, "splitDockWidget") or not hasattr(
|
|
90
|
+
qt_window,
|
|
91
|
+
"resizeDocks",
|
|
92
|
+
):
|
|
93
|
+
return
|
|
94
|
+
qt_window.splitDockWidget(
|
|
95
|
+
load_dock_widget,
|
|
96
|
+
loaded_recordings_dock_widget,
|
|
97
|
+
Qt.Orientation.Vertical,
|
|
98
|
+
)
|
|
99
|
+
qt_window.resizeDocks(
|
|
100
|
+
[load_dock_widget, loaded_recordings_dock_widget],
|
|
101
|
+
[260, 180],
|
|
102
|
+
Qt.Orientation.Vertical,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def place_response_options_after_recording_list(
|
|
107
|
+
viewer: NapariViewer,
|
|
108
|
+
loaded_recordings_dock_widget: object | None,
|
|
109
|
+
response_options_dock_widget: object | None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Stack response options under the Loaded Recordings dock.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
viewer: Napari viewer that owns the dock widgets.
|
|
115
|
+
loaded_recordings_dock_widget: Dock widget returned for the loaded
|
|
116
|
+
recordings panel.
|
|
117
|
+
response_options_dock_widget: Dock widget returned for response
|
|
118
|
+
options.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
None.
|
|
122
|
+
"""
|
|
123
|
+
if loaded_recordings_dock_widget is None or response_options_dock_widget is None:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
from qtpy.QtCore import Qt
|
|
127
|
+
except ImportError:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
qt_window = getattr(viewer.window, "_qt_window", None)
|
|
131
|
+
if qt_window is None:
|
|
132
|
+
return
|
|
133
|
+
if not hasattr(qt_window, "splitDockWidget") or not hasattr(
|
|
134
|
+
qt_window,
|
|
135
|
+
"resizeDocks",
|
|
136
|
+
):
|
|
137
|
+
return
|
|
138
|
+
qt_window.splitDockWidget(
|
|
139
|
+
loaded_recordings_dock_widget,
|
|
140
|
+
response_options_dock_widget,
|
|
141
|
+
Qt.Orientation.Vertical,
|
|
142
|
+
)
|
|
143
|
+
qt_window.resizeDocks(
|
|
144
|
+
[loaded_recordings_dock_widget, response_options_dock_widget],
|
|
145
|
+
[180, 10000],
|
|
146
|
+
Qt.Orientation.Vertical,
|
|
147
|
+
)
|
twopy/napari/loading.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Recording loading helpers for the twopy napari adapter.
|
|
2
|
+
|
|
3
|
+
Inputs: user-selected recording folders, converted folders, or recording files.
|
|
4
|
+
Outputs: converted recording paths ready for napari.
|
|
5
|
+
|
|
6
|
+
This module may trigger conversion when a selected source recording does not
|
|
7
|
+
yet have twopy HDF5 output. It keeps that write behavior out of the read-only
|
|
8
|
+
path resolver.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import h5py
|
|
15
|
+
|
|
16
|
+
from twopy.conversion import convert_recording_to_twopy
|
|
17
|
+
from twopy.napari.constants import RECORDING_DATA_FILENAME
|
|
18
|
+
from twopy.napari.paths import NapariRecordingPaths, PathInput, is_default_path
|
|
19
|
+
from twopy.napari.paths import resolve_recording_paths as resolve_converted_paths
|
|
20
|
+
from twopy.session import discover_session_files
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ResolvedNapariRecording",
|
|
24
|
+
"resolve_or_convert_launch_recording",
|
|
25
|
+
"resolve_or_convert_recording",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ResolvedNapariRecording:
|
|
31
|
+
"""Resolved recording paths plus whether napari had to convert first.
|
|
32
|
+
|
|
33
|
+
Inputs: one selected path.
|
|
34
|
+
Outputs: converted paths and a conversion flag for user-facing status.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
paths: NapariRecordingPaths
|
|
38
|
+
was_converted: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_or_convert_recording(path: PathInput) -> ResolvedNapariRecording:
|
|
42
|
+
"""Resolve a selected recording path, converting source data if needed.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
path: User-selected source folder, converted folder, or
|
|
46
|
+
``recording_data.h5`` path.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Converted recording paths and whether conversion was run.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If the path is neither converted twopy output nor a source
|
|
53
|
+
recording folder.
|
|
54
|
+
|
|
55
|
+
Existing converted files win. If ``recording_data.h5`` exists but
|
|
56
|
+
``aligned_movie.h5`` is missing and the source recording can be identified,
|
|
57
|
+
conversion rewrites that same output folder to repair the pair.
|
|
58
|
+
"""
|
|
59
|
+
selected = Path.cwd() if is_default_path(path) else Path(path).expanduser()
|
|
60
|
+
try:
|
|
61
|
+
paths = resolve_converted_paths(selected)
|
|
62
|
+
except ValueError as error:
|
|
63
|
+
source_dir = _source_recording_dir(selected)
|
|
64
|
+
if source_dir is None:
|
|
65
|
+
raise error
|
|
66
|
+
converted = convert_recording_to_twopy(source_dir)
|
|
67
|
+
return ResolvedNapariRecording(
|
|
68
|
+
paths=resolve_converted_paths(converted.path),
|
|
69
|
+
was_converted=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if paths.movie_path is not None:
|
|
73
|
+
return ResolvedNapariRecording(paths=paths, was_converted=False)
|
|
74
|
+
|
|
75
|
+
source_dir = _source_recording_dir_for_converted_selection(
|
|
76
|
+
selected=selected,
|
|
77
|
+
recording_data_path=paths.recording_data_path,
|
|
78
|
+
)
|
|
79
|
+
if source_dir is None:
|
|
80
|
+
return ResolvedNapariRecording(paths=paths, was_converted=False)
|
|
81
|
+
|
|
82
|
+
converted = convert_recording_to_twopy(
|
|
83
|
+
source_dir,
|
|
84
|
+
output_dir=paths.recording_data_path.parent,
|
|
85
|
+
)
|
|
86
|
+
return ResolvedNapariRecording(
|
|
87
|
+
paths=resolve_converted_paths(converted.path),
|
|
88
|
+
was_converted=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_or_convert_launch_recording(
|
|
93
|
+
recording_data_path: PathInput | None,
|
|
94
|
+
) -> ResolvedNapariRecording | None:
|
|
95
|
+
"""Resolve startup recording paths for the ``twopy`` command.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
recording_data_path: Optional command-line path.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Converted recording paths, or ``None`` when no startup recording is
|
|
102
|
+
available.
|
|
103
|
+
|
|
104
|
+
An explicit command-line path may be source or converted data. With no path,
|
|
105
|
+
twopy checks the current folder for converted output first, then treats the
|
|
106
|
+
current folder as a source recording only if it has the expected source file
|
|
107
|
+
shape.
|
|
108
|
+
"""
|
|
109
|
+
if recording_data_path is not None:
|
|
110
|
+
return resolve_or_convert_recording(recording_data_path)
|
|
111
|
+
|
|
112
|
+
cwd = Path.cwd()
|
|
113
|
+
for candidate in (cwd / RECORDING_DATA_FILENAME, cwd / "twopy", cwd):
|
|
114
|
+
try:
|
|
115
|
+
return resolve_or_convert_recording(candidate)
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _source_recording_dir_for_converted_selection(
|
|
122
|
+
*,
|
|
123
|
+
selected: Path,
|
|
124
|
+
recording_data_path: Path,
|
|
125
|
+
) -> Path | None:
|
|
126
|
+
"""Infer the source recording folder for incomplete converted output.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
selected: User-selected path.
|
|
130
|
+
recording_data_path: Existing converted ``recording_data.h5`` path.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Source recording folder, or ``None`` when it cannot be inferred.
|
|
134
|
+
"""
|
|
135
|
+
candidates = [
|
|
136
|
+
_source_dir_from_recording_data(recording_data_path),
|
|
137
|
+
selected,
|
|
138
|
+
selected.parent,
|
|
139
|
+
recording_data_path.parent.parent,
|
|
140
|
+
]
|
|
141
|
+
if recording_data_path.parent.name == "twopy":
|
|
142
|
+
candidates.insert(0, recording_data_path.parent.parent)
|
|
143
|
+
return _first_source_recording_dir(tuple(candidates))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _source_recording_dir(path: Path) -> Path | None:
|
|
147
|
+
"""Return the source recording folder implied by one selected path.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
path: User-selected path.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Source recording folder, or ``None``.
|
|
154
|
+
"""
|
|
155
|
+
candidates = [path]
|
|
156
|
+
if path.is_file():
|
|
157
|
+
candidates.append(path.parent)
|
|
158
|
+
if path.name == "twopy":
|
|
159
|
+
candidates.append(path.parent)
|
|
160
|
+
if path.parent.name == "twopy":
|
|
161
|
+
candidates.append(path.parent.parent)
|
|
162
|
+
return _first_source_recording_dir(tuple(candidates))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _source_dir_from_recording_data(recording_data_path: Path) -> Path | None:
|
|
166
|
+
"""Read the source recording folder recorded during conversion.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
recording_data_path: Existing converted recording file.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Source recording path, or ``None`` if the file cannot be read.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
with h5py.File(recording_data_path, "r") as h5_file:
|
|
176
|
+
value = h5_file.attrs.get("source_session_dir")
|
|
177
|
+
except OSError:
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(value, bytes):
|
|
180
|
+
value = value.decode("utf-8")
|
|
181
|
+
if not isinstance(value, str) or value == "":
|
|
182
|
+
return None
|
|
183
|
+
return Path(value).expanduser()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _first_source_recording_dir(candidates: tuple[Path | None, ...]) -> Path | None:
|
|
187
|
+
"""Return the first candidate with the expected source recording files.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
candidates: Possible source recording folders.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Resolved source folder, or ``None``.
|
|
194
|
+
"""
|
|
195
|
+
seen: set[Path] = set()
|
|
196
|
+
for candidate in candidates:
|
|
197
|
+
if candidate is None:
|
|
198
|
+
continue
|
|
199
|
+
expanded = candidate.expanduser()
|
|
200
|
+
if expanded in seen:
|
|
201
|
+
continue
|
|
202
|
+
seen.add(expanded)
|
|
203
|
+
try:
|
|
204
|
+
return discover_session_files(expanded).session_dir.resolve()
|
|
205
|
+
except (FileNotFoundError, ValueError):
|
|
206
|
+
continue
|
|
207
|
+
return None
|
twopy/napari/movie.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Movie frame-range helpers for the twopy napari adapter.
|
|
2
|
+
|
|
3
|
+
Inputs: GUI or CLI movie frame numbers.
|
|
4
|
+
Outputs: validated inclusive frame ranges and HDF5 reader stop indices.
|
|
5
|
+
|
|
6
|
+
Napari controls show inclusive frame numbers because that is the intuitive UI
|
|
7
|
+
contract. The converted movie reader uses Python's exclusive stop convention,
|
|
8
|
+
so the conversion happens in one small helper.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__ = ["exclusive_stop", "resolve_movie_frame_range"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_movie_frame_range(
|
|
15
|
+
*,
|
|
16
|
+
start_frame: int,
|
|
17
|
+
end_frame: int | None,
|
|
18
|
+
frame_count: int,
|
|
19
|
+
zero_end_means_last: bool = False,
|
|
20
|
+
) -> tuple[int, int]:
|
|
21
|
+
"""Resolve and validate an inclusive movie frame range.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
start_frame: First movie frame.
|
|
25
|
+
end_frame: Last movie frame, or ``None`` for the final frame.
|
|
26
|
+
frame_count: Number of available movie frames.
|
|
27
|
+
zero_end_means_last: Whether ``start=0, end=0`` should mean the final
|
|
28
|
+
frame. The empty-launch widget uses this before it knows the movie
|
|
29
|
+
length.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Inclusive ``(start, end)`` movie frame range.
|
|
33
|
+
"""
|
|
34
|
+
if start_frame < 0:
|
|
35
|
+
msg = f"movie_start_frame must be at least 0; got {start_frame}"
|
|
36
|
+
raise ValueError(msg)
|
|
37
|
+
if frame_count < 1:
|
|
38
|
+
msg = f"movie must have at least 1 frame; got {frame_count}"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
|
|
41
|
+
last_frame = frame_count - 1
|
|
42
|
+
if end_frame is None or (
|
|
43
|
+
zero_end_means_last and start_frame == 0 and end_frame == 0
|
|
44
|
+
):
|
|
45
|
+
resolved_end = last_frame
|
|
46
|
+
else:
|
|
47
|
+
resolved_end = end_frame
|
|
48
|
+
|
|
49
|
+
if resolved_end < start_frame:
|
|
50
|
+
msg = (
|
|
51
|
+
"movie_end_frame must be greater than or equal to "
|
|
52
|
+
f"movie_start_frame; got {resolved_end} < {start_frame}"
|
|
53
|
+
)
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
if resolved_end > last_frame:
|
|
56
|
+
msg = f"movie_end_frame must be at most {last_frame}; got {resolved_end}"
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
return (start_frame, resolved_end)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def exclusive_stop(end_frame: int | None) -> int | None:
|
|
62
|
+
"""Convert an inclusive end frame to a reader stop frame.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
end_frame: Inclusive final frame, or ``None`` for the final movie
|
|
66
|
+
frame.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Exclusive stop index for ``ConvertedMovie.read_frames``.
|
|
70
|
+
"""
|
|
71
|
+
return None if end_frame is None else end_frame + 1
|