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.
Files changed (84) hide show
  1. twopy/__init__.py +168 -0
  2. twopy/_segments.py +53 -0
  3. twopy/analysis/__init__.py +101 -0
  4. twopy/analysis/background_subtraction.py +672 -0
  5. twopy/analysis/dff.py +524 -0
  6. twopy/analysis/epoch_mapping.py +223 -0
  7. twopy/analysis/motion.py +66 -0
  8. twopy/analysis/persistence.py +975 -0
  9. twopy/analysis/response_processing/__init__.py +55 -0
  10. twopy/analysis/response_processing/apply.py +298 -0
  11. twopy/analysis/response_processing/options.py +192 -0
  12. twopy/analysis/response_processing/qc.py +270 -0
  13. twopy/analysis/response_processing/signals.py +200 -0
  14. twopy/analysis/responses.py +345 -0
  15. twopy/analysis/trials.py +341 -0
  16. twopy/analysis/workflow.py +457 -0
  17. twopy/api.py +78 -0
  18. twopy/config.py +200 -0
  19. twopy/conversion/__init__.py +40 -0
  20. twopy/conversion/alignment_crop.py +329 -0
  21. twopy/conversion/api.py +113 -0
  22. twopy/conversion/frame_ranges.py +35 -0
  23. twopy/conversion/hdf5_writing.py +362 -0
  24. twopy/conversion/matlab_values.py +123 -0
  25. twopy/conversion/source_loading.py +403 -0
  26. twopy/conversion/stimulus_code.py +248 -0
  27. twopy/conversion/types.py +295 -0
  28. twopy/converted.py +681 -0
  29. twopy/database/__init__.py +33 -0
  30. twopy/database/cache.py +166 -0
  31. twopy/database/catalog.py +90 -0
  32. twopy/database/connection.py +27 -0
  33. twopy/database/generic.py +254 -0
  34. twopy/database/modeled.py +419 -0
  35. twopy/database/sql.py +46 -0
  36. twopy/database/types.py +116 -0
  37. twopy/inspection.py +492 -0
  38. twopy/matlab.py +398 -0
  39. twopy/napari/__init__.py +27 -0
  40. twopy/napari/__main__.py +10 -0
  41. twopy/napari/constants.py +13 -0
  42. twopy/napari/controls.py +739 -0
  43. twopy/napari/display.py +181 -0
  44. twopy/napari/display_paths.py +197 -0
  45. twopy/napari/interactive.py +310 -0
  46. twopy/napari/launcher.py +175 -0
  47. twopy/napari/layout.py +147 -0
  48. twopy/napari/loading.py +207 -0
  49. twopy/napari/movie.py +71 -0
  50. twopy/napari/paths.py +181 -0
  51. twopy/napari/plotting/__init__.py +21 -0
  52. twopy/napari/plotting/data.py +266 -0
  53. twopy/napari/plotting/docks.py +1052 -0
  54. twopy/napari/plotting/export.py +803 -0
  55. twopy/napari/plotting/export_controls.py +209 -0
  56. twopy/napari/plotting/label_visibility.py +260 -0
  57. twopy/napari/plotting/options.py +269 -0
  58. twopy/napari/plotting/panels.py +119 -0
  59. twopy/napari/plotting/widgets.py +767 -0
  60. twopy/napari/protocols.py +90 -0
  61. twopy/napari/responses.py +91 -0
  62. twopy/napari/roi.py +238 -0
  63. twopy/napari/session.py +381 -0
  64. twopy/napari/state.py +106 -0
  65. twopy/napari/text.py +26 -0
  66. twopy/napari/types.py +39 -0
  67. twopy/napari/viewer.py +405 -0
  68. twopy/parity/__init__.py +30 -0
  69. twopy/parity/dff.py +399 -0
  70. twopy/parity/roi.py +145 -0
  71. twopy/parity/saved_analysis.py +174 -0
  72. twopy/photodiode.py +375 -0
  73. twopy/photodiode_classification.py +484 -0
  74. twopy/roi.py +462 -0
  75. twopy/session.py +167 -0
  76. twopy/spatial.py +154 -0
  77. twopy/stimulus.py +233 -0
  78. twopy/synchronization.py +235 -0
  79. twopy/typing_guards.py +255 -0
  80. twopy-0.1.0.dist-info/METADATA +311 -0
  81. twopy-0.1.0.dist-info/RECORD +84 -0
  82. twopy-0.1.0.dist-info/WHEEL +5 -0
  83. twopy-0.1.0.dist-info/entry_points.txt +2 -0
  84. twopy-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
+ )
@@ -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