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,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)