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
twopy/parity/dff.py ADDED
@@ -0,0 +1,399 @@
1
+ """Compare twopy ROI dF/F outputs against prior saved-analysis traces.
2
+
3
+ Inputs: a converted ``RecordingData`` object and a read-only saved-analysis
4
+ object.
5
+ Outputs: computed twopy dF/F, saved traces, and simple error metrics.
6
+
7
+ This module is an audit layer. It does not make saved-analysis files part of
8
+ normal analysis; it only checks whether twopy analysis over converted objects
9
+ matches prior saved outputs for the same ROI masks and frame windows.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+
17
+ from twopy.analysis.background_subtraction import (
18
+ BackgroundCorrectionMethod,
19
+ extract_background_corrected_roi_traces,
20
+ )
21
+ from twopy.analysis.dff import (
22
+ DeltaFOverFFitMode,
23
+ RoiDeltaFOverF,
24
+ compute_roi_delta_f_over_f,
25
+ )
26
+ from twopy.analysis.epoch_mapping import interpolate_stimulus_epochs_to_frame_windows
27
+ from twopy.analysis.trials import FrameWindow
28
+ from twopy.converted import RecordingData
29
+ from twopy.parity.roi import SavedAnalysisRoiSet, roi_set_from_saved_analysis
30
+ from twopy.parity.saved_analysis import SavedAnalysisLastRoi
31
+
32
+ __all__ = [
33
+ "SavedAnalysisDeltaFOverFComparison",
34
+ "compare_saved_analysis_delta_f_over_f",
35
+ "saved_analysis_epoch_windows",
36
+ ]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SavedAnalysisDeltaFOverFComparison:
41
+ """Computed dF/F beside saved reference traces and error metrics.
42
+
43
+ Inputs: twopy-computed dF/F and saved-analysis ROI traces with the same
44
+ frame and ROI shape.
45
+ Outputs: absolute errors, per-ROI correlations, and summary metrics.
46
+
47
+ Keeping the full arrays beside the summaries makes a parity failure
48
+ inspectable: callers can plot one ROI, inspect one frame window, or report
49
+ aggregate errors without recomputing extraction.
50
+ """
51
+
52
+ computed: RoiDeltaFOverF
53
+ saved_values: npt.NDArray[np.float64]
54
+ absolute_error: npt.NDArray[np.float64]
55
+ correlation_by_roi: npt.NDArray[np.float64]
56
+ roi_set: SavedAnalysisRoiSet
57
+ interleave_windows: tuple[FrameWindow, ...]
58
+ mean_absolute_error: float
59
+ median_absolute_error: float
60
+ max_absolute_error: float
61
+
62
+
63
+ def compare_saved_analysis_delta_f_over_f(
64
+ recording: RecordingData,
65
+ saved_analysis: SavedAnalysisLastRoi,
66
+ *,
67
+ saved_trace_index: int = 0,
68
+ interleave_epoch_number: int = 1,
69
+ background_method: BackgroundCorrectionMethod = "movie_global_percentile",
70
+ seconds_interleave_use: float | None = 1.0,
71
+ fit_mode: DeltaFOverFFitMode = "source_bounds",
72
+ trace_start_frame: int | None = None,
73
+ trace_stop_frame: int | None = None,
74
+ data_rate_hz: float | None = None,
75
+ chunk_frames: int = 128,
76
+ ) -> SavedAnalysisDeltaFOverFComparison:
77
+ """Compute twopy dF/F and compare it to one saved trace matrix.
78
+
79
+ Args:
80
+ recording: Loaded converted recording.
81
+ saved_analysis: Read-only saved-analysis data.
82
+ saved_trace_index: Which saved trace cell to compare.
83
+ interleave_epoch_number: One-based saved epoch number used as baseline.
84
+ background_method: twopy background method used before dF/F.
85
+ seconds_interleave_use: Seconds from the end of each interleave window.
86
+ ``None`` uses each full interleave window.
87
+ fit_mode: dF/F exponential fit mode.
88
+ trace_start_frame: Optional absolute movie frame for saved row zero.
89
+ When omitted, twopy infers the stimulus-bounded frame range from
90
+ converted photodiode and stimulus data.
91
+ trace_stop_frame: Optional exclusive absolute stop frame. When omitted
92
+ with an explicit start, the saved trace length defines the stop.
93
+ data_rate_hz: Optional imaging frame rate. When omitted, twopy reads
94
+ ``acq.frameRate`` from converted acquisition metadata.
95
+ chunk_frames: Movie frames to read per HDF5 chunk during ROI extraction.
96
+
97
+ Returns:
98
+ ``SavedAnalysisDeltaFOverFComparison`` with computed values and metrics.
99
+
100
+ Raises:
101
+ ValueError: If saved traces, ROI masks, or windows do not agree with the
102
+ converted recording frame contract.
103
+
104
+ The comparison uses saved ROI masks and saved interleave windows, then runs
105
+ the normal twopy analysis path over converted HDF5 data.
106
+ """
107
+ saved_values = _saved_trace_values(saved_analysis, saved_trace_index)
108
+ frame_start, frame_stop = _saved_trace_frame_range(
109
+ recording=recording,
110
+ saved_frame_count=saved_values.shape[0],
111
+ trace_start_frame=trace_start_frame,
112
+ trace_stop_frame=trace_stop_frame,
113
+ )
114
+ roi_set = roi_set_from_saved_analysis(saved_analysis, recording)
115
+ _validate_saved_trace_roi_count(saved_values, roi_set)
116
+
117
+ traces = extract_background_corrected_roi_traces(
118
+ recording,
119
+ roi_set.roi_set,
120
+ method=background_method,
121
+ start_frame=frame_start,
122
+ stop_frame=frame_stop,
123
+ chunk_frames=chunk_frames,
124
+ )
125
+ interleave_windows = saved_analysis_epoch_windows(
126
+ saved_analysis,
127
+ epoch_number=interleave_epoch_number,
128
+ trace_start_frame=frame_start,
129
+ trace_stop_frame=frame_stop,
130
+ )
131
+ computed = compute_roi_delta_f_over_f(
132
+ traces,
133
+ interleave_windows,
134
+ data_rate_hz=(
135
+ _recording_frame_rate_hz(recording)
136
+ if data_rate_hz is None
137
+ else float(data_rate_hz)
138
+ ),
139
+ seconds_interleave_use=seconds_interleave_use,
140
+ fit_mode=fit_mode,
141
+ )
142
+ _validate_comparison_shapes(computed.values, saved_values)
143
+
144
+ absolute_error = np.abs(computed.values - saved_values)
145
+ finite_errors = absolute_error[np.isfinite(absolute_error)]
146
+ return SavedAnalysisDeltaFOverFComparison(
147
+ computed=computed,
148
+ saved_values=saved_values,
149
+ absolute_error=absolute_error,
150
+ correlation_by_roi=_correlation_by_roi(computed.values, saved_values),
151
+ roi_set=roi_set,
152
+ interleave_windows=interleave_windows,
153
+ mean_absolute_error=float(np.mean(finite_errors)),
154
+ median_absolute_error=float(np.median(finite_errors)),
155
+ max_absolute_error=float(np.max(finite_errors)),
156
+ )
157
+
158
+
159
+ def saved_analysis_epoch_windows(
160
+ saved_analysis: SavedAnalysisLastRoi,
161
+ *,
162
+ epoch_number: int,
163
+ trace_start_frame: int,
164
+ trace_stop_frame: int,
165
+ ) -> tuple[FrameWindow, ...]:
166
+ """Convert saved epoch starts and durations to movie-frame windows.
167
+
168
+ Args:
169
+ saved_analysis: Read-only saved-analysis data.
170
+ epoch_number: One-based saved epoch number.
171
+ trace_start_frame: Absolute movie frame for saved trace row zero.
172
+ trace_stop_frame: Exclusive absolute movie frame after the saved trace.
173
+
174
+ Returns:
175
+ Tuple of ``FrameWindow`` objects in absolute movie coordinates.
176
+
177
+ Saved epoch starts are one-based row numbers inside the saved trace matrix.
178
+ twopy analysis uses zero-based half-open movie-frame windows, so each saved
179
+ start is shifted by ``trace_start_frame - 1`` and each duration defines the
180
+ exclusive stop.
181
+ """
182
+ epoch_index = epoch_number - 1
183
+ if epoch_index < 0 or epoch_index >= len(saved_analysis.epoch_start_times):
184
+ msg = (
185
+ f"Saved analysis has {len(saved_analysis.epoch_start_times)} epochs; "
186
+ f"cannot read epoch {epoch_number}"
187
+ )
188
+ raise ValueError(msg)
189
+
190
+ starts = saved_analysis.epoch_start_times[epoch_index]
191
+ durations = saved_analysis.epoch_durations[epoch_index]
192
+ if starts.shape != durations.shape:
193
+ msg = (
194
+ f"Saved epoch {epoch_number} starts and durations differ: "
195
+ f"{starts.shape} versus {durations.shape}"
196
+ )
197
+ raise ValueError(msg)
198
+
199
+ windows: list[FrameWindow] = []
200
+ for index, (start, duration) in enumerate(zip(starts, durations, strict=True)):
201
+ local_start = int(start) - 1
202
+ duration_frames = int(duration)
203
+ absolute_start = trace_start_frame + local_start
204
+ absolute_stop = absolute_start + duration_frames
205
+ if local_start < 0 or duration_frames <= 0 or absolute_stop > trace_stop_frame:
206
+ msg = (
207
+ f"Saved epoch {epoch_number} window {index} is outside trace "
208
+ f"range [{trace_start_frame}, {trace_stop_frame}): "
209
+ f"start={int(start)}, duration={duration_frames}"
210
+ )
211
+ raise ValueError(msg)
212
+ windows.append(
213
+ FrameWindow(
214
+ index=index,
215
+ start_frame=absolute_start,
216
+ stop_frame=absolute_stop,
217
+ label=f"saved_epoch_{epoch_number:04d}_{index + 1:04d}",
218
+ ),
219
+ )
220
+ return tuple(windows)
221
+
222
+
223
+ def _saved_trace_values(
224
+ saved_analysis: SavedAnalysisLastRoi,
225
+ saved_trace_index: int,
226
+ ) -> npt.NDArray[np.float64]:
227
+ """Return one saved trace matrix as float64 values.
228
+
229
+ Args:
230
+ saved_analysis: Read-only saved-analysis data.
231
+ saved_trace_index: Trace cell index to load.
232
+
233
+ Returns:
234
+ Saved trace matrix shaped ``(frames, rois)``.
235
+ """
236
+ if saved_trace_index < 0 or saved_trace_index >= len(
237
+ saved_analysis.time_by_rois_initial,
238
+ ):
239
+ msg = (
240
+ f"Saved analysis has {len(saved_analysis.time_by_rois_initial)} "
241
+ f"trace matrices; cannot read index {saved_trace_index}"
242
+ )
243
+ raise ValueError(msg)
244
+ values = np.asarray(
245
+ saved_analysis.time_by_rois_initial[saved_trace_index],
246
+ dtype=np.float64,
247
+ )
248
+ if values.ndim != 2:
249
+ msg = f"Saved trace matrix must have shape (frames, rois); got {values.shape}"
250
+ raise ValueError(msg)
251
+ return values
252
+
253
+
254
+ def _saved_trace_frame_range(
255
+ *,
256
+ recording: RecordingData,
257
+ saved_frame_count: int,
258
+ trace_start_frame: int | None,
259
+ trace_stop_frame: int | None,
260
+ ) -> tuple[int, int]:
261
+ """Resolve the absolute movie-frame range for saved trace rows.
262
+
263
+ Args:
264
+ recording: Converted recording.
265
+ saved_frame_count: Number of saved trace rows.
266
+ trace_start_frame: Optional explicit start frame.
267
+ trace_stop_frame: Optional explicit stop frame.
268
+
269
+ Returns:
270
+ ``(start, stop)`` in absolute movie-frame coordinates.
271
+ """
272
+ if trace_start_frame is None:
273
+ if trace_stop_frame is not None:
274
+ msg = "trace_stop_frame requires trace_start_frame"
275
+ raise ValueError(msg)
276
+ mapping = interpolate_stimulus_epochs_to_frame_windows(recording)
277
+ start = mapping.start_frame
278
+ stop = mapping.stop_frame
279
+ if stop - start != saved_frame_count:
280
+ msg = (
281
+ "Saved trace length does not match interpolated stimulus frame "
282
+ f"range: saved={saved_frame_count}, inferred={stop - start}"
283
+ )
284
+ raise ValueError(msg)
285
+ return start, stop
286
+
287
+ start = int(trace_start_frame)
288
+ stop = (
289
+ start + saved_frame_count if trace_stop_frame is None else int(trace_stop_frame)
290
+ )
291
+ if stop - start != saved_frame_count:
292
+ msg = (
293
+ "Saved trace frame range length must match saved rows: "
294
+ f"range={stop - start}, saved={saved_frame_count}"
295
+ )
296
+ raise ValueError(msg)
297
+ if start < 0 or stop > recording.movie.shape[0] or start >= stop:
298
+ msg = (
299
+ f"Saved trace frame range [{start}, {stop}) is invalid for "
300
+ f"{recording.movie.shape[0]} movie frames"
301
+ )
302
+ raise ValueError(msg)
303
+ return start, stop
304
+
305
+
306
+ def _validate_saved_trace_roi_count(
307
+ saved_values: npt.NDArray[np.float64],
308
+ roi_set: SavedAnalysisRoiSet,
309
+ ) -> None:
310
+ """Confirm saved traces and saved ROI masks describe the same ROI count.
311
+
312
+ Args:
313
+ saved_values: Saved trace matrix.
314
+ roi_set: ROI set derived from the saved mask.
315
+
316
+ Returns:
317
+ None.
318
+ """
319
+ roi_count = roi_set.roi_set.masks.shape[0]
320
+ if saved_values.shape[1] != roi_count:
321
+ msg = (
322
+ "Saved trace ROI count does not match saved ROI mask labels: "
323
+ f"traces={saved_values.shape[1]}, masks={roi_count}"
324
+ )
325
+ raise ValueError(msg)
326
+
327
+
328
+ def _validate_comparison_shapes(
329
+ computed_values: npt.NDArray[np.float64],
330
+ saved_values: npt.NDArray[np.float64],
331
+ ) -> None:
332
+ """Confirm computed and saved dF/F matrices can be compared.
333
+
334
+ Args:
335
+ computed_values: twopy-computed dF/F values.
336
+ saved_values: Saved trace matrix.
337
+
338
+ Returns:
339
+ None.
340
+ """
341
+ if computed_values.shape != saved_values.shape:
342
+ msg = (
343
+ "Computed dF/F shape does not match saved traces: "
344
+ f"computed={computed_values.shape}, saved={saved_values.shape}"
345
+ )
346
+ raise ValueError(msg)
347
+
348
+
349
+ def _recording_frame_rate_hz(recording: RecordingData) -> float:
350
+ """Read the imaging frame rate from converted recording metadata.
351
+
352
+ Args:
353
+ recording: Converted recording with acquisition metadata.
354
+
355
+ Returns:
356
+ Frame rate in hertz.
357
+ """
358
+ try:
359
+ value = recording.acquisition_metadata["acq.frameRate"]
360
+ except KeyError as error:
361
+ msg = "Recording metadata is missing required field 'acq.frameRate'"
362
+ raise ValueError(msg) from error
363
+ if isinstance(value, str | bytes | int | float | np.generic):
364
+ return float(value)
365
+
366
+ value_type = type(value).__name__
367
+ msg = f"Recording metadata field 'acq.frameRate' must be numeric, got {value_type}"
368
+ raise ValueError(msg)
369
+
370
+
371
+ def _correlation_by_roi(
372
+ computed_values: npt.NDArray[np.float64],
373
+ saved_values: npt.NDArray[np.float64],
374
+ ) -> npt.NDArray[np.float64]:
375
+ """Compute one Pearson correlation per ROI.
376
+
377
+ Args:
378
+ computed_values: twopy-computed dF/F matrix.
379
+ saved_values: Saved dF/F matrix.
380
+
381
+ Returns:
382
+ One correlation per ROI, with ``NaN`` when either trace is constant or
383
+ has fewer than two finite samples.
384
+ """
385
+ correlations = np.full(computed_values.shape[1], np.nan, dtype=np.float64)
386
+ for roi_index in range(computed_values.shape[1]):
387
+ computed = computed_values[:, roi_index]
388
+ saved = saved_values[:, roi_index]
389
+ finite = np.isfinite(computed) & np.isfinite(saved)
390
+ if np.count_nonzero(finite) < 2:
391
+ continue
392
+ computed_finite = computed[finite]
393
+ saved_finite = saved[finite]
394
+ if np.std(computed_finite) == 0 or np.std(saved_finite) == 0:
395
+ continue
396
+ correlations[roi_index] = float(
397
+ np.corrcoef(computed_finite, saved_finite)[0, 1],
398
+ )
399
+ return correlations
twopy/parity/roi.py ADDED
@@ -0,0 +1,145 @@
1
+ """Convert prior saved ROI masks into twopy ROI sets for parity checks.
2
+
3
+ Inputs: read-only saved-analysis ROI label images and converted recording
4
+ metadata.
5
+ Outputs: normal ``RoiSet`` objects that twopy analysis functions can consume.
6
+
7
+ This module is only for parity work. It lets audit scripts reuse historical ROI
8
+ masks while keeping normal analysis inputs in twopy-owned objects.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import numpy as np
14
+ import numpy.typing as npt
15
+
16
+ from twopy.converted import RecordingData
17
+ from twopy.parity.saved_analysis import SavedAnalysisLastRoi
18
+ from twopy.roi import RoiSet, make_roi_set_from_label_image
19
+
20
+ __all__ = [
21
+ "SavedAnalysisRoiSet",
22
+ "roi_set_from_saved_analysis",
23
+ ]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SavedAnalysisRoiSet:
28
+ """A twopy ROI set plus provenance from a saved ROI label image.
29
+
30
+ Inputs: one saved ROI label image and one converted recording.
31
+ Outputs: full-frame ``RoiSet`` masks, original numeric ROI labels, and a
32
+ note describing whether the source mask was already full-frame or embedded
33
+ from the alignment-valid crop.
34
+
35
+ The provenance fields make parity comparisons auditable without changing
36
+ the normal ``RoiSet`` contract.
37
+ """
38
+
39
+ roi_set: RoiSet
40
+ source_label_values: tuple[int, ...]
41
+ source_shape: tuple[int, int]
42
+ spatial_source: str
43
+
44
+
45
+ def roi_set_from_saved_analysis(
46
+ saved_analysis: SavedAnalysisLastRoi,
47
+ recording: RecordingData,
48
+ ) -> SavedAnalysisRoiSet:
49
+ """Build a full-frame twopy ROI set from a saved ``lastRoi`` mask.
50
+
51
+ Args:
52
+ saved_analysis: Loaded saved-analysis object with ``roi_mask_initial``.
53
+ recording: Converted recording that defines the movie shape and saved
54
+ alignment-valid crop.
55
+
56
+ Returns:
57
+ ``SavedAnalysisRoiSet`` with masks in full movie coordinates.
58
+
59
+ Raises:
60
+ ValueError: If the label image shape is not the movie shape or the
61
+ alignment-valid crop shape, or if ROI labels are not positive
62
+ integer-like values.
63
+
64
+ Historical ROI masks from the example saved outputs are crop-domain label
65
+ images. twopy ROI masks are full-frame, so crop-shaped masks are embedded
66
+ back into the converted movie coordinates before analysis.
67
+ """
68
+ label_image = _integer_label_image(saved_analysis.roi_mask_initial)
69
+ full_frame_labels, spatial_source = _label_image_in_movie_coordinates(
70
+ label_image,
71
+ recording,
72
+ )
73
+ label_values = tuple(
74
+ int(value) for value in np.unique(full_frame_labels) if value > 0
75
+ )
76
+ roi_set = make_roi_set_from_label_image(
77
+ full_frame_labels,
78
+ labels=tuple(f"saved_roi_{label_value:04d}" for label_value in label_values),
79
+ )
80
+ return SavedAnalysisRoiSet(
81
+ roi_set=roi_set,
82
+ source_label_values=label_values,
83
+ source_shape=saved_analysis.roi_mask_initial.shape,
84
+ spatial_source=spatial_source,
85
+ )
86
+
87
+
88
+ def _integer_label_image(label_image: npt.NDArray[np.float64]) -> npt.NDArray[np.int64]:
89
+ """Return a saved ROI label image as integer labels.
90
+
91
+ Args:
92
+ label_image: Two-dimensional numeric ROI label image.
93
+
94
+ Returns:
95
+ Integer label image with nonfinite values treated as background.
96
+ """
97
+ values = np.asarray(label_image, dtype=np.float64)
98
+ if values.ndim != 2:
99
+ msg = f"Saved ROI mask must be two-dimensional; got {values.shape}"
100
+ raise ValueError(msg)
101
+
102
+ finite = np.isfinite(values)
103
+ rounded = np.rint(values)
104
+ if not np.allclose(values[finite], rounded[finite]):
105
+ msg = "Saved ROI mask labels must be integer-like values"
106
+ raise ValueError(msg)
107
+ labels = np.zeros(values.shape, dtype=np.int64)
108
+ labels[finite] = rounded[finite].astype(np.int64)
109
+ if np.any(labels < 0):
110
+ msg = "Saved ROI mask labels cannot be negative"
111
+ raise ValueError(msg)
112
+ return labels
113
+
114
+
115
+ def _label_image_in_movie_coordinates(
116
+ label_image: npt.NDArray[np.int64],
117
+ recording: RecordingData,
118
+ ) -> tuple[npt.NDArray[np.int64], str]:
119
+ """Return a label image in full movie coordinates.
120
+
121
+ Args:
122
+ label_image: Integer saved ROI label image.
123
+ recording: Converted recording that defines full-frame and crop shapes.
124
+
125
+ Returns:
126
+ Full-frame label image plus the source coordinate description.
127
+ """
128
+ movie_shape = recording.movie.shape[1:]
129
+ if label_image.shape == movie_shape:
130
+ return label_image, "full_frame"
131
+
132
+ crop = recording.alignment_valid_crop
133
+ if label_image.shape == crop.shape:
134
+ full_frame = np.zeros(movie_shape, dtype=np.int64)
135
+ full_frame[
136
+ crop.axis0_start : crop.axis0_stop,
137
+ crop.axis1_start : crop.axis1_stop,
138
+ ] = label_image
139
+ return full_frame, "alignment_valid_crop"
140
+
141
+ msg = (
142
+ "Saved ROI mask shape must match the movie frame or alignment-valid "
143
+ f"crop; got {label_image.shape}, movie={movie_shape}, crop={crop.shape}"
144
+ )
145
+ raise ValueError(msg)