TwoPy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twopy/__init__.py +168 -0
- twopy/_segments.py +53 -0
- twopy/analysis/__init__.py +101 -0
- twopy/analysis/background_subtraction.py +672 -0
- twopy/analysis/dff.py +524 -0
- twopy/analysis/epoch_mapping.py +223 -0
- twopy/analysis/motion.py +66 -0
- twopy/analysis/persistence.py +975 -0
- twopy/analysis/response_processing/__init__.py +55 -0
- twopy/analysis/response_processing/apply.py +298 -0
- twopy/analysis/response_processing/options.py +192 -0
- twopy/analysis/response_processing/qc.py +270 -0
- twopy/analysis/response_processing/signals.py +200 -0
- twopy/analysis/responses.py +345 -0
- twopy/analysis/trials.py +341 -0
- twopy/analysis/workflow.py +457 -0
- twopy/api.py +78 -0
- twopy/config.py +200 -0
- twopy/conversion/__init__.py +40 -0
- twopy/conversion/alignment_crop.py +329 -0
- twopy/conversion/api.py +113 -0
- twopy/conversion/frame_ranges.py +35 -0
- twopy/conversion/hdf5_writing.py +362 -0
- twopy/conversion/matlab_values.py +123 -0
- twopy/conversion/source_loading.py +403 -0
- twopy/conversion/stimulus_code.py +248 -0
- twopy/conversion/types.py +295 -0
- twopy/converted.py +681 -0
- twopy/database/__init__.py +33 -0
- twopy/database/cache.py +166 -0
- twopy/database/catalog.py +90 -0
- twopy/database/connection.py +27 -0
- twopy/database/generic.py +254 -0
- twopy/database/modeled.py +419 -0
- twopy/database/sql.py +46 -0
- twopy/database/types.py +116 -0
- twopy/inspection.py +492 -0
- twopy/matlab.py +398 -0
- twopy/napari/__init__.py +27 -0
- twopy/napari/__main__.py +10 -0
- twopy/napari/constants.py +13 -0
- twopy/napari/controls.py +739 -0
- twopy/napari/display.py +181 -0
- twopy/napari/display_paths.py +197 -0
- twopy/napari/interactive.py +310 -0
- twopy/napari/launcher.py +175 -0
- twopy/napari/layout.py +147 -0
- twopy/napari/loading.py +207 -0
- twopy/napari/movie.py +71 -0
- twopy/napari/paths.py +181 -0
- twopy/napari/plotting/__init__.py +21 -0
- twopy/napari/plotting/data.py +266 -0
- twopy/napari/plotting/docks.py +1052 -0
- twopy/napari/plotting/export.py +803 -0
- twopy/napari/plotting/export_controls.py +209 -0
- twopy/napari/plotting/label_visibility.py +260 -0
- twopy/napari/plotting/options.py +269 -0
- twopy/napari/plotting/panels.py +119 -0
- twopy/napari/plotting/widgets.py +767 -0
- twopy/napari/protocols.py +90 -0
- twopy/napari/responses.py +91 -0
- twopy/napari/roi.py +238 -0
- twopy/napari/session.py +381 -0
- twopy/napari/state.py +106 -0
- twopy/napari/text.py +26 -0
- twopy/napari/types.py +39 -0
- twopy/napari/viewer.py +405 -0
- twopy/parity/__init__.py +30 -0
- twopy/parity/dff.py +399 -0
- twopy/parity/roi.py +145 -0
- twopy/parity/saved_analysis.py +174 -0
- twopy/photodiode.py +375 -0
- twopy/photodiode_classification.py +484 -0
- twopy/roi.py +462 -0
- twopy/session.py +167 -0
- twopy/spatial.py +154 -0
- twopy/stimulus.py +233 -0
- twopy/synchronization.py +235 -0
- twopy/typing_guards.py +255 -0
- twopy-0.1.0.dist-info/METADATA +311 -0
- twopy-0.1.0.dist-info/RECORD +84 -0
- twopy-0.1.0.dist-info/WHEEL +5 -0
- twopy-0.1.0.dist-info/entry_points.txt +2 -0
- twopy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|