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/controls.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"""Magicgui controls for the twopy napari adapter.
|
|
2
|
+
|
|
3
|
+
Inputs: napari viewer, current ROI Labels layer, and paths chosen by the user.
|
|
4
|
+
Outputs: small dock widgets for loading converted recordings and saving ROI
|
|
5
|
+
masks.
|
|
6
|
+
|
|
7
|
+
The control panel owns GUI callbacks only. Loading recordings and saving ROIs
|
|
8
|
+
still go through the same typed helpers used by scripts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Protocol, cast
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from magicgui.widgets import FileEdit
|
|
18
|
+
|
|
19
|
+
from twopy.converted import RecordingData
|
|
20
|
+
from twopy.napari.constants import DEFAULT_PATH_TEXT
|
|
21
|
+
from twopy.napari.layout import (
|
|
22
|
+
place_loaded_recordings_dock_after_load,
|
|
23
|
+
place_save_rois_dock_before_layer_list,
|
|
24
|
+
)
|
|
25
|
+
from twopy.napari.loading import resolve_or_convert_recording
|
|
26
|
+
from twopy.napari.movie import resolve_movie_frame_range
|
|
27
|
+
from twopy.napari.paths import (
|
|
28
|
+
PathInput,
|
|
29
|
+
is_default_path,
|
|
30
|
+
resolve_widget_output_path,
|
|
31
|
+
)
|
|
32
|
+
from twopy.napari.protocols import NapariViewer
|
|
33
|
+
from twopy.napari.roi import (
|
|
34
|
+
roi_label_image_from_layer_for_recording,
|
|
35
|
+
save_napari_label_rois,
|
|
36
|
+
)
|
|
37
|
+
from twopy.napari.session import (
|
|
38
|
+
LoadedNapariRecording,
|
|
39
|
+
LoadedRecordingsPanel,
|
|
40
|
+
record_loaded_view,
|
|
41
|
+
render_loaded_recordings_panel,
|
|
42
|
+
select_loaded_recording,
|
|
43
|
+
unload_loaded_recording,
|
|
44
|
+
update_selected_roi_save_file,
|
|
45
|
+
)
|
|
46
|
+
from twopy.napari.state import (
|
|
47
|
+
read_last_recording_folder,
|
|
48
|
+
recording_folder_for_state,
|
|
49
|
+
write_last_recording_folder,
|
|
50
|
+
)
|
|
51
|
+
from twopy.napari.text import counted_noun
|
|
52
|
+
|
|
53
|
+
__all__ = ["NapariControlDocks", "add_twopy_magicgui_controls"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _RoiSavePathReceiver(Protocol):
|
|
57
|
+
"""Small protocol for widgets that mirror the selected ROI save path."""
|
|
58
|
+
|
|
59
|
+
def set_roi_save_file(self, roi_save_file: Path | None) -> None:
|
|
60
|
+
"""Store the ROI HDF5 path for the selected recording.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
roi_save_file: ROI output path, or ``None`` when no recording is
|
|
64
|
+
selected.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
None.
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class NapariControlDocks:
|
|
74
|
+
"""Dock widgets created for twopy napari controls.
|
|
75
|
+
|
|
76
|
+
Inputs: Qt widgets and napari dock objects.
|
|
77
|
+
Outputs: grouped references for scripts and tests.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
load_widget: object
|
|
81
|
+
load_dock_widget: object
|
|
82
|
+
loaded_recordings_widget: object
|
|
83
|
+
loaded_recordings_dock_widget: object
|
|
84
|
+
save_rois_widget: object
|
|
85
|
+
save_rois_dock_widget: object
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class NapariControlState:
|
|
90
|
+
"""Mutable state shared by the small magicgui controls.
|
|
91
|
+
|
|
92
|
+
Inputs: napari viewer, current ROI Labels layer, and default save path.
|
|
93
|
+
Outputs: state that button callbacks can update after loading recordings.
|
|
94
|
+
|
|
95
|
+
The mutable state is intentionally narrow. It keeps GUI callback ownership
|
|
96
|
+
in this module without moving recording loading or ROI conversion out of
|
|
97
|
+
the typed core helpers.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
viewer: NapariViewer
|
|
101
|
+
roi_labels_layer: object | None
|
|
102
|
+
roi_save_file: Path
|
|
103
|
+
recording: RecordingData | None
|
|
104
|
+
response_plot_widget: object | None
|
|
105
|
+
loaded_recordings: list[LoadedNapariRecording] = field(default_factory=list)
|
|
106
|
+
selected_recording_index: int | None = None
|
|
107
|
+
loaded_recordings_panel: LoadedRecordingsPanel | None = None
|
|
108
|
+
recording_picker_path: Path | None = None
|
|
109
|
+
recording_picker_display_text: str = DEFAULT_PATH_TEXT
|
|
110
|
+
is_loading: bool = False
|
|
111
|
+
replace_selected_on_next_load: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def add_twopy_magicgui_controls(
|
|
115
|
+
viewer: NapariViewer,
|
|
116
|
+
*,
|
|
117
|
+
roi_labels_layer: object | None,
|
|
118
|
+
roi_save_file: Path,
|
|
119
|
+
recording: RecordingData | None = None,
|
|
120
|
+
response_plot_widget: object | None = None,
|
|
121
|
+
mean_image_layer: object | None = None,
|
|
122
|
+
movie_layer: object | None = None,
|
|
123
|
+
dock_name: str = "twopy",
|
|
124
|
+
dock_area: str = "right",
|
|
125
|
+
) -> NapariControlDocks:
|
|
126
|
+
"""Add the first twopy magicgui control panel to a napari viewer.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
viewer: Napari viewer that should receive the dock widget.
|
|
130
|
+
roi_labels_layer: Editable napari Labels layer that contains ROI
|
|
131
|
+
integer labels. ``None`` creates a disabled save path with a clear
|
|
132
|
+
status message.
|
|
133
|
+
roi_save_file: Default destination for saved ROI HDF5 files.
|
|
134
|
+
recording: Optional loaded recording used to populate GUI defaults.
|
|
135
|
+
response_plot_widget: Optional response plotting widget to refresh
|
|
136
|
+
after a new recording is loaded.
|
|
137
|
+
mean_image_layer: Optional mean-image layer for an already loaded
|
|
138
|
+
startup recording.
|
|
139
|
+
movie_layer: Optional movie layer for an already loaded startup
|
|
140
|
+
recording.
|
|
141
|
+
dock_name: Dock widget title.
|
|
142
|
+
dock_area: Napari dock area.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Created load and save-ROI widgets plus their napari dock widgets.
|
|
146
|
+
|
|
147
|
+
The panel is intentionally small. It loads converted recordings and saves
|
|
148
|
+
the current Labels layer through the same helpers used by scripts, so a
|
|
149
|
+
future plugin can wrap this without changing ROI semantics.
|
|
150
|
+
"""
|
|
151
|
+
state = NapariControlState(
|
|
152
|
+
viewer=viewer,
|
|
153
|
+
roi_labels_layer=roi_labels_layer,
|
|
154
|
+
roi_save_file=roi_save_file,
|
|
155
|
+
recording=recording,
|
|
156
|
+
response_plot_widget=response_plot_widget,
|
|
157
|
+
)
|
|
158
|
+
if recording is not None and mean_image_layer is not None:
|
|
159
|
+
state.loaded_recordings.append(
|
|
160
|
+
LoadedNapariRecording(
|
|
161
|
+
display_path=recording.path.parent,
|
|
162
|
+
recording=recording,
|
|
163
|
+
roi_save_file=roi_save_file,
|
|
164
|
+
mean_image_layer=mean_image_layer,
|
|
165
|
+
movie_layer=movie_layer,
|
|
166
|
+
roi_labels_layer=roi_labels_layer,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
state.selected_recording_index = 0
|
|
170
|
+
load_widget = _make_twopy_load_widget(state)
|
|
171
|
+
load_dock_widget = viewer.window.add_dock_widget(
|
|
172
|
+
load_widget,
|
|
173
|
+
name=dock_name,
|
|
174
|
+
area=dock_area,
|
|
175
|
+
)
|
|
176
|
+
loaded_recordings_widget = _make_loaded_recordings_widget(state)
|
|
177
|
+
loaded_recordings_dock_widget = viewer.window.add_dock_widget(
|
|
178
|
+
loaded_recordings_widget,
|
|
179
|
+
name="twopy loaded recordings",
|
|
180
|
+
area=dock_area,
|
|
181
|
+
)
|
|
182
|
+
place_loaded_recordings_dock_after_load(
|
|
183
|
+
viewer,
|
|
184
|
+
load_dock_widget,
|
|
185
|
+
loaded_recordings_dock_widget,
|
|
186
|
+
)
|
|
187
|
+
save_rois_widget = _make_twopy_save_rois_widget(state)
|
|
188
|
+
save_rois_dock_widget = viewer.window.add_dock_widget(
|
|
189
|
+
save_rois_widget,
|
|
190
|
+
name="twopy save ROIs",
|
|
191
|
+
area="left",
|
|
192
|
+
)
|
|
193
|
+
place_save_rois_dock_before_layer_list(viewer, save_rois_dock_widget)
|
|
194
|
+
return NapariControlDocks(
|
|
195
|
+
load_widget=load_widget,
|
|
196
|
+
load_dock_widget=load_dock_widget,
|
|
197
|
+
loaded_recordings_widget=loaded_recordings_widget,
|
|
198
|
+
loaded_recordings_dock_widget=loaded_recordings_dock_widget,
|
|
199
|
+
save_rois_widget=save_rois_widget,
|
|
200
|
+
save_rois_dock_widget=save_rois_dock_widget,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _make_twopy_load_widget(state: NapariControlState) -> object:
|
|
205
|
+
"""Create the right-side magicgui widget for loading recordings.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
state: Mutable napari control state.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
magicgui container with recording-load controls.
|
|
212
|
+
"""
|
|
213
|
+
from magicgui import magicgui
|
|
214
|
+
from magicgui.widgets import Container, Label
|
|
215
|
+
|
|
216
|
+
default_movie_end_frame = _default_movie_end_frame(state.recording)
|
|
217
|
+
|
|
218
|
+
@magicgui(
|
|
219
|
+
call_button=False,
|
|
220
|
+
layout="vertical",
|
|
221
|
+
movie_frame_range={
|
|
222
|
+
"widget_type": "RangeSlider",
|
|
223
|
+
"min": 0,
|
|
224
|
+
"max": max(default_movie_end_frame, 0),
|
|
225
|
+
"step": 1,
|
|
226
|
+
"tracking": False,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
def load_recording(
|
|
230
|
+
recording_folder: Path = Path(DEFAULT_PATH_TEXT),
|
|
231
|
+
roi_file_to_load: Path = Path(DEFAULT_PATH_TEXT),
|
|
232
|
+
movie_frame_range: tuple[int, int] = (0, default_movie_end_frame),
|
|
233
|
+
load_movie: bool = True,
|
|
234
|
+
) -> str:
|
|
235
|
+
"""Load a recording into the current napari viewer.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
recording_folder: Source recording folder, converted folder, or
|
|
239
|
+
direct ``recording_data.h5`` path. Source folders are converted
|
|
240
|
+
first when twopy output does not exist yet.
|
|
241
|
+
roi_file_to_load: Optional existing ROI HDF5 file. ``default`` uses
|
|
242
|
+
``rois.h5`` in the selected recording folder when it exists.
|
|
243
|
+
movie_frame_range: First and last movie frames to load. When the
|
|
244
|
+
empty-launch widget still shows ``(0, 0)``, twopy resolves it
|
|
245
|
+
to the recording's full frame range after the folder is
|
|
246
|
+
selected.
|
|
247
|
+
load_movie: Whether to load movie frames.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Human-readable status for the dock widget.
|
|
251
|
+
"""
|
|
252
|
+
if state.is_loading:
|
|
253
|
+
return "Recording load already in progress."
|
|
254
|
+
state.is_loading = True
|
|
255
|
+
replace_selected = state.replace_selected_on_next_load
|
|
256
|
+
state.replace_selected_on_next_load = False
|
|
257
|
+
selected_recording_path = _resolve_recording_folder_value(
|
|
258
|
+
recording_folder,
|
|
259
|
+
state,
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
resolved_recording = resolve_or_convert_recording(selected_recording_path)
|
|
263
|
+
paths = resolved_recording.paths
|
|
264
|
+
roi_path = (
|
|
265
|
+
paths.roi_file_to_load
|
|
266
|
+
if is_default_path(roi_file_to_load)
|
|
267
|
+
else _resolve_optional_roi_path(roi_file_to_load)
|
|
268
|
+
)
|
|
269
|
+
loaded_recording = _load_recording_for_defaults(
|
|
270
|
+
recording_data_path=paths.recording_data_path,
|
|
271
|
+
movie_path=paths.movie_path,
|
|
272
|
+
)
|
|
273
|
+
movie_range = None
|
|
274
|
+
if load_movie:
|
|
275
|
+
movie_range = resolve_movie_frame_range(
|
|
276
|
+
start_frame=int(movie_frame_range[0]),
|
|
277
|
+
end_frame=int(movie_frame_range[1]),
|
|
278
|
+
frame_count=loaded_recording.movie.shape[0],
|
|
279
|
+
zero_end_means_last=True,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Import here to avoid a top-level cycle: viewer creates controls,
|
|
283
|
+
# while controls can load recordings into that same viewer.
|
|
284
|
+
from twopy.napari.viewer import open_recording_in_napari
|
|
285
|
+
|
|
286
|
+
view = open_recording_in_napari(
|
|
287
|
+
paths.recording_data_path,
|
|
288
|
+
viewer=state.viewer,
|
|
289
|
+
roi_set=roi_path,
|
|
290
|
+
roi_save_file=paths.roi_save_file,
|
|
291
|
+
movie_path=paths.movie_path,
|
|
292
|
+
movie_frame_range=movie_range,
|
|
293
|
+
add_controls=False,
|
|
294
|
+
)
|
|
295
|
+
record_loaded_view(
|
|
296
|
+
state,
|
|
297
|
+
view=view,
|
|
298
|
+
roi_save_file=paths.roi_save_file,
|
|
299
|
+
replace_selected=replace_selected,
|
|
300
|
+
)
|
|
301
|
+
write_last_recording_folder(
|
|
302
|
+
recording_folder_for_state(
|
|
303
|
+
selected_recording_path,
|
|
304
|
+
paths.recording_data_path,
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
frame_end = _default_movie_end_frame(view.recording)
|
|
308
|
+
load_recording.movie_frame_range.max = frame_end
|
|
309
|
+
load_recording.movie_frame_range.value = movie_range or (0, frame_end)
|
|
310
|
+
_set_recording_picker_display_path(
|
|
311
|
+
cast(FileEdit, load_recording.recording_folder),
|
|
312
|
+
state,
|
|
313
|
+
paths.recording_data_path.parent,
|
|
314
|
+
)
|
|
315
|
+
status = (
|
|
316
|
+
"Converted and loaded" if resolved_recording.was_converted else "Loaded"
|
|
317
|
+
)
|
|
318
|
+
return f"{status} {paths.recording_data_path}"
|
|
319
|
+
finally:
|
|
320
|
+
state.replace_selected_on_next_load = False
|
|
321
|
+
state.is_loading = False
|
|
322
|
+
|
|
323
|
+
load_recording.recording_folder.mode = "d"
|
|
324
|
+
load_recording.roi_file_to_load.mode = "r"
|
|
325
|
+
|
|
326
|
+
load_recording.recording_folder.label = "recording folder or file"
|
|
327
|
+
load_recording.roi_file_to_load.label = "ROI file to load"
|
|
328
|
+
load_recording.movie_frame_range.label = "movie frames"
|
|
329
|
+
load_recording.load_movie.label = "load movie"
|
|
330
|
+
_configure_recording_folder_picker(
|
|
331
|
+
cast(FileEdit, load_recording.recording_folder),
|
|
332
|
+
state,
|
|
333
|
+
lambda path: load_recording(recording_folder=path),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def load_after_selection(_value: object) -> None:
|
|
337
|
+
"""Load when the user changes the recording picker.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
_value: Ignored widget signal value.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
None.
|
|
344
|
+
"""
|
|
345
|
+
if state.is_loading or not _has_recording_to_load(
|
|
346
|
+
load_recording.recording_folder.value,
|
|
347
|
+
state,
|
|
348
|
+
):
|
|
349
|
+
return
|
|
350
|
+
load_recording()
|
|
351
|
+
|
|
352
|
+
def reload_after_option_change(_value: object) -> None:
|
|
353
|
+
"""Reload the current recording after a non-path option changes.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
_value: Ignored widget signal value.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
None.
|
|
360
|
+
|
|
361
|
+
Secondary controls such as the movie-frame slider should reuse the
|
|
362
|
+
resolved recording path stored in state. The visible picker text may be
|
|
363
|
+
shortened for display and is not always a real filesystem path.
|
|
364
|
+
"""
|
|
365
|
+
if state.is_loading or not _has_recording_to_load(
|
|
366
|
+
load_recording.recording_folder.value,
|
|
367
|
+
state,
|
|
368
|
+
):
|
|
369
|
+
return
|
|
370
|
+
state.replace_selected_on_next_load = True
|
|
371
|
+
load_recording(
|
|
372
|
+
recording_folder=_current_recording_path_for_reload(
|
|
373
|
+
load_recording.recording_folder.value,
|
|
374
|
+
state,
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
load_recording.recording_folder.changed.connect(load_after_selection)
|
|
379
|
+
load_recording.roi_file_to_load.changed.connect(reload_after_option_change)
|
|
380
|
+
load_recording.movie_frame_range.changed.connect(reload_after_option_change)
|
|
381
|
+
load_recording.load_movie.changed.connect(reload_after_option_change)
|
|
382
|
+
|
|
383
|
+
return Container(
|
|
384
|
+
widgets=[
|
|
385
|
+
Label(value="Load Recording"),
|
|
386
|
+
load_recording,
|
|
387
|
+
],
|
|
388
|
+
layout="vertical",
|
|
389
|
+
labels=False,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _make_loaded_recordings_widget(state: NapariControlState) -> object:
|
|
394
|
+
"""Create the loaded-recordings list panel.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
state: Mutable napari control state.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Qt panel that lists loaded recordings and can unload the selected one.
|
|
401
|
+
"""
|
|
402
|
+
panel = LoadedRecordingsPanel(
|
|
403
|
+
on_select=lambda index: select_loaded_recording(state, index),
|
|
404
|
+
on_unload=lambda index: unload_loaded_recording(state, index),
|
|
405
|
+
)
|
|
406
|
+
state.loaded_recordings_panel = panel
|
|
407
|
+
render_loaded_recordings_panel(state)
|
|
408
|
+
return panel
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _make_twopy_save_rois_widget(state: NapariControlState) -> object:
|
|
412
|
+
"""Create the left-side magicgui widget for saving ROI labels.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
state: Mutable napari control state.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
magicgui widget with one Save ROIs button.
|
|
419
|
+
"""
|
|
420
|
+
from magicgui import magicgui
|
|
421
|
+
|
|
422
|
+
@magicgui(call_button="Save ROIs", layout="vertical")
|
|
423
|
+
def save_rois(roi_save_file: Path = Path(DEFAULT_PATH_TEXT)) -> str:
|
|
424
|
+
"""Save current napari ROI labels to twopy ROI HDF5.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
roi_save_file: Destination ROI HDF5 path. ``default`` uses the
|
|
428
|
+
current recording's default ROI output path.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Human-readable status for the dock widget.
|
|
432
|
+
"""
|
|
433
|
+
if state.roi_labels_layer is None:
|
|
434
|
+
return "No ROI Labels layer is available."
|
|
435
|
+
if state.recording is None:
|
|
436
|
+
return "No recording is selected."
|
|
437
|
+
try:
|
|
438
|
+
label_image = roi_label_image_from_layer_for_recording(
|
|
439
|
+
state.roi_labels_layer,
|
|
440
|
+
state.recording,
|
|
441
|
+
)
|
|
442
|
+
except ValueError as error:
|
|
443
|
+
return str(error)
|
|
444
|
+
if not np.any(label_image > 0):
|
|
445
|
+
return "No ROI labels to save."
|
|
446
|
+
|
|
447
|
+
resolved_output_path = resolve_widget_output_path(
|
|
448
|
+
roi_save_file,
|
|
449
|
+
default=state.roi_save_file,
|
|
450
|
+
)
|
|
451
|
+
roi_set = save_napari_label_rois(label_image, resolved_output_path)
|
|
452
|
+
state.roi_save_file = resolved_output_path
|
|
453
|
+
update_selected_roi_save_file(state, resolved_output_path)
|
|
454
|
+
if hasattr(state.response_plot_widget, "set_roi_save_file"):
|
|
455
|
+
cast(_RoiSavePathReceiver, state.response_plot_widget).set_roi_save_file(
|
|
456
|
+
resolved_output_path
|
|
457
|
+
)
|
|
458
|
+
return (
|
|
459
|
+
f"Saved {counted_noun(len(roi_set.labels), 'ROI', 'ROIs')} "
|
|
460
|
+
f"to {resolved_output_path}"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
save_rois.roi_save_file.mode = "w"
|
|
464
|
+
save_rois.roi_save_file.label = "ROI save file"
|
|
465
|
+
return save_rois
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _configure_recording_folder_picker(
|
|
469
|
+
widget: FileEdit,
|
|
470
|
+
state: NapariControlState,
|
|
471
|
+
load_selected_path: Callable[[Path], object],
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Keep the picker display compact while remembering dialog start folders.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
widget: Recording-folder FileEdit widget.
|
|
477
|
+
state: Current napari control state.
|
|
478
|
+
load_selected_path: Callback that loads the selected recording path.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
None.
|
|
482
|
+
|
|
483
|
+
Magicgui normally shows full paths. Twopy displays a shortened tail path so
|
|
484
|
+
the useful recording date/folder stays visible in the compact dock.
|
|
485
|
+
"""
|
|
486
|
+
widget.choose_btn.changed.disconnect(widget._on_choose_clicked)
|
|
487
|
+
|
|
488
|
+
def choose_recording_folder() -> None:
|
|
489
|
+
"""Open the folder dialog and load even when the same folder is picked."""
|
|
490
|
+
result = widget._show_file_dialog(
|
|
491
|
+
widget.mode,
|
|
492
|
+
caption="Choose directory",
|
|
493
|
+
start_path=_recording_picker_start_path(state),
|
|
494
|
+
filter=widget.filter,
|
|
495
|
+
)
|
|
496
|
+
if result is None or isinstance(result, tuple):
|
|
497
|
+
return
|
|
498
|
+
with widget.changed.blocked():
|
|
499
|
+
widget.line_edit.value = str(result)
|
|
500
|
+
load_selected_path(result)
|
|
501
|
+
|
|
502
|
+
widget.choose_btn.changed.connect(choose_recording_folder)
|
|
503
|
+
if state.recording is not None:
|
|
504
|
+
_set_recording_picker_display_path(widget, state, state.recording.path.parent)
|
|
505
|
+
else:
|
|
506
|
+
_set_recording_picker_display_default(widget, state)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _set_recording_picker_display_default(
|
|
510
|
+
widget: FileEdit,
|
|
511
|
+
state: NapariControlState,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Show ``default`` instead of a long recording path in the picker field.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
widget: Recording-folder FileEdit widget.
|
|
517
|
+
state: Current napari control state.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
None.
|
|
521
|
+
"""
|
|
522
|
+
state.recording_picker_path = None
|
|
523
|
+
state.recording_picker_display_text = DEFAULT_PATH_TEXT
|
|
524
|
+
with widget.changed.blocked():
|
|
525
|
+
widget.line_edit.value = DEFAULT_PATH_TEXT
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _set_recording_picker_display_path(
|
|
529
|
+
widget: FileEdit,
|
|
530
|
+
state: NapariControlState,
|
|
531
|
+
path: Path,
|
|
532
|
+
) -> None:
|
|
533
|
+
"""Show a shortened loaded-recording path in the picker field.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
widget: Recording-folder FileEdit widget.
|
|
537
|
+
state: Current napari control state.
|
|
538
|
+
path: Loaded recording folder to display and remember.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
None.
|
|
542
|
+
"""
|
|
543
|
+
state.recording_picker_path = path.expanduser()
|
|
544
|
+
state.recording_picker_display_text = _short_path_text(
|
|
545
|
+
state.recording_picker_path,
|
|
546
|
+
max_chars=72,
|
|
547
|
+
)
|
|
548
|
+
with widget.changed.blocked():
|
|
549
|
+
widget.line_edit.value = state.recording_picker_display_text
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _short_path_text(path: Path, *, max_chars: int) -> str:
|
|
553
|
+
"""Return a compact path string that keeps the folder tail visible.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
path: Path to display.
|
|
557
|
+
max_chars: Maximum text length.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
Full path when it fits, otherwise ``...`` plus the path tail.
|
|
561
|
+
"""
|
|
562
|
+
text = str(path)
|
|
563
|
+
if path.is_dir() and not text.endswith("/"):
|
|
564
|
+
text = f"{text}/"
|
|
565
|
+
if len(text) <= max_chars:
|
|
566
|
+
return text
|
|
567
|
+
return f"...{text[-(max_chars - 3) :]}"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _recording_picker_start_path(state: NapariControlState) -> str | None:
|
|
571
|
+
"""Return the folder shown first when choosing a recording.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
state: Current napari control state.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
Absolute path string, or ``None`` for the platform default.
|
|
578
|
+
"""
|
|
579
|
+
if state.recording_picker_path is not None:
|
|
580
|
+
return str(state.recording_picker_path.resolve())
|
|
581
|
+
if state.recording is not None:
|
|
582
|
+
return str(state.recording.path.expanduser().parent.resolve())
|
|
583
|
+
remembered_folder = read_last_recording_folder()
|
|
584
|
+
if remembered_folder is None:
|
|
585
|
+
return None
|
|
586
|
+
return str(remembered_folder.resolve())
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _resolve_recording_folder_value(
|
|
590
|
+
recording_folder: PathInput,
|
|
591
|
+
state: NapariControlState,
|
|
592
|
+
) -> Path:
|
|
593
|
+
"""Resolve the picker value into the recording path to load.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
recording_folder: Value from the visible picker field.
|
|
597
|
+
state: Current napari control state.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
Selected path, or the current recording when the field shows
|
|
601
|
+
``default``.
|
|
602
|
+
"""
|
|
603
|
+
if _is_displayed_recording_path(recording_folder, state):
|
|
604
|
+
if state.recording_picker_path is not None:
|
|
605
|
+
return state.recording_picker_path
|
|
606
|
+
if state.recording is not None:
|
|
607
|
+
return state.recording.path
|
|
608
|
+
if not is_default_path(recording_folder):
|
|
609
|
+
return Path(recording_folder).expanduser()
|
|
610
|
+
if state.recording_picker_path is not None:
|
|
611
|
+
return state.recording_picker_path
|
|
612
|
+
if state.recording is not None:
|
|
613
|
+
return state.recording.path
|
|
614
|
+
return Path(recording_folder)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _current_recording_path_for_reload(
|
|
618
|
+
recording_folder: PathInput,
|
|
619
|
+
state: NapariControlState,
|
|
620
|
+
) -> Path:
|
|
621
|
+
"""Return a concrete path for reloading the current recording.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
recording_folder: Visible recording picker value.
|
|
625
|
+
state: Current napari control state.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
Stored real recording path when available, otherwise the picker value.
|
|
629
|
+
"""
|
|
630
|
+
if state.recording_picker_path is not None:
|
|
631
|
+
return state.recording_picker_path
|
|
632
|
+
if state.recording is not None:
|
|
633
|
+
return state.recording.path
|
|
634
|
+
return _resolve_recording_folder_value(recording_folder, state)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _has_recording_to_load(
|
|
638
|
+
recording_folder: PathInput,
|
|
639
|
+
state: NapariControlState,
|
|
640
|
+
) -> bool:
|
|
641
|
+
"""Return whether a load-relevant widget change can load a recording.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
recording_folder: Visible recording picker value.
|
|
645
|
+
state: Current napari control state.
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
``True`` when a concrete path is selected or a recording is already
|
|
649
|
+
loaded.
|
|
650
|
+
"""
|
|
651
|
+
return (
|
|
652
|
+
not is_default_path(recording_folder)
|
|
653
|
+
or state.recording_picker_path is not None
|
|
654
|
+
or state.recording is not None
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _is_displayed_recording_path(
|
|
659
|
+
recording_folder: PathInput,
|
|
660
|
+
state: NapariControlState,
|
|
661
|
+
) -> bool:
|
|
662
|
+
"""Return whether the picker value is twopy's shortened display text.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
recording_folder: Visible recording picker value.
|
|
666
|
+
state: Current napari control state.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
``True`` when the field text matches the remembered display value.
|
|
670
|
+
"""
|
|
671
|
+
return state.recording_picker_path is not None and _normalized_display_path_text(
|
|
672
|
+
str(recording_folder)
|
|
673
|
+
) == _normalized_display_path_text(state.recording_picker_display_text)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _normalized_display_path_text(text: str) -> str:
|
|
677
|
+
"""Return path-display text normalized for widget comparison.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
text: Text from magicgui or twopy's shortened display state.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Text without a trailing path separator.
|
|
684
|
+
"""
|
|
685
|
+
return text.rstrip("/")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _resolve_optional_roi_path(path: PathInput) -> Path | None:
|
|
689
|
+
"""Resolve an optional ROI file selected by the user.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
path: Candidate ROI file path.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Existing ROI file path, or ``None`` for empty/default values.
|
|
696
|
+
"""
|
|
697
|
+
if is_default_path(path):
|
|
698
|
+
return None
|
|
699
|
+
resolved = Path(path).expanduser()
|
|
700
|
+
if not resolved.is_file():
|
|
701
|
+
msg = f"ROI file does not exist: {resolved}"
|
|
702
|
+
raise ValueError(msg)
|
|
703
|
+
return resolved.resolve()
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _default_movie_end_frame(recording: RecordingData | None) -> int:
|
|
707
|
+
"""Return the default inclusive movie end frame for a loaded recording.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
recording: Optional loaded recording.
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
Last available frame index, or ``0`` before a recording is loaded.
|
|
714
|
+
"""
|
|
715
|
+
if recording is None:
|
|
716
|
+
return 0
|
|
717
|
+
return max(recording.movie.shape[0] - 1, 0)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _load_recording_for_defaults(
|
|
721
|
+
*,
|
|
722
|
+
recording_data_path: Path,
|
|
723
|
+
movie_path: Path | None,
|
|
724
|
+
) -> RecordingData:
|
|
725
|
+
"""Load recording metadata before resolving frame defaults.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
recording_data_path: Converted ``recording_data.h5`` path.
|
|
729
|
+
movie_path: Optional explicit converted movie path.
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
Loaded recording object.
|
|
733
|
+
|
|
734
|
+
Loading the small manifest lets the empty-launch widget convert its initial
|
|
735
|
+
``0`` end frame into the actual final frame before adding the movie layer.
|
|
736
|
+
"""
|
|
737
|
+
from twopy.converted import load_converted_recording
|
|
738
|
+
|
|
739
|
+
return load_converted_recording(recording_data_path, movie_path=movie_path)
|