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/analysis/dff.py ADDED
@@ -0,0 +1,524 @@
1
+ """Compute ROI-level delta F over F traces.
2
+
3
+ Inputs: background-corrected ROI fluorescence traces and gray-interleave frame
4
+ windows.
5
+ Outputs: ROI dF/F traces, fitted baselines, shared exponential tau, and
6
+ per-ROI amplitudes.
7
+
8
+ This module owns ROI-level dF/F calculation from twopy Python objects. It does
9
+ not decide which stimulus events are interleaves; callers pass explicit
10
+ interleave windows derived from converted data and photodiode alignment.
11
+ """
12
+
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Literal
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+ from scipy.optimize import least_squares
20
+
21
+ from twopy.analysis.background_subtraction import BackgroundCorrectedRoiTraces
22
+ from twopy.analysis.trials import FrameWindow
23
+
24
+ __all__ = [
25
+ "DeltaFOverFFitMode",
26
+ "RoiDeltaFOverF",
27
+ "compute_roi_delta_f_over_f",
28
+ ]
29
+
30
+ DeltaFOverFFitMode = Literal["robust", "source_bounds"]
31
+ DffMetadataValue = str | int | float | bool
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class RoiDeltaFOverF:
36
+ """ROI-level dF/F and the fitted baseline used to compute it.
37
+
38
+ Inputs: background-corrected ROI fluorescence and gray-interleave windows.
39
+ Outputs: fluorescence, baseline, dF/F, ROI labels, shared tau, per-ROI
40
+ amplitudes, and fit metadata.
41
+
42
+ Keeping fluorescence and baseline beside dF/F makes the calculation easy to
43
+ audit. The final trace is ``(fluorescence - baseline) / baseline``.
44
+ """
45
+
46
+ fluorescence: npt.NDArray[np.float64]
47
+ baseline: npt.NDArray[np.float64]
48
+ values: npt.NDArray[np.float64]
49
+ labels: tuple[str, ...]
50
+ start_frame: int
51
+ stop_frame: int
52
+ tau: float
53
+ amplitudes: npt.NDArray[np.float64]
54
+ interleave_frame_numbers: npt.NDArray[np.float64]
55
+ interleave_fluorescence: npt.NDArray[np.float64]
56
+ metadata: dict[str, DffMetadataValue]
57
+
58
+
59
+ def compute_roi_delta_f_over_f(
60
+ traces: BackgroundCorrectedRoiTraces,
61
+ interleave_windows: Sequence[FrameWindow],
62
+ *,
63
+ data_rate_hz: float,
64
+ seconds_interleave_use: float | None = 1.0,
65
+ fit_mode: DeltaFOverFFitMode = "robust",
66
+ ) -> RoiDeltaFOverF:
67
+ """Compute ROI-level dF/F from corrected fluorescence.
68
+
69
+ Args:
70
+ traces: Background-corrected ROI traces. ``corrected_values`` is treated
71
+ as final ROI fluorescence ``F_roi(t)``.
72
+ interleave_windows: Gray-interleave frame windows in the same absolute
73
+ frame coordinates as ``traces``.
74
+ data_rate_hz: Imaging frame rate in frames per second.
75
+ seconds_interleave_use: Number of seconds to keep from the end of each
76
+ interleave window. ``None`` uses the full window.
77
+ fit_mode: Exponential fit bounds. ``"robust"`` keeps the shared tau
78
+ bounded but leaves log-amplitude unbounded and ignores nonpositive
79
+ fit samples. ``"source_bounds"`` uses bounded log-amplitude from the
80
+ original analysis source for audit comparisons. ``"robust"`` is the
81
+ default because the source log-amplitude upper bound depends on the
82
+ first baseline sample; if that sample is small, nonpositive, or
83
+ noisy, the bound can become invalid or overly restrictive.
84
+
85
+ Returns:
86
+ ``RoiDeltaFOverF`` with dF/F values shaped ``(frames, rois)``.
87
+
88
+ Raises:
89
+ ValueError: If no interleave windows are supplied, windows fall outside
90
+ the traces, or the fit has no positive finite baseline samples.
91
+
92
+ The calculation uses the current twopy analysis contract: first use the
93
+ background-corrected mean fluorescence inside each ROI, then fit one
94
+ exponential decay constant shared by all ROIs from gray interleaves, then
95
+ estimate one amplitude per ROI.
96
+ """
97
+ _validate_dff_inputs(
98
+ traces=traces,
99
+ interleave_windows=interleave_windows,
100
+ data_rate_hz=data_rate_hz,
101
+ seconds_interleave_use=seconds_interleave_use,
102
+ fit_mode=fit_mode,
103
+ )
104
+
105
+ fluorescence = traces.corrected_values.astype(np.float64, copy=False)
106
+ interleave_samples = _interleave_samples(
107
+ fluorescence=fluorescence,
108
+ trace_start_frame=traces.start_frame,
109
+ interleave_windows=interleave_windows,
110
+ data_rate_hz=data_rate_hz,
111
+ seconds_interleave_use=seconds_interleave_use,
112
+ )
113
+ tau = _fit_shared_tau(
114
+ frame_numbers=interleave_samples.frame_numbers,
115
+ mean_fluorescence=interleave_samples.values.mean(axis=1),
116
+ fit_mode=fit_mode,
117
+ )
118
+ amplitudes = _compute_roi_amplitudes(
119
+ interleave_values=interleave_samples.values,
120
+ interleave_frame_numbers=interleave_samples.frame_numbers,
121
+ tau=tau,
122
+ )
123
+ baseline = _evaluate_baseline(
124
+ frame_count=fluorescence.shape[0],
125
+ start_frame=traces.start_frame,
126
+ amplitudes=amplitudes,
127
+ tau=tau,
128
+ )
129
+ values = (fluorescence - baseline) / baseline
130
+
131
+ return RoiDeltaFOverF(
132
+ fluorescence=fluorescence,
133
+ baseline=baseline,
134
+ values=values,
135
+ labels=traces.labels,
136
+ start_frame=traces.start_frame,
137
+ stop_frame=traces.stop_frame,
138
+ tau=tau,
139
+ amplitudes=amplitudes,
140
+ interleave_frame_numbers=interleave_samples.frame_numbers,
141
+ interleave_fluorescence=interleave_samples.values,
142
+ metadata={
143
+ "method": "shared_tau_roi_amplitude",
144
+ "data_rate_hz": float(data_rate_hz),
145
+ "seconds_interleave_use": (
146
+ "full"
147
+ if seconds_interleave_use is None
148
+ else float(seconds_interleave_use)
149
+ ),
150
+ "interleave_window_count": len(interleave_windows),
151
+ "fit_mode": fit_mode,
152
+ },
153
+ )
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class _InterleaveSamples:
158
+ """Mean fluorescence samples from interleave windows.
159
+
160
+ Inputs: gray-interleave frame windows and ROI fluorescence traces.
161
+ Outputs: one mean fluorescence value per interleave window and ROI, plus
162
+ the frame number used for exponential fitting.
163
+ """
164
+
165
+ frame_numbers: npt.NDArray[np.float64]
166
+ values: npt.NDArray[np.float64]
167
+
168
+
169
+ def _validate_dff_inputs(
170
+ *,
171
+ traces: BackgroundCorrectedRoiTraces,
172
+ interleave_windows: Sequence[FrameWindow],
173
+ data_rate_hz: float,
174
+ seconds_interleave_use: float | None,
175
+ fit_mode: DeltaFOverFFitMode,
176
+ ) -> None:
177
+ """Validate the public dF/F inputs.
178
+
179
+ Args:
180
+ traces: Candidate background-corrected ROI traces.
181
+ interleave_windows: Candidate baseline windows.
182
+ data_rate_hz: Imaging frame rate.
183
+ seconds_interleave_use: Optional seconds from each interleave end.
184
+ fit_mode: Requested exponential fit mode.
185
+
186
+ Returns:
187
+ None.
188
+ """
189
+ if traces.corrected_values.ndim != 2:
190
+ msg = "corrected_values must have shape (frames, rois)"
191
+ raise ValueError(msg)
192
+ if traces.corrected_values.shape[1] != len(traces.labels):
193
+ msg = (
194
+ "ROI labels must match corrected_values width; "
195
+ f"got {len(traces.labels)} labels for "
196
+ f"{traces.corrected_values.shape[1]} ROIs"
197
+ )
198
+ raise ValueError(msg)
199
+ if len(interleave_windows) == 0:
200
+ msg = "At least one interleave window is required for dF/F"
201
+ raise ValueError(msg)
202
+ if data_rate_hz <= 0:
203
+ msg = f"data_rate_hz must be positive; got {data_rate_hz}"
204
+ raise ValueError(msg)
205
+ if seconds_interleave_use is not None and seconds_interleave_use <= 0:
206
+ msg = (
207
+ "seconds_interleave_use must be positive or None; "
208
+ f"got {seconds_interleave_use}"
209
+ )
210
+ raise ValueError(msg)
211
+ if fit_mode not in {"robust", "source_bounds"}:
212
+ msg = f"Unknown dF/F fit_mode {fit_mode!r}"
213
+ raise ValueError(msg)
214
+
215
+
216
+ def _interleave_samples(
217
+ *,
218
+ fluorescence: npt.NDArray[np.float64],
219
+ trace_start_frame: int,
220
+ interleave_windows: Sequence[FrameWindow],
221
+ data_rate_hz: float,
222
+ seconds_interleave_use: float | None,
223
+ ) -> _InterleaveSamples:
224
+ """Average the chosen part of each interleave window.
225
+
226
+ Args:
227
+ fluorescence: ROI fluorescence shaped ``(frames, rois)``.
228
+ trace_start_frame: Absolute frame index for ``fluorescence[0]``.
229
+ interleave_windows: Gray-interleave windows in absolute frame indices.
230
+ data_rate_hz: Imaging frame rate.
231
+ seconds_interleave_use: Optional seconds from each interleave end.
232
+
233
+ Returns:
234
+ Mean interleave fluorescence and one-based fit frame numbers.
235
+
236
+ Each interleave is first narrowed to the last requested second, then the
237
+ selected frames are averaged into one point per interleave. That keeps
238
+ longer interleaves from dominating the exponential fit.
239
+ """
240
+ frame_numbers: list[float] = []
241
+ values: list[npt.NDArray[np.float64]] = []
242
+ for window in interleave_windows:
243
+ local_start = window.start_frame - trace_start_frame
244
+ local_stop = window.stop_frame - trace_start_frame
245
+ _validate_interleave_local_range(
246
+ window=window,
247
+ local_start=local_start,
248
+ local_stop=local_stop,
249
+ frame_count=fluorescence.shape[0],
250
+ )
251
+ sample_start, sample_stop = _last_interleave_sample_range(
252
+ local_start=local_start,
253
+ local_stop=local_stop,
254
+ data_rate_hz=data_rate_hz,
255
+ seconds_interleave_use=seconds_interleave_use,
256
+ )
257
+
258
+ duration = sample_stop - sample_start
259
+ absolute_sample_start = trace_start_frame + sample_start
260
+ # The fit uses one-based movie frame numbers centered on the averaged
261
+ # half-open sample range. Local indices slice the trace array; absolute
262
+ # frame numbers preserve the recording-wide time coordinate.
263
+ frame_numbers.append(float(absolute_sample_start + 1) + (duration / 2.0))
264
+ values.append(fluorescence[sample_start:sample_stop, :].mean(axis=0))
265
+
266
+ return _InterleaveSamples(
267
+ frame_numbers=np.asarray(frame_numbers, dtype=np.float64),
268
+ values=np.asarray(values, dtype=np.float64),
269
+ )
270
+
271
+
272
+ def _validate_interleave_local_range(
273
+ *,
274
+ window: FrameWindow,
275
+ local_start: int,
276
+ local_stop: int,
277
+ frame_count: int,
278
+ ) -> None:
279
+ """Check that one interleave window can slice the trace array.
280
+
281
+ Args:
282
+ window: Window in absolute frame coordinates.
283
+ local_start: Window start after subtracting trace start.
284
+ local_stop: Window stop after subtracting trace start.
285
+ frame_count: Number of trace frames available.
286
+
287
+ Returns:
288
+ None.
289
+ """
290
+ if local_start < 0 or local_stop > frame_count or local_start >= local_stop:
291
+ msg = (
292
+ f"Interleave window [{window.start_frame}, {window.stop_frame}) is "
293
+ f"outside trace range with {frame_count} frames"
294
+ )
295
+ raise ValueError(msg)
296
+
297
+
298
+ def _last_interleave_sample_range(
299
+ *,
300
+ local_start: int,
301
+ local_stop: int,
302
+ data_rate_hz: float,
303
+ seconds_interleave_use: float | None,
304
+ ) -> tuple[int, int]:
305
+ """Choose the final part of one interleave window.
306
+
307
+ Args:
308
+ local_start: Window start in local trace coordinates.
309
+ local_stop: Window stop in local trace coordinates.
310
+ data_rate_hz: Imaging frame rate.
311
+ seconds_interleave_use: Optional seconds from the window end.
312
+
313
+ Returns:
314
+ Half-open local frame range to average.
315
+ """
316
+ if seconds_interleave_use is None:
317
+ return local_start, local_stop
318
+
319
+ frame_count = int(round(seconds_interleave_use * data_rate_hz))
320
+ frame_count = max(frame_count, 1)
321
+ duration = local_stop - local_start
322
+ if frame_count > duration:
323
+ msg = (
324
+ "Requested interleave baseline is longer than a window: "
325
+ f"requested_frames={frame_count}, window_frames={duration}"
326
+ )
327
+ raise ValueError(msg)
328
+ return local_stop - frame_count, local_stop
329
+
330
+
331
+ def _fit_shared_tau(
332
+ *,
333
+ frame_numbers: npt.NDArray[np.float64],
334
+ mean_fluorescence: npt.NDArray[np.float64],
335
+ fit_mode: DeltaFOverFFitMode,
336
+ ) -> float:
337
+ """Fit one exponential decay constant shared across ROIs.
338
+
339
+ Args:
340
+ frame_numbers: One-based frame number for each interleave sample.
341
+ mean_fluorescence: Mean fluorescence across ROIs for each sample.
342
+ fit_mode: Fit bounds to use.
343
+
344
+ Returns:
345
+ Shared exponential ``tau``.
346
+
347
+ The fit model is ``exp(tau * t + b)`` on the ROI-averaged interleave trace.
348
+ Only ``tau`` is shared; per-ROI amplitudes are computed in a separate
349
+ transparent step. ``robust`` mode handles nonpositive outliers defensively;
350
+ ``source_bounds`` preserves the original bounded log-amplitude fit for
351
+ audit comparisons.
352
+ """
353
+ if fit_mode == "source_bounds":
354
+ return _fit_shared_tau_with_source_bounds(
355
+ frame_numbers=frame_numbers,
356
+ mean_fluorescence=mean_fluorescence,
357
+ )
358
+ return _fit_shared_tau_robust(
359
+ frame_numbers=frame_numbers,
360
+ mean_fluorescence=mean_fluorescence,
361
+ )
362
+
363
+
364
+ def _fit_shared_tau_robust(
365
+ *,
366
+ frame_numbers: npt.NDArray[np.float64],
367
+ mean_fluorescence: npt.NDArray[np.float64],
368
+ ) -> float:
369
+ """Fit shared tau with defensive handling for nonpositive samples.
370
+
371
+ Args:
372
+ frame_numbers: One-based frame number for each interleave sample.
373
+ mean_fluorescence: Mean fluorescence across ROIs for each sample.
374
+
375
+ Returns:
376
+ Shared exponential ``tau``.
377
+
378
+ This is the default fit path because background correction can create
379
+ nonpositive values and the first interleave sample can be noisy. The source
380
+ log-amplitude upper bound is ``10 * log(first baseline)``; that becomes
381
+ invalid when the first baseline is not greater than one and can be overly
382
+ restrictive when the first baseline is an outlier. This path keeps the
383
+ decay-rate bound while fitting amplitude scale from all usable positive
384
+ samples.
385
+ """
386
+ finite_positive = np.isfinite(mean_fluorescence) & (mean_fluorescence > 0)
387
+ if not np.any(finite_positive):
388
+ msg = "Cannot fit dF/F baseline: interleave fluorescence has no positive values"
389
+ raise ValueError(msg)
390
+ if np.count_nonzero(finite_positive) == 1:
391
+ return 0.0
392
+
393
+ x = frame_numbers[finite_positive]
394
+ y = mean_fluorescence[finite_positive]
395
+ initial_log_amplitude = float(np.log(y[0]))
396
+
397
+ def residual(parameters: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
398
+ """Return direct exponential residuals for SciPy least squares."""
399
+ tau = parameters[0]
400
+ log_amplitude = parameters[1]
401
+ return np.exp((tau * x) + log_amplitude) - y
402
+
403
+ fit = least_squares(
404
+ residual,
405
+ x0=np.asarray([0.0, initial_log_amplitude], dtype=np.float64),
406
+ bounds=(
407
+ np.asarray([-0.01, -np.inf], dtype=np.float64),
408
+ np.asarray([0.01, np.inf], dtype=np.float64),
409
+ ),
410
+ )
411
+ return float(fit.x[0])
412
+
413
+
414
+ def _fit_shared_tau_with_source_bounds(
415
+ *,
416
+ frame_numbers: npt.NDArray[np.float64],
417
+ mean_fluorescence: npt.NDArray[np.float64],
418
+ ) -> float:
419
+ """Fit shared tau using the original bounded log-amplitude contract.
420
+
421
+ Args:
422
+ frame_numbers: One-based frame number for each interleave sample.
423
+ mean_fluorescence: Mean fluorescence across ROIs for each sample.
424
+
425
+ Returns:
426
+ Shared exponential ``tau``.
427
+
428
+ Raises:
429
+ ValueError: If any fit sample is nonfinite or the first sample cannot
430
+ define a positive log-amplitude bound.
431
+
432
+ This mode exists for audit comparisons. Its log-amplitude bound is
433
+ ``[0, 10 * log(first baseline)]``. That contract makes the first baseline
434
+ sample unusually important, so this mode intentionally fails on nonfinite,
435
+ nonpositive, or too-small first samples instead of silently switching to the
436
+ robust behavior.
437
+ """
438
+ if np.any(~np.isfinite(mean_fluorescence)):
439
+ msg = "source_bounds dF/F fit requires finite interleave fluorescence"
440
+ raise ValueError(msg)
441
+ if mean_fluorescence.size == 0:
442
+ msg = "source_bounds dF/F fit requires at least one interleave sample"
443
+ raise ValueError(msg)
444
+ first_value = float(mean_fluorescence[0])
445
+ if first_value <= 1.0:
446
+ msg = (
447
+ "source_bounds dF/F fit requires first interleave fluorescence "
448
+ "greater than 1.0 to define an ordered log-amplitude bound; "
449
+ f"got {first_value}"
450
+ )
451
+ raise ValueError(msg)
452
+ if np.any(mean_fluorescence <= 0):
453
+ msg = "source_bounds dF/F fit requires positive interleave fluorescence"
454
+ raise ValueError(msg)
455
+ if mean_fluorescence.size == 1:
456
+ return 0.0
457
+
458
+ initial_log_amplitude = float(np.log(first_value))
459
+ upper_log_amplitude = 10.0 * initial_log_amplitude
460
+
461
+ def residual(parameters: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
462
+ """Return direct exponential residuals for source-bound least squares."""
463
+ tau = parameters[0]
464
+ log_amplitude = parameters[1]
465
+ return np.exp((tau * frame_numbers) + log_amplitude) - mean_fluorescence
466
+
467
+ fit = least_squares(
468
+ residual,
469
+ x0=np.asarray([0.0, initial_log_amplitude], dtype=np.float64),
470
+ bounds=(
471
+ np.asarray([-0.01, 0.0], dtype=np.float64),
472
+ np.asarray([0.01, upper_log_amplitude], dtype=np.float64),
473
+ ),
474
+ )
475
+ return float(fit.x[0])
476
+
477
+
478
+ def _compute_roi_amplitudes(
479
+ *,
480
+ interleave_values: npt.NDArray[np.float64],
481
+ interleave_frame_numbers: npt.NDArray[np.float64],
482
+ tau: float,
483
+ ) -> npt.NDArray[np.float64]:
484
+ """Estimate one exponential amplitude per ROI.
485
+
486
+ Args:
487
+ interleave_values: Mean interleave fluorescence shaped
488
+ ``(interleaves, rois)``.
489
+ interleave_frame_numbers: One-based fit frame number per interleave.
490
+ tau: Shared exponential decay constant.
491
+
492
+ Returns:
493
+ One amplitude per ROI.
494
+ """
495
+ decay = np.exp(tau * interleave_frame_numbers)[:, None]
496
+ if tau == 0:
497
+ return interleave_values.mean(axis=0)
498
+ return (interleave_values / decay).mean(axis=0)
499
+
500
+
501
+ def _evaluate_baseline(
502
+ *,
503
+ frame_count: int,
504
+ start_frame: int,
505
+ amplitudes: npt.NDArray[np.float64],
506
+ tau: float,
507
+ ) -> npt.NDArray[np.float64]:
508
+ """Evaluate the fitted baseline for every frame and ROI.
509
+
510
+ Args:
511
+ frame_count: Number of fluorescence frames.
512
+ start_frame: Absolute frame index for the first fluorescence frame.
513
+ amplitudes: Per-ROI amplitudes.
514
+ tau: Shared exponential decay constant.
515
+
516
+ Returns:
517
+ Baseline shaped ``(frames, rois)``.
518
+ """
519
+ frame_numbers = np.arange(
520
+ start_frame + 1,
521
+ start_frame + frame_count + 1,
522
+ dtype=np.float64,
523
+ )
524
+ return amplitudes[None, :] * np.exp(tau * frame_numbers[:, None])