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,672 @@
1
+ """Background correction for ROI fluorescence traces.
2
+
3
+ Inputs: converted aligned movie frames, ROI masks, and correction parameters.
4
+ Outputs: raw ROI traces, estimated background traces, corrected traces, and
5
+ method metadata.
6
+
7
+ This module owns analysis-time background correction. ROI storage and raw ROI
8
+ trace extraction stay in ``twopy.roi``; dF/F and trial analysis can consume the
9
+ auditable outputs produced here.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ from twopy.converted import RecordingData
19
+ from twopy.roi import RoiSet, TraceStatistic, validate_trace_statistic
20
+ from twopy.spatial import SpatialCrop, SpatialDomain, full_frame_crop
21
+
22
+ __all__ = [
23
+ "BackgroundCorrectedRoiTraces",
24
+ "BackgroundCorrectionMethod",
25
+ "extract_background_corrected_roi_traces",
26
+ ]
27
+
28
+ BackgroundCorrectionMethod = Literal[
29
+ "none",
30
+ "movie_global_percentile",
31
+ "roi_local_percentile_y",
32
+ ]
33
+ BackgroundMetadataValue = str | int | float | bool
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class BackgroundCorrectedRoiTraces:
38
+ """ROI traces with explicit background correction outputs.
39
+
40
+ Inputs: one converted recording, one ROI set, and one background correction
41
+ method.
42
+ Outputs: raw fluorescence, estimated background, corrected fluorescence,
43
+ labels, frame bounds, and method metadata.
44
+
45
+ Keeping all three arrays makes the correction auditable: downstream dF/F
46
+ can use ``corrected_values`` while plots and tests can still inspect the
47
+ raw signal and the subtracted background.
48
+ """
49
+
50
+ raw_values: npt.NDArray[np.float64]
51
+ background_values: npt.NDArray[np.float64]
52
+ corrected_values: npt.NDArray[np.float64]
53
+ labels: tuple[str, ...]
54
+ start_frame: int
55
+ stop_frame: int
56
+ statistic: TraceStatistic
57
+ method: BackgroundCorrectionMethod
58
+ metadata: dict[str, BackgroundMetadataValue]
59
+
60
+
61
+ def extract_background_corrected_roi_traces(
62
+ recording: RecordingData,
63
+ roi_set: RoiSet,
64
+ *,
65
+ method: BackgroundCorrectionMethod = "none",
66
+ start_frame: int | None = None,
67
+ stop_frame: int | None = None,
68
+ chunk_frames: int = 128,
69
+ statistic: TraceStatistic = "mean",
70
+ global_percentile: float = 10.0,
71
+ local_y_radius: int | None = None,
72
+ local_percentile: float = 20.0,
73
+ spatial_domain: SpatialDomain = "alignment_valid_crop",
74
+ ) -> BackgroundCorrectedRoiTraces:
75
+ """Extract ROI traces plus an explicit background correction.
76
+
77
+ Args:
78
+ recording: Loaded converted recording with lazy aligned movie access.
79
+ roi_set: ROI masks in aligned movie spatial coordinates.
80
+ method: Background correction method. ``"none"`` keeps raw traces inside
81
+ the selected spatial domain, ``"movie_global_percentile"`` subtracts
82
+ a framewise background estimated from dim global pixels in the mean
83
+ image, and
84
+ ``"roi_local_percentile_y"`` subtracts a local y-neighborhood
85
+ background trace from each ROI.
86
+ start_frame: Optional first frame to extract.
87
+ stop_frame: Optional exclusive stop frame.
88
+ chunk_frames: Number of movie frames to read per chunk.
89
+ statistic: Pixel statistic inside each ROI. Currently only ``mean`` is
90
+ supported.
91
+ global_percentile: Percentile of the recording mean image used to pick
92
+ global background pixels.
93
+ local_y_radius: Half-width in rows for local-y background pixels. When
94
+ omitted, twopy uses roughly five percent of the image height.
95
+ local_percentile: Percentile inside each local row window used to keep
96
+ dim background pixels and reject bright non-ROI structures.
97
+ spatial_domain: Spatial region used for analysis extraction. The default
98
+ uses the alignment-valid crop saved during conversion, matching the
99
+ crop-domain analysis contract while keeping ROI masks stored in
100
+ full-frame coordinates.
101
+
102
+ Returns:
103
+ ``BackgroundCorrectedRoiTraces`` with raw, background, and corrected
104
+ frame-by-ROI arrays.
105
+
106
+ Raises:
107
+ ValueError: If masks do not match the movie or a background mask is
108
+ empty.
109
+
110
+ The function streams movie chunks from HDF5 and never loads the full movie
111
+ into memory. Each method records enough metadata to audit how the background
112
+ values were produced.
113
+ """
114
+ _validate_background_method(method)
115
+ validate_trace_statistic(statistic)
116
+ _validate_roi_movie_shape(roi_set, recording)
117
+
118
+ frame_start, frame_stop = _normalize_trace_frame_range(
119
+ frame_count=recording.movie.shape[0],
120
+ start_frame=start_frame,
121
+ stop_frame=stop_frame,
122
+ )
123
+
124
+ crop = _resolve_spatial_domain(recording, spatial_domain)
125
+ cropped_roi_masks = _crop_roi_masks_for_domain(roi_set, crop)
126
+ roi_pixel_indices = _roi_pixel_indices_from_masks(cropped_roi_masks)
127
+
128
+ if method == "none":
129
+ raw_values = _extract_mean_traces_from_indices(
130
+ recording=recording,
131
+ pixel_indices_by_trace=roi_pixel_indices,
132
+ frame_start=frame_start,
133
+ frame_stop=frame_stop,
134
+ chunk_frames=chunk_frames,
135
+ spatial_crop=crop,
136
+ )
137
+ background_values = np.zeros_like(raw_values)
138
+ corrected_values = raw_values.copy()
139
+ metadata: dict[str, BackgroundMetadataValue] = {
140
+ "method": method,
141
+ **_spatial_crop_metadata(spatial_domain, crop),
142
+ }
143
+ elif method == "movie_global_percentile":
144
+ raw_values, background_values, corrected_values, metadata = (
145
+ _extract_movie_global_percentile_corrected_traces(
146
+ recording=recording,
147
+ roi_pixel_indices=roi_pixel_indices,
148
+ frame_start=frame_start,
149
+ frame_stop=frame_stop,
150
+ chunk_frames=chunk_frames,
151
+ percentile=global_percentile,
152
+ spatial_crop=crop,
153
+ spatial_domain=spatial_domain,
154
+ )
155
+ )
156
+ elif method == "roi_local_percentile_y":
157
+ raw_values, background_values, corrected_values, metadata = (
158
+ _extract_roi_local_y_corrected_traces(
159
+ recording=recording,
160
+ roi_masks=cropped_roi_masks,
161
+ roi_pixel_indices=roi_pixel_indices,
162
+ frame_start=frame_start,
163
+ frame_stop=frame_stop,
164
+ chunk_frames=chunk_frames,
165
+ local_y_radius=local_y_radius,
166
+ local_percentile=local_percentile,
167
+ spatial_crop=crop,
168
+ spatial_domain=spatial_domain,
169
+ )
170
+ )
171
+
172
+ return BackgroundCorrectedRoiTraces(
173
+ raw_values=raw_values,
174
+ background_values=background_values,
175
+ corrected_values=corrected_values,
176
+ labels=roi_set.labels,
177
+ start_frame=frame_start,
178
+ stop_frame=frame_stop,
179
+ statistic=statistic,
180
+ method=method,
181
+ metadata=metadata,
182
+ )
183
+
184
+
185
+ def _validate_background_method(method: BackgroundCorrectionMethod) -> None:
186
+ """Validate the requested background correction method.
187
+
188
+ Args:
189
+ method: Background correction method requested by the caller.
190
+
191
+ Returns:
192
+ None.
193
+
194
+ Raises:
195
+ ValueError: If ``method`` names behavior twopy does not implement.
196
+
197
+ Runtime validation keeps untyped scripts from falling through the branch
198
+ table and getting a misleading low-level error.
199
+ """
200
+ allowed_methods = {
201
+ "none",
202
+ "movie_global_percentile",
203
+ "roi_local_percentile_y",
204
+ }
205
+ if method not in allowed_methods:
206
+ msg = (
207
+ f"Unknown background correction method {method!r}; "
208
+ f"supported methods: {', '.join(sorted(allowed_methods))}"
209
+ )
210
+ raise ValueError(msg)
211
+
212
+
213
+ def _extract_mean_traces_from_indices(
214
+ *,
215
+ recording: RecordingData,
216
+ pixel_indices_by_trace: tuple[npt.NDArray[np.int64], ...],
217
+ frame_start: int,
218
+ frame_stop: int,
219
+ chunk_frames: int,
220
+ spatial_crop: SpatialCrop,
221
+ ) -> npt.NDArray[np.float64]:
222
+ """Extract mean traces for arbitrary flattened pixel-index groups.
223
+
224
+ Args:
225
+ recording: Converted recording with lazy movie batches.
226
+ pixel_indices_by_trace: One flattened pixel-index array per output
227
+ trace.
228
+ frame_start: First frame to read.
229
+ frame_stop: Exclusive stop frame.
230
+ chunk_frames: Maximum number of frames per HDF5 read.
231
+ spatial_crop: Spatial domain read from each movie frame.
232
+
233
+ Returns:
234
+ Array shaped ``(frames, traces)``.
235
+
236
+ This shared helper is used for raw ROI traces and background traces. It
237
+ streams one movie chunk at a time so memory scales with ``chunk_frames``
238
+ rather than total recording length.
239
+ """
240
+ traces = np.empty((frame_stop - frame_start, len(pixel_indices_by_trace)))
241
+ for chunk_start, chunk_stop, frames in recording.movie.iter_frame_batches(
242
+ chunk_frames=chunk_frames,
243
+ start=frame_start,
244
+ stop=frame_stop,
245
+ spatial_crop=spatial_crop,
246
+ ):
247
+ chunk_offset = chunk_start - frame_start
248
+ chunk_slice = slice(chunk_offset, chunk_offset + (chunk_stop - chunk_start))
249
+ chunk_flat = frames.reshape(frames.shape[0], -1)
250
+ for trace_index, pixel_indices in enumerate(pixel_indices_by_trace):
251
+ traces[chunk_slice, trace_index] = chunk_flat[:, pixel_indices].mean(
252
+ axis=1,
253
+ )
254
+
255
+ return traces
256
+
257
+
258
+ def _extract_movie_global_percentile_corrected_traces(
259
+ *,
260
+ recording: RecordingData,
261
+ roi_pixel_indices: tuple[npt.NDArray[np.int64], ...],
262
+ frame_start: int,
263
+ frame_stop: int,
264
+ chunk_frames: int,
265
+ percentile: float,
266
+ spatial_crop: SpatialCrop,
267
+ spatial_domain: SpatialDomain,
268
+ ) -> tuple[
269
+ npt.NDArray[np.float64],
270
+ npt.NDArray[np.float64],
271
+ npt.NDArray[np.float64],
272
+ dict[str, BackgroundMetadataValue],
273
+ ]:
274
+ """Extract global movie-background-corrected traces.
275
+
276
+ Args:
277
+ recording: Converted recording with mean image and aligned movie.
278
+ roi_pixel_indices: Flattened pixel indices for each ROI.
279
+ frame_start: First frame to read.
280
+ frame_stop: Exclusive stop frame.
281
+ chunk_frames: Maximum number of frames per HDF5 read.
282
+ percentile: Mean-image percentile used to define background pixels.
283
+ spatial_crop: Spatial domain used for background and ROI pixels.
284
+ spatial_domain: User-facing name of ``spatial_crop``.
285
+
286
+ Returns:
287
+ Raw ROI traces, repeated background traces, corrected ROI traces, and
288
+ audit metadata.
289
+
290
+ This subtracts one framewise background value from each ROI pixel, clamps
291
+ negatives to zero, and then averages corrected pixels inside each ROI.
292
+ """
293
+ _validate_percentile(percentile)
294
+ mean_image = spatial_crop.crop_image(recording.mean_image)
295
+ background_indices, threshold = _global_background_pixel_indices(
296
+ mean_image,
297
+ percentile=percentile,
298
+ )
299
+ frame_count = frame_stop - frame_start
300
+ roi_count = len(roi_pixel_indices)
301
+ raw_values = np.empty((frame_count, roi_count), dtype=np.float64)
302
+ background_values = np.empty((frame_count, roi_count), dtype=np.float64)
303
+ corrected_values = np.empty((frame_count, roi_count), dtype=np.float64)
304
+
305
+ for chunk_start, chunk_stop, frames in recording.movie.iter_frame_batches(
306
+ chunk_frames=chunk_frames,
307
+ start=frame_start,
308
+ stop=frame_stop,
309
+ spatial_crop=spatial_crop,
310
+ ):
311
+ chunk_offset = chunk_start - frame_start
312
+ chunk_slice = slice(chunk_offset, chunk_offset + (chunk_stop - chunk_start))
313
+ chunk_flat = frames.reshape(frames.shape[0], -1)
314
+ background_trace = chunk_flat[:, background_indices].mean(axis=1)
315
+ for roi_index, pixel_indices in enumerate(roi_pixel_indices):
316
+ roi_pixels = chunk_flat[:, pixel_indices]
317
+ raw_values[chunk_slice, roi_index] = roi_pixels.mean(axis=1)
318
+ background_values[chunk_slice, roi_index] = background_trace
319
+ # Clamp pixels before averaging so dim pixels cannot pull the
320
+ # corrected ROI trace below zero.
321
+ corrected_pixels = np.maximum(roi_pixels - background_trace[:, None], 0.0)
322
+ corrected_values[chunk_slice, roi_index] = corrected_pixels.mean(axis=1)
323
+
324
+ metadata: dict[str, BackgroundMetadataValue] = {
325
+ "method": "movie_global_percentile",
326
+ "global_percentile": float(percentile),
327
+ "background_threshold": threshold,
328
+ "background_pixel_count": int(background_indices.size),
329
+ "negative_values_clamped": True,
330
+ **_spatial_crop_metadata(spatial_domain, spatial_crop),
331
+ }
332
+ return raw_values, background_values, corrected_values, metadata
333
+
334
+
335
+ def _extract_roi_local_y_corrected_traces(
336
+ *,
337
+ recording: RecordingData,
338
+ roi_masks: npt.NDArray[np.bool_],
339
+ roi_pixel_indices: tuple[npt.NDArray[np.int64], ...],
340
+ frame_start: int,
341
+ frame_stop: int,
342
+ chunk_frames: int,
343
+ local_y_radius: int | None,
344
+ local_percentile: float,
345
+ spatial_crop: SpatialCrop,
346
+ spatial_domain: SpatialDomain,
347
+ ) -> tuple[
348
+ npt.NDArray[np.float64],
349
+ npt.NDArray[np.float64],
350
+ npt.NDArray[np.float64],
351
+ dict[str, BackgroundMetadataValue],
352
+ ]:
353
+ """Extract ROI traces corrected by local y-neighborhood background.
354
+
355
+ Args:
356
+ recording: Converted recording with lazy movie batches.
357
+ roi_masks: ROI masks in the selected spatial domain.
358
+ roi_pixel_indices: Flattened pixel indices for each ROI.
359
+ frame_start: First frame to read.
360
+ frame_stop: Exclusive stop frame.
361
+ chunk_frames: Maximum number of frames per HDF5 read.
362
+ local_y_radius: Optional row half-width for local background pixels.
363
+ local_percentile: Percentile used to keep dim local background pixels.
364
+ spatial_crop: Spatial domain used for background and ROI pixels.
365
+ spatial_domain: User-facing name of ``spatial_crop``.
366
+
367
+ Returns:
368
+ Raw ROI traces, local background traces, corrected traces, and metadata.
369
+
370
+ The method estimates a background trace per ROI from dim pixels that are
371
+ outside every ROI and close to that ROI along the scanline axis. It is
372
+ designed for stimulus bleedthrough that varies across rows without
373
+ requiring relaxation labeling or connected-component heuristics.
374
+ """
375
+ radius = _resolve_local_y_radius(
376
+ row_count=roi_masks.shape[1],
377
+ local_y_radius=local_y_radius,
378
+ )
379
+ _validate_percentile(local_percentile)
380
+ background_indices_by_roi = _local_y_background_pixel_indices(
381
+ roi_masks=roi_masks,
382
+ mean_image=spatial_crop.crop_image(recording.mean_image),
383
+ local_y_radius=radius,
384
+ local_percentile=local_percentile,
385
+ )
386
+ raw_values = _extract_mean_traces_from_indices(
387
+ recording=recording,
388
+ pixel_indices_by_trace=roi_pixel_indices,
389
+ frame_start=frame_start,
390
+ frame_stop=frame_stop,
391
+ chunk_frames=chunk_frames,
392
+ spatial_crop=spatial_crop,
393
+ )
394
+ background_values = _extract_mean_traces_from_indices(
395
+ recording=recording,
396
+ pixel_indices_by_trace=background_indices_by_roi,
397
+ frame_start=frame_start,
398
+ frame_stop=frame_stop,
399
+ chunk_frames=chunk_frames,
400
+ spatial_crop=spatial_crop,
401
+ )
402
+ corrected_values = raw_values - background_values
403
+ background_counts = tuple(
404
+ int(pixel_indices.size) for pixel_indices in background_indices_by_roi
405
+ )
406
+ metadata: dict[str, BackgroundMetadataValue] = {
407
+ "method": "roi_local_percentile_y",
408
+ "local_y_radius": radius,
409
+ "local_percentile": float(local_percentile),
410
+ "background_pixel_count_min": min(background_counts),
411
+ "background_pixel_count_max": max(background_counts),
412
+ "negative_values_clamped": False,
413
+ **_spatial_crop_metadata(spatial_domain, spatial_crop),
414
+ }
415
+ return raw_values, background_values, corrected_values, metadata
416
+
417
+
418
+ def _roi_pixel_indices_from_masks(
419
+ masks: npt.NDArray[np.bool_],
420
+ ) -> tuple[npt.NDArray[np.int64], ...]:
421
+ """Flatten ROI masks from one spatial domain into pixel-index arrays.
422
+
423
+ Args:
424
+ masks: Boolean ROI masks shaped ``(rois, axis0, axis1)``.
425
+
426
+ Returns:
427
+ Tuple with one integer index array per ROI.
428
+ """
429
+ flat_masks = masks.reshape(masks.shape[0], -1)
430
+ return tuple(np.flatnonzero(mask).astype(np.int64) for mask in flat_masks)
431
+
432
+
433
+ def _crop_roi_masks_for_domain(
434
+ roi_set: RoiSet,
435
+ spatial_crop: SpatialCrop,
436
+ ) -> npt.NDArray[np.bool_]:
437
+ """Crop ROI masks and require every ROI pixel to remain inside the domain.
438
+
439
+ Args:
440
+ roi_set: Full-frame ROI masks.
441
+ spatial_crop: Spatial domain selected for analysis.
442
+
443
+ Returns:
444
+ ROI masks restricted to ``spatial_crop``.
445
+
446
+ The check prevents a quiet coordinate error: if an ROI crosses the invalid
447
+ alignment border, crop-domain analysis would otherwise drop pixels silently.
448
+ """
449
+ cropped_masks = spatial_crop.crop_masks(roi_set.masks)
450
+ full_counts = roi_set.masks.reshape(roi_set.masks.shape[0], -1).sum(axis=1)
451
+ cropped_counts = cropped_masks.reshape(cropped_masks.shape[0], -1).sum(axis=1)
452
+ outside = np.flatnonzero(full_counts != cropped_counts)
453
+ if outside.size:
454
+ msg = (
455
+ "ROI pixels fall outside the selected spatial domain "
456
+ f"{spatial_crop.source}; affected ROI indices: {outside.tolist()}"
457
+ )
458
+ raise ValueError(msg)
459
+ return cropped_masks
460
+
461
+
462
+ def _validate_roi_movie_shape(roi_set: RoiSet, recording: RecordingData) -> None:
463
+ """Confirm ROI masks match the converted movie shape.
464
+
465
+ Args:
466
+ roi_set: ROI masks being used for trace extraction.
467
+ recording: Converted recording with movie shape metadata.
468
+
469
+ Returns:
470
+ None.
471
+ """
472
+ if roi_set.masks.shape[1:] != recording.movie.shape[1:]:
473
+ msg = (
474
+ f"ROI mask shape {roi_set.masks.shape[1:]} does not match movie "
475
+ f"shape {recording.movie.shape[1:]}"
476
+ )
477
+ raise ValueError(msg)
478
+
479
+
480
+ def _validate_percentile(percentile: float) -> None:
481
+ """Validate a percentile used to define background pixels.
482
+
483
+ Args:
484
+ percentile: Percentile in the open interval ``(0, 100)``.
485
+
486
+ Returns:
487
+ None.
488
+
489
+ Strict bounds avoid empty masks from the active ``mean_image < threshold``
490
+ rule at the extremes.
491
+ """
492
+ if not 0.0 < percentile < 100.0:
493
+ msg = f"Background percentile must be between 0 and 100; got {percentile}"
494
+ raise ValueError(msg)
495
+
496
+
497
+ def _global_background_pixel_indices(
498
+ mean_image: npt.NDArray[np.float64],
499
+ *,
500
+ percentile: float,
501
+ ) -> tuple[npt.NDArray[np.int64], float]:
502
+ """Find global background pixels from a mean image.
503
+
504
+ Args:
505
+ mean_image: Mean image from the converted recording.
506
+ percentile: Percentile threshold used to select dim pixels.
507
+
508
+ Returns:
509
+ Flattened background pixel indices and the numeric threshold.
510
+
511
+ Raises:
512
+ ValueError: If no pixels are below the percentile threshold.
513
+ """
514
+ threshold = float(np.percentile(mean_image, percentile))
515
+ background_mask = mean_image < threshold
516
+ background_indices = np.flatnonzero(background_mask.reshape(-1)).astype(np.int64)
517
+ if background_indices.size == 0:
518
+ msg = (
519
+ "Global background mask is empty. Use a higher percentile or inspect "
520
+ "the recording mean image."
521
+ )
522
+ raise ValueError(msg)
523
+ return background_indices, threshold
524
+
525
+
526
+ def _resolve_local_y_radius(
527
+ *,
528
+ row_count: int,
529
+ local_y_radius: int | None,
530
+ ) -> int:
531
+ """Resolve the row radius for local-y background correction.
532
+
533
+ Args:
534
+ row_count: Number of image rows.
535
+ local_y_radius: Optional caller-provided radius.
536
+
537
+ Returns:
538
+ Nonnegative row radius.
539
+ """
540
+ radius = max(1, round(row_count / 20)) if local_y_radius is None else local_y_radius
541
+ if radius < 0:
542
+ msg = f"local_y_radius must be nonnegative; got {radius}"
543
+ raise ValueError(msg)
544
+ return radius
545
+
546
+
547
+ def _local_y_background_pixel_indices(
548
+ *,
549
+ roi_masks: npt.NDArray[np.bool_],
550
+ mean_image: npt.NDArray[np.float64],
551
+ local_y_radius: int,
552
+ local_percentile: float,
553
+ ) -> tuple[npt.NDArray[np.int64], ...]:
554
+ """Find local y-neighborhood background pixels for each ROI.
555
+
556
+ Args:
557
+ roi_masks: ROI masks used to exclude foreground pixels.
558
+ mean_image: Recording mean image used to choose dim local pixels.
559
+ local_y_radius: Row half-width around each ROI center.
560
+ local_percentile: Percentile used inside the local candidate pixels.
561
+
562
+ Returns:
563
+ Tuple of flattened background pixel-index arrays, one per ROI.
564
+
565
+ Raises:
566
+ ValueError: If any ROI has no background pixels in its local row window.
567
+
568
+ Candidate background pixels are outside every ROI. The local window is
569
+ centered on each ROI's row center, and the percentile threshold keeps dim
570
+ pixels inside that window so bright unlabeled structures do not dominate the
571
+ background trace.
572
+ """
573
+ occupied_mask = np.any(roi_masks, axis=0)
574
+ background_mask = ~occupied_mask
575
+ row_grid = np.arange(background_mask.shape[0])[:, None]
576
+ background_indices: list[npt.NDArray[np.int64]] = []
577
+
578
+ for roi_index, roi_mask in enumerate(roi_masks):
579
+ roi_rows = np.flatnonzero(np.any(roi_mask, axis=1))
580
+ center_row = int(round(float(np.mean(roi_rows))))
581
+ local_rows = np.abs(row_grid - center_row) <= local_y_radius
582
+ local_candidate_mask = background_mask & local_rows
583
+ local_candidate_values = mean_image[local_candidate_mask]
584
+ if local_candidate_values.size == 0:
585
+ msg = (
586
+ f"ROI {roi_index} has no local background candidates within "
587
+ f"{local_y_radius} rows"
588
+ )
589
+ raise ValueError(msg)
590
+ local_threshold = float(np.percentile(local_candidate_values, local_percentile))
591
+ local_background_mask = local_candidate_mask & (mean_image <= local_threshold)
592
+ local_indices = np.flatnonzero(local_background_mask.reshape(-1)).astype(
593
+ np.int64,
594
+ )
595
+ if local_indices.size == 0:
596
+ msg = (
597
+ f"ROI {roi_index} has no local background pixels within "
598
+ f"{local_y_radius} rows"
599
+ )
600
+ raise ValueError(msg)
601
+ background_indices.append(local_indices)
602
+
603
+ return tuple(background_indices)
604
+
605
+
606
+ def _resolve_spatial_domain(
607
+ recording: RecordingData,
608
+ spatial_domain: SpatialDomain,
609
+ ) -> SpatialCrop:
610
+ """Resolve a user-facing spatial domain name into crop bounds.
611
+
612
+ Args:
613
+ recording: Loaded converted recording.
614
+ spatial_domain: Requested spatial domain.
615
+
616
+ Returns:
617
+ ``SpatialCrop`` used for image and movie reads.
618
+ """
619
+ if spatial_domain == "alignment_valid_crop":
620
+ return recording.alignment_valid_crop
621
+ if spatial_domain == "full_frame":
622
+ return full_frame_crop(recording.movie.shape[1:])
623
+ msg = f"Unknown spatial_domain {spatial_domain!r}"
624
+ raise ValueError(msg)
625
+
626
+
627
+ def _spatial_crop_metadata(
628
+ spatial_domain: SpatialDomain,
629
+ spatial_crop: SpatialCrop,
630
+ ) -> dict[str, BackgroundMetadataValue]:
631
+ """Return scalar metadata that records the spatial analysis contract.
632
+
633
+ Args:
634
+ spatial_domain: User-facing domain name.
635
+ spatial_crop: Crop bounds used by analysis.
636
+
637
+ Returns:
638
+ Metadata dictionary safe to store in trace outputs.
639
+ """
640
+ return {
641
+ "spatial_domain": spatial_domain,
642
+ "spatial_axis0_start": spatial_crop.axis0_start,
643
+ "spatial_axis0_stop": spatial_crop.axis0_stop,
644
+ "spatial_axis1_start": spatial_crop.axis1_start,
645
+ "spatial_axis1_stop": spatial_crop.axis1_stop,
646
+ "spatial_original_axis0": spatial_crop.original_shape[0],
647
+ "spatial_original_axis1": spatial_crop.original_shape[1],
648
+ }
649
+
650
+
651
+ def _normalize_trace_frame_range(
652
+ *,
653
+ frame_count: int,
654
+ start_frame: int | None,
655
+ stop_frame: int | None,
656
+ ) -> tuple[int, int]:
657
+ """Normalize and validate the frame range for trace extraction.
658
+
659
+ Args:
660
+ frame_count: Number of frames in the movie.
661
+ start_frame: Optional first frame index.
662
+ stop_frame: Optional exclusive stop frame.
663
+
664
+ Returns:
665
+ ``(start, stop)`` frame bounds.
666
+ """
667
+ start = 0 if start_frame is None else start_frame
668
+ stop = frame_count if stop_frame is None else stop_frame
669
+ if start < 0 or stop > frame_count or start >= stop:
670
+ msg = f"Invalid trace frame range [{start}, {stop}) for {frame_count} frames"
671
+ raise ValueError(msg)
672
+ return start, stop