lbm_caiman_python 0.2.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.
- lbm_caiman_python/__init__.py +63 -0
- lbm_caiman_python/__main__.py +302 -0
- lbm_caiman_python/_version.py +8 -0
- lbm_caiman_python/batch.py +188 -0
- lbm_caiman_python/collation.py +125 -0
- lbm_caiman_python/default_ops.py +92 -0
- lbm_caiman_python/gui/__init__.py +3 -0
- lbm_caiman_python/gui/_store_model.py +170 -0
- lbm_caiman_python/gui/rungui.py +13 -0
- lbm_caiman_python/gui/widgets.py +114 -0
- lbm_caiman_python/helpers.py +262 -0
- lbm_caiman_python/postprocessing.py +319 -0
- lbm_caiman_python/run_lcp.py +1059 -0
- lbm_caiman_python/stdout.py +3 -0
- lbm_caiman_python/summary.py +569 -0
- lbm_caiman_python/util/__init__.py +87 -0
- lbm_caiman_python/util/exceptions.py +6 -0
- lbm_caiman_python/util/quality.py +366 -0
- lbm_caiman_python/util/signal.py +17 -0
- lbm_caiman_python/util/transform.py +208 -0
- lbm_caiman_python/visualize.py +522 -0
- lbm_caiman_python-0.2.0.dist-info/METADATA +161 -0
- lbm_caiman_python-0.2.0.dist-info/RECORD +27 -0
- lbm_caiman_python-0.2.0.dist-info/WHEEL +5 -0
- lbm_caiman_python-0.2.0.dist-info/entry_points.txt +2 -0
- lbm_caiman_python-0.2.0.dist-info/licenses/LICENSE.md +38 -0
- lbm_caiman_python-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CaImAn-backed pipeline for LBM data.
|
|
3
|
+
|
|
4
|
+
Produces the same on-disk layout as ``lbm_suite2p_python.pipeline()`` so the
|
|
5
|
+
results can be consumed by mbo studio / lbm_suite2p_python tooling. CaImAn
|
|
6
|
+
provides motion correction and CNMF source extraction; outputs are mapped
|
|
7
|
+
into suite2p-style files (``data.bin``, ``ops.npy``, ``stat.npy``,
|
|
8
|
+
``iscell.npy``, ``F.npy``, ``Fneu.npy``, ``spks.npy``, ``dff.npy``,
|
|
9
|
+
``roi_stats.npy``) and the lsp post-processing helpers (figures, volume
|
|
10
|
+
stats) run on top of them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Union
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from lbm_caiman_python.default_ops import default_ops
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
PIPELINE_TAGS = ("plane", "roi", "z", "plane_", "roi_", "z_")
|
|
29
|
+
|
|
30
|
+
LAZY_TYPES = (
|
|
31
|
+
"TiffArray", "ScanImageArray", "LBMArray", "PiezoArray",
|
|
32
|
+
"Suite2pArray", "H5Array", "ZarrArray", "NumpyArray",
|
|
33
|
+
"SinglePlaneArray", "ImageJHyperstackArray", "BinArray",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_version() -> str:
|
|
38
|
+
try:
|
|
39
|
+
from lbm_caiman_python import __version__
|
|
40
|
+
return __version__
|
|
41
|
+
except Exception:
|
|
42
|
+
return "0.0.0"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_caiman_version() -> str:
|
|
46
|
+
try:
|
|
47
|
+
import caiman
|
|
48
|
+
return getattr(caiman, "__version__", "unknown")
|
|
49
|
+
except ImportError:
|
|
50
|
+
return "not installed"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_lazy_array(obj) -> bool:
|
|
54
|
+
return type(obj).__name__ in LAZY_TYPES
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_input_path(path):
|
|
58
|
+
"""Resolve a file or directory path for imread."""
|
|
59
|
+
path = Path(path)
|
|
60
|
+
if path.is_dir():
|
|
61
|
+
from mbo_utilities import get_files
|
|
62
|
+
files = get_files(str(path), str_contains="tif", max_depth=1)
|
|
63
|
+
if not files:
|
|
64
|
+
raise FileNotFoundError(f"no tiff files found in {path}")
|
|
65
|
+
return files
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_num_planes(arr) -> int:
|
|
70
|
+
if hasattr(arr, "num_planes"):
|
|
71
|
+
return arr.num_planes
|
|
72
|
+
if hasattr(arr, "shape5d"):
|
|
73
|
+
return int(arr.shape5d[2])
|
|
74
|
+
if hasattr(arr, "shape") and arr.ndim == 4:
|
|
75
|
+
return arr.shape[1]
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def add_processing_step(ops, step_name, input_files=None, duration_seconds=None, extra=None):
|
|
80
|
+
"""Append a processing step record to ``ops['processing_history']``."""
|
|
81
|
+
if "processing_history" not in ops:
|
|
82
|
+
ops["processing_history"] = []
|
|
83
|
+
|
|
84
|
+
step_record = {
|
|
85
|
+
"step": step_name,
|
|
86
|
+
"timestamp": datetime.now().isoformat(),
|
|
87
|
+
"lbm_caiman_python_version": _get_version(),
|
|
88
|
+
"caiman_version": _get_caiman_version(),
|
|
89
|
+
}
|
|
90
|
+
if input_files is not None:
|
|
91
|
+
step_record["input_files"] = (
|
|
92
|
+
[input_files] if isinstance(input_files, str)
|
|
93
|
+
else [str(f) for f in input_files]
|
|
94
|
+
)
|
|
95
|
+
if duration_seconds is not None:
|
|
96
|
+
step_record["duration_seconds"] = round(duration_seconds, 2)
|
|
97
|
+
if extra is not None:
|
|
98
|
+
step_record["extra"] = extra
|
|
99
|
+
|
|
100
|
+
ops["processing_history"].append(step_record)
|
|
101
|
+
return ops
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_plane_dirname(plane, nframes=None, frame_start=1, frame_stop=None, suffix=None):
|
|
105
|
+
"""Generate ``zplaneNN[_tpSTART-STOP][_suffix]`` directory name."""
|
|
106
|
+
try:
|
|
107
|
+
from lbm_suite2p_python.run_lsp import generate_plane_dirname as _gpd
|
|
108
|
+
return _gpd(plane, nframes=nframes, frame_start=frame_start,
|
|
109
|
+
frame_stop=frame_stop, suffix=suffix)
|
|
110
|
+
except ImportError:
|
|
111
|
+
parts = [f"zplane{plane:02d}"]
|
|
112
|
+
if nframes is not None and nframes > 1:
|
|
113
|
+
stop = frame_stop if frame_stop is not None else nframes
|
|
114
|
+
parts.append(f"tp{frame_start:05d}-{stop:05d}")
|
|
115
|
+
if suffix:
|
|
116
|
+
parts.append(suffix)
|
|
117
|
+
return "_".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize_planes(planes, num_planes: int) -> list:
|
|
121
|
+
if planes is None:
|
|
122
|
+
return list(range(num_planes))
|
|
123
|
+
if isinstance(planes, int):
|
|
124
|
+
planes = [planes]
|
|
125
|
+
indices = []
|
|
126
|
+
for p in planes:
|
|
127
|
+
idx = p - 1
|
|
128
|
+
if 0 <= idx < num_planes:
|
|
129
|
+
indices.append(idx)
|
|
130
|
+
else:
|
|
131
|
+
print(f"Warning: plane {p} out of range (1-{num_planes}), skipping")
|
|
132
|
+
return indices
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def derive_tag_from_filename(path):
|
|
136
|
+
name = Path(path).stem
|
|
137
|
+
for tag in PIPELINE_TAGS:
|
|
138
|
+
low = name.lower()
|
|
139
|
+
if low.startswith(tag):
|
|
140
|
+
suffix = name[len(tag):]
|
|
141
|
+
if suffix and suffix[0] in ("_", "-"):
|
|
142
|
+
suffix = suffix[1:]
|
|
143
|
+
if suffix.isdigit():
|
|
144
|
+
return f"{tag.rstrip('_')}{int(suffix)}"
|
|
145
|
+
return name
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_plane_num_from_tag(tag: str, fallback: int = None) -> int:
|
|
149
|
+
import re
|
|
150
|
+
match = re.search(r"(\d+)$", tag)
|
|
151
|
+
return int(match.group(1)) if match else fallback
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _stat_from_A(A, dims, C=None):
|
|
155
|
+
"""Build a suite2p-style ``stat`` list from a CNMF spatial matrix.
|
|
156
|
+
|
|
157
|
+
Each entry is a dict with keys used by lsp plotting / roi_stats code:
|
|
158
|
+
``ypix``, ``xpix``, ``lam``, ``npix``, ``med``, ``radius``, ``compact``,
|
|
159
|
+
``skew``. Coordinates are in full-frame (Ly, Lx) space.
|
|
160
|
+
"""
|
|
161
|
+
from scipy import sparse
|
|
162
|
+
from scipy.stats import skew as _skew
|
|
163
|
+
|
|
164
|
+
Ly, Lx = int(dims[0]), int(dims[1])
|
|
165
|
+
if sparse.issparse(A):
|
|
166
|
+
A_dense = A.toarray()
|
|
167
|
+
else:
|
|
168
|
+
A_dense = np.asarray(A)
|
|
169
|
+
n_rois = A_dense.shape[1]
|
|
170
|
+
|
|
171
|
+
stat = []
|
|
172
|
+
for i in range(n_rois):
|
|
173
|
+
comp = A_dense[:, i].reshape((Ly, Lx), order="F")
|
|
174
|
+
mask = comp > 0
|
|
175
|
+
if not mask.any():
|
|
176
|
+
ypix = np.array([0], dtype=np.int32)
|
|
177
|
+
xpix = np.array([0], dtype=np.int32)
|
|
178
|
+
lam = np.array([0.0], dtype=np.float32)
|
|
179
|
+
else:
|
|
180
|
+
ys, xs = np.where(mask)
|
|
181
|
+
ypix = ys.astype(np.int32)
|
|
182
|
+
xpix = xs.astype(np.int32)
|
|
183
|
+
lam = comp[mask].astype(np.float32)
|
|
184
|
+
|
|
185
|
+
npix = int(ypix.size)
|
|
186
|
+
med = [int(np.median(ypix)), int(np.median(xpix))]
|
|
187
|
+
radius = float(np.sqrt(npix / np.pi))
|
|
188
|
+
compact = float(radius / max(1.0, npix ** 0.5))
|
|
189
|
+
|
|
190
|
+
sk = float(_skew(C[i])) if (C is not None and C.shape[0] > i) else np.nan
|
|
191
|
+
|
|
192
|
+
stat.append({
|
|
193
|
+
"ypix": ypix,
|
|
194
|
+
"xpix": xpix,
|
|
195
|
+
"lam": lam,
|
|
196
|
+
"npix": npix,
|
|
197
|
+
"med": med,
|
|
198
|
+
"radius": radius,
|
|
199
|
+
"compact": compact,
|
|
200
|
+
"skew": sk,
|
|
201
|
+
"footprint": 0,
|
|
202
|
+
"mrs": radius,
|
|
203
|
+
"mrs0": radius,
|
|
204
|
+
"soma_crop": np.ones(npix, dtype=bool),
|
|
205
|
+
"overlap": np.zeros(npix, dtype=bool),
|
|
206
|
+
"aspect_ratio": 1.0,
|
|
207
|
+
})
|
|
208
|
+
return np.array(stat, dtype=object)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _iscell_from_estimates(estimates, n_total: int):
|
|
212
|
+
"""Build a suite2p-style ``iscell`` (n_rois, 2) array.
|
|
213
|
+
|
|
214
|
+
Column 0 is the 0/1 accepted flag from CaImAn's
|
|
215
|
+
``idx_components``; column 1 is a probability proxy from
|
|
216
|
+
``SNR_comp`` (rescaled to [0, 1]).
|
|
217
|
+
"""
|
|
218
|
+
iscell = np.zeros((n_total, 2), dtype=np.float32)
|
|
219
|
+
|
|
220
|
+
accepted = getattr(estimates, "idx_components", None)
|
|
221
|
+
if accepted is not None and len(accepted) > 0:
|
|
222
|
+
iscell[np.asarray(accepted, dtype=int), 0] = 1.0
|
|
223
|
+
else:
|
|
224
|
+
iscell[:, 0] = 1.0 # accept all when evaluation didn't produce a verdict
|
|
225
|
+
|
|
226
|
+
snr = getattr(estimates, "SNR_comp", None)
|
|
227
|
+
if snr is not None and len(snr) == n_total:
|
|
228
|
+
snr = np.asarray(snr, dtype=np.float32)
|
|
229
|
+
snr = np.nan_to_num(snr, nan=0.0)
|
|
230
|
+
smax = float(np.percentile(snr, 99)) if snr.size else 1.0
|
|
231
|
+
smax = smax if smax > 0 else 1.0
|
|
232
|
+
iscell[:, 1] = np.clip(snr / smax, 0.0, 1.0)
|
|
233
|
+
else:
|
|
234
|
+
iscell[:, 1] = iscell[:, 0]
|
|
235
|
+
return iscell
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _enhanced_mean_image(mean_img):
|
|
239
|
+
"""Suite2p-style high-pass enhanced mean. Falls back gracefully."""
|
|
240
|
+
if mean_img is None:
|
|
241
|
+
return None
|
|
242
|
+
try:
|
|
243
|
+
from suite2p.registration import highpass_mean_image
|
|
244
|
+
return highpass_mean_image(
|
|
245
|
+
np.asarray(mean_img, dtype=np.float32), aspect=1.0
|
|
246
|
+
)
|
|
247
|
+
except Exception:
|
|
248
|
+
# Manual fallback: subtract gaussian blur.
|
|
249
|
+
from scipy.ndimage import gaussian_filter
|
|
250
|
+
img = np.asarray(mean_img, dtype=np.float32)
|
|
251
|
+
return img - gaussian_filter(img, sigma=10.0)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _local_correlations(images):
|
|
255
|
+
"""Compute a Vcorr-like correlation image. Returns None on failure."""
|
|
256
|
+
try:
|
|
257
|
+
import caiman as cm
|
|
258
|
+
from caiman.summary_images import local_correlations
|
|
259
|
+
return local_correlations(images, swap_dim=False)
|
|
260
|
+
except Exception:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _convert_mmap_to_bin(mmap_path: Path, bin_path: Path) -> tuple[int, int, int]:
|
|
265
|
+
"""Convert a CaImAn (Lx*Ly, T) F-order float32 mmap to a suite2p
|
|
266
|
+
``(T, Ly, Lx)`` C-order int16 binary. Returns ``(T, Ly, Lx)``.
|
|
267
|
+
"""
|
|
268
|
+
import caiman as cm
|
|
269
|
+
Yr, dims, T = cm.load_memmap(str(mmap_path))
|
|
270
|
+
Ly, Lx = int(dims[0]), int(dims[1])
|
|
271
|
+
images = np.reshape(Yr.T, [T, Ly, Lx], order="F")
|
|
272
|
+
out = np.clip(images, np.iinfo(np.int16).min, np.iinfo(np.int16).max)
|
|
273
|
+
out.astype(np.int16).tofile(str(bin_path))
|
|
274
|
+
return T, Ly, Lx
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _ensure_caiman_mmap(plane_dir: Path, data_bin: Path, T: int, Ly: int, Lx: int) -> Path:
|
|
278
|
+
"""Build the F-order (Lx*Ly, T) float32 mmap CaImAn's CNMF wants from
|
|
279
|
+
a registered ``data.bin``. The caller is responsible for deleting it
|
|
280
|
+
after CNMF if storage matters.
|
|
281
|
+
"""
|
|
282
|
+
mmap_path = plane_dir / f"Yr_d1_{Ly}_d2_{Lx}_d3_1_order_C_frames_{T}_.mmap"
|
|
283
|
+
if mmap_path.exists():
|
|
284
|
+
return mmap_path
|
|
285
|
+
src = np.memmap(str(data_bin), dtype=np.int16, mode="r", shape=(T, Ly, Lx))
|
|
286
|
+
fp = np.memmap(str(mmap_path), mode="w+", dtype=np.float32,
|
|
287
|
+
shape=(Ly * Lx, T), order="F")
|
|
288
|
+
for t in range(T):
|
|
289
|
+
fp[:, t] = src[t].ravel(order="F").astype(np.float32)
|
|
290
|
+
del fp
|
|
291
|
+
return mmap_path
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def pipeline(
|
|
295
|
+
input_data,
|
|
296
|
+
save_path: Union[str, Path] = None,
|
|
297
|
+
ops: dict = None,
|
|
298
|
+
planes: Union[list, int] = None,
|
|
299
|
+
roi_mode: int = None,
|
|
300
|
+
keep_reg: bool = True,
|
|
301
|
+
keep_raw: bool = False,
|
|
302
|
+
force_reg: bool = False,
|
|
303
|
+
force_detect: bool = False,
|
|
304
|
+
num_timepoints: int = None,
|
|
305
|
+
frame_indices: list = None,
|
|
306
|
+
dff_window_size: int = None,
|
|
307
|
+
dff_percentile: int = 20,
|
|
308
|
+
dff_smooth_window: int = None,
|
|
309
|
+
correct_neuropil: bool = True,
|
|
310
|
+
accept_all_cells: bool = False,
|
|
311
|
+
save_json: bool = False,
|
|
312
|
+
reader_kwargs: dict = None,
|
|
313
|
+
writer_kwargs: dict = None,
|
|
314
|
+
rastermap_kwargs: dict = None,
|
|
315
|
+
**kwargs,
|
|
316
|
+
) -> list:
|
|
317
|
+
"""Auto-detect 3D vs 4D input and dispatch to ``run_plane`` / ``run_volume``.
|
|
318
|
+
|
|
319
|
+
Mirrors :func:`lbm_suite2p_python.pipeline` with CaImAn-specific
|
|
320
|
+
extras (``force_mcorr``/``force_cnmf`` accepted as legacy aliases for
|
|
321
|
+
``force_reg``/``force_detect``).
|
|
322
|
+
"""
|
|
323
|
+
# legacy aliases
|
|
324
|
+
if kwargs.pop("force_mcorr", False):
|
|
325
|
+
force_reg = True
|
|
326
|
+
if kwargs.pop("force_cnmf", False):
|
|
327
|
+
force_detect = True
|
|
328
|
+
|
|
329
|
+
from mbo_utilities import imread
|
|
330
|
+
|
|
331
|
+
reader_kwargs = reader_kwargs or {}
|
|
332
|
+
writer_kwargs = dict(writer_kwargs or {})
|
|
333
|
+
if num_timepoints is not None and "num_frames" not in writer_kwargs:
|
|
334
|
+
writer_kwargs["num_frames"] = num_timepoints
|
|
335
|
+
|
|
336
|
+
is_list = isinstance(input_data, (list, tuple))
|
|
337
|
+
if is_list:
|
|
338
|
+
is_volumetric = True
|
|
339
|
+
arr = None
|
|
340
|
+
else:
|
|
341
|
+
if _is_lazy_array(input_data) or isinstance(input_data, np.ndarray):
|
|
342
|
+
arr = input_data
|
|
343
|
+
else:
|
|
344
|
+
print(f"Loading input: {input_data}")
|
|
345
|
+
arr = imread(_resolve_input_path(input_data), **reader_kwargs)
|
|
346
|
+
is_volumetric = (_get_num_planes(arr) > 1) or (
|
|
347
|
+
hasattr(arr, "ndim") and arr.ndim == 4
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
common = dict(
|
|
351
|
+
save_path=save_path,
|
|
352
|
+
ops=ops,
|
|
353
|
+
keep_reg=keep_reg,
|
|
354
|
+
keep_raw=keep_raw,
|
|
355
|
+
force_reg=force_reg,
|
|
356
|
+
force_detect=force_detect,
|
|
357
|
+
frame_indices=frame_indices,
|
|
358
|
+
dff_window_size=dff_window_size,
|
|
359
|
+
dff_percentile=dff_percentile,
|
|
360
|
+
dff_smooth_window=dff_smooth_window,
|
|
361
|
+
correct_neuropil=correct_neuropil,
|
|
362
|
+
accept_all_cells=accept_all_cells,
|
|
363
|
+
save_json=save_json,
|
|
364
|
+
rastermap_kwargs=rastermap_kwargs,
|
|
365
|
+
reader_kwargs=reader_kwargs,
|
|
366
|
+
writer_kwargs=writer_kwargs,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if is_volumetric:
|
|
370
|
+
print("Detected 4D input, delegating to run_volume...")
|
|
371
|
+
return run_volume(
|
|
372
|
+
input_data=arr if arr is not None else input_data,
|
|
373
|
+
planes=planes,
|
|
374
|
+
**common,
|
|
375
|
+
)
|
|
376
|
+
print("Detected 3D input, delegating to run_plane...")
|
|
377
|
+
ops_file = run_plane(input_data=arr, **common)
|
|
378
|
+
return [ops_file]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def run_volume(
|
|
382
|
+
input_data,
|
|
383
|
+
save_path: Union[str, Path] = None,
|
|
384
|
+
ops: dict = None,
|
|
385
|
+
planes: Union[list, int] = None,
|
|
386
|
+
keep_reg: bool = True,
|
|
387
|
+
keep_raw: bool = False,
|
|
388
|
+
force_reg: bool = False,
|
|
389
|
+
force_detect: bool = False,
|
|
390
|
+
frame_indices: list = None,
|
|
391
|
+
dff_window_size: int = None,
|
|
392
|
+
dff_percentile: int = 20,
|
|
393
|
+
dff_smooth_window: int = None,
|
|
394
|
+
correct_neuropil: bool = True,
|
|
395
|
+
accept_all_cells: bool = False,
|
|
396
|
+
save_json: bool = False,
|
|
397
|
+
rastermap_kwargs: dict = None,
|
|
398
|
+
reader_kwargs: dict = None,
|
|
399
|
+
writer_kwargs: dict = None,
|
|
400
|
+
**kwargs,
|
|
401
|
+
) -> list:
|
|
402
|
+
"""Run the pipeline on a volumetric input, one plane at a time.
|
|
403
|
+
|
|
404
|
+
Each plane gets its own ``zplaneNN`` subdirectory under ``save_path``.
|
|
405
|
+
After all planes complete, volume-level stats and figures are written
|
|
406
|
+
via the lsp helpers.
|
|
407
|
+
"""
|
|
408
|
+
if kwargs.pop("force_mcorr", False):
|
|
409
|
+
force_reg = True
|
|
410
|
+
if kwargs.pop("force_cnmf", False):
|
|
411
|
+
force_detect = True
|
|
412
|
+
|
|
413
|
+
from mbo_utilities import imread
|
|
414
|
+
|
|
415
|
+
reader_kwargs = reader_kwargs or {}
|
|
416
|
+
writer_kwargs = writer_kwargs or {}
|
|
417
|
+
|
|
418
|
+
input_arr = None
|
|
419
|
+
input_paths = []
|
|
420
|
+
|
|
421
|
+
if _is_lazy_array(input_data):
|
|
422
|
+
input_arr = input_data
|
|
423
|
+
filenames = getattr(input_arr, "filenames", None) or []
|
|
424
|
+
if save_path is None and filenames:
|
|
425
|
+
save_path = Path(filenames[0]).parent / "caiman_results"
|
|
426
|
+
elif isinstance(input_data, np.ndarray):
|
|
427
|
+
input_arr = input_data
|
|
428
|
+
elif isinstance(input_data, (list, tuple)):
|
|
429
|
+
input_paths = [Path(p) for p in input_data]
|
|
430
|
+
if save_path is None and input_paths:
|
|
431
|
+
save_path = input_paths[0].parent / "caiman_results"
|
|
432
|
+
elif isinstance(input_data, (str, Path)):
|
|
433
|
+
input_path = Path(input_data)
|
|
434
|
+
if save_path is None:
|
|
435
|
+
save_path = input_path.parent / (input_path.stem + "_results")
|
|
436
|
+
print(f"Loading volume: {input_path}")
|
|
437
|
+
input_arr = imread(_resolve_input_path(input_path), **reader_kwargs)
|
|
438
|
+
else:
|
|
439
|
+
raise TypeError(f"Invalid input_data type: {type(input_data)}")
|
|
440
|
+
|
|
441
|
+
if save_path is None:
|
|
442
|
+
raise ValueError("save_path must be specified.")
|
|
443
|
+
|
|
444
|
+
save_path = Path(save_path)
|
|
445
|
+
save_path.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
|
|
447
|
+
if input_arr is not None:
|
|
448
|
+
num_planes = _get_num_planes(input_arr)
|
|
449
|
+
else:
|
|
450
|
+
num_planes = len(input_paths)
|
|
451
|
+
|
|
452
|
+
plane_indices = _normalize_planes(planes, num_planes)
|
|
453
|
+
print(f"Processing {len(plane_indices)} planes (total: {num_planes})")
|
|
454
|
+
print(f"Output: {save_path}")
|
|
455
|
+
|
|
456
|
+
ops_files = []
|
|
457
|
+
for plane_idx in plane_indices:
|
|
458
|
+
plane_num = plane_idx + 1
|
|
459
|
+
|
|
460
|
+
if input_arr is not None:
|
|
461
|
+
current_input = input_arr # run_plane extracts the plane
|
|
462
|
+
else:
|
|
463
|
+
current_input = input_paths[plane_idx]
|
|
464
|
+
|
|
465
|
+
from lbm_caiman_python.postprocessing import load_ops as _load_ops
|
|
466
|
+
current_ops = _load_ops(ops) if ops else default_ops()
|
|
467
|
+
current_ops["plane"] = plane_num
|
|
468
|
+
current_ops["num_zplanes"] = num_planes
|
|
469
|
+
current_ops["nplanes"] = 1
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
print(f"\n--- Plane {plane_num}/{num_planes} ---")
|
|
473
|
+
ops_file = run_plane(
|
|
474
|
+
input_data=current_input,
|
|
475
|
+
save_path=save_path,
|
|
476
|
+
ops=current_ops,
|
|
477
|
+
keep_reg=keep_reg,
|
|
478
|
+
keep_raw=keep_raw,
|
|
479
|
+
force_reg=force_reg,
|
|
480
|
+
force_detect=force_detect,
|
|
481
|
+
frame_indices=frame_indices,
|
|
482
|
+
dff_window_size=dff_window_size,
|
|
483
|
+
dff_percentile=dff_percentile,
|
|
484
|
+
dff_smooth_window=dff_smooth_window,
|
|
485
|
+
correct_neuropil=correct_neuropil,
|
|
486
|
+
accept_all_cells=accept_all_cells,
|
|
487
|
+
save_json=save_json,
|
|
488
|
+
rastermap_kwargs=rastermap_kwargs,
|
|
489
|
+
reader_kwargs=reader_kwargs,
|
|
490
|
+
writer_kwargs=writer_kwargs,
|
|
491
|
+
_volume_plane_idx=plane_idx,
|
|
492
|
+
)
|
|
493
|
+
ops_files.append(ops_file)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
print(f"ERROR processing plane {plane_num}: {e}")
|
|
496
|
+
traceback.print_exc()
|
|
497
|
+
|
|
498
|
+
if ops_files:
|
|
499
|
+
_generate_volume_outputs(ops_files, save_path, rastermap_kwargs)
|
|
500
|
+
|
|
501
|
+
return ops_files
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def run_plane(
|
|
505
|
+
input_data,
|
|
506
|
+
save_path: Union[str, Path] = None,
|
|
507
|
+
ops: dict = None,
|
|
508
|
+
keep_reg: bool = True,
|
|
509
|
+
keep_raw: bool = False,
|
|
510
|
+
force_reg: bool = False,
|
|
511
|
+
force_detect: bool = False,
|
|
512
|
+
frame_indices: list = None,
|
|
513
|
+
dff_window_size: int = None,
|
|
514
|
+
dff_percentile: int = 20,
|
|
515
|
+
dff_smooth_window: int = None,
|
|
516
|
+
correct_neuropil: bool = True,
|
|
517
|
+
accept_all_cells: bool = False,
|
|
518
|
+
save_json: bool = False,
|
|
519
|
+
rastermap_kwargs: dict = None,
|
|
520
|
+
plane_name: str = None,
|
|
521
|
+
reader_kwargs: dict = None,
|
|
522
|
+
writer_kwargs: dict = None,
|
|
523
|
+
_volume_plane_idx: int = None,
|
|
524
|
+
**kwargs,
|
|
525
|
+
) -> Path:
|
|
526
|
+
"""Process a single imaging plane with CaImAn and emit suite2p-format outputs."""
|
|
527
|
+
if kwargs.pop("force_mcorr", False):
|
|
528
|
+
force_reg = True
|
|
529
|
+
if kwargs.pop("force_cnmf", False):
|
|
530
|
+
force_detect = True
|
|
531
|
+
|
|
532
|
+
from mbo_utilities import imread, imwrite
|
|
533
|
+
from lbm_caiman_python.postprocessing import load_ops
|
|
534
|
+
|
|
535
|
+
reader_kwargs = reader_kwargs or {}
|
|
536
|
+
writer_kwargs = dict(writer_kwargs or {})
|
|
537
|
+
|
|
538
|
+
input_path = None
|
|
539
|
+
input_arr = None
|
|
540
|
+
if _is_lazy_array(input_data):
|
|
541
|
+
input_arr = input_data
|
|
542
|
+
filenames = getattr(input_arr, "filenames", None) or []
|
|
543
|
+
input_path = Path(filenames[0]) if filenames else Path(f"{plane_name or 'array_input'}.tif")
|
|
544
|
+
elif isinstance(input_data, np.ndarray):
|
|
545
|
+
input_arr = input_data
|
|
546
|
+
input_path = Path(f"{plane_name or 'array_input'}.tif")
|
|
547
|
+
elif isinstance(input_data, (str, Path)):
|
|
548
|
+
input_path = Path(input_data)
|
|
549
|
+
else:
|
|
550
|
+
raise TypeError(f"input_data must be path or array, got {type(input_data)}")
|
|
551
|
+
|
|
552
|
+
if save_path is None:
|
|
553
|
+
save_path = input_path.parent / "caiman_results"
|
|
554
|
+
save_path = Path(save_path)
|
|
555
|
+
save_path.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
|
|
557
|
+
ops_default = default_ops()
|
|
558
|
+
ops_user = load_ops(ops) if ops else {}
|
|
559
|
+
ops = {**ops_default, **ops_user}
|
|
560
|
+
|
|
561
|
+
if "plane" not in ops:
|
|
562
|
+
tag = derive_tag_from_filename(input_path)
|
|
563
|
+
ops["plane"] = get_plane_num_from_tag(tag, fallback=1)
|
|
564
|
+
plane = int(ops["plane"])
|
|
565
|
+
|
|
566
|
+
if input_arr is None:
|
|
567
|
+
print(f" Loading: {input_path}")
|
|
568
|
+
input_arr = imread(_resolve_input_path(input_path), **reader_kwargs)
|
|
569
|
+
|
|
570
|
+
metadata = dict(getattr(input_arr, "metadata", {}) or {})
|
|
571
|
+
|
|
572
|
+
def _meta(key):
|
|
573
|
+
v = metadata.get(key)
|
|
574
|
+
return v if v is not None else None
|
|
575
|
+
|
|
576
|
+
# Metadata only fills in values the user didn't supply. None-valued
|
|
577
|
+
# metadata entries are ignored (mbo_utilities arrays sometimes carry
|
|
578
|
+
# a `dz=None` placeholder, and float(None) crashes).
|
|
579
|
+
if _meta("fs") is not None and ops_user.get("fs") is None and ops_user.get("fr") is None:
|
|
580
|
+
ops["fs"] = float(_meta("fs"))
|
|
581
|
+
ops["fr"] = ops["fs"]
|
|
582
|
+
elif "fr" in ops and "fs" not in ops:
|
|
583
|
+
ops["fs"] = ops["fr"]
|
|
584
|
+
if _meta("dx") is not None and ops_user.get("dx") is None:
|
|
585
|
+
ops["dx"] = float(_meta("dx"))
|
|
586
|
+
if _meta("dy") is not None and ops_user.get("dy") is None:
|
|
587
|
+
ops["dy"] = float(_meta("dy"))
|
|
588
|
+
if "dxy" not in ops_user and (_meta("dx") is not None or _meta("dy") is not None):
|
|
589
|
+
ops["dxy"] = (float(_meta("dy") or 1.0), float(_meta("dx") or 1.0))
|
|
590
|
+
if _meta("dz") is not None and ops_user.get("dz") is None:
|
|
591
|
+
ops["dz"] = float(_meta("dz"))
|
|
592
|
+
ops.setdefault("z_step", ops["dz"])
|
|
593
|
+
|
|
594
|
+
is_volumetric_source = _get_num_planes(input_arr) > 1
|
|
595
|
+
|
|
596
|
+
nframes_full = None
|
|
597
|
+
if hasattr(input_arr, "shape5d"):
|
|
598
|
+
nframes_full = int(input_arr.shape5d[0])
|
|
599
|
+
elif hasattr(input_arr, "shape"):
|
|
600
|
+
nframes_full = int(input_arr.shape[0])
|
|
601
|
+
|
|
602
|
+
subdir = plane_name if plane_name else generate_plane_dirname(
|
|
603
|
+
plane=plane, nframes=nframes_full
|
|
604
|
+
)
|
|
605
|
+
plane_dir = save_path / subdir
|
|
606
|
+
plane_dir.mkdir(exist_ok=True)
|
|
607
|
+
ops_file = plane_dir / "ops.npy"
|
|
608
|
+
|
|
609
|
+
ops["save_path"] = str(plane_dir.resolve())
|
|
610
|
+
ops["ops_path"] = str(ops_file)
|
|
611
|
+
ops["data_path"] = str(input_path.resolve()) if input_path.exists() else str(input_path)
|
|
612
|
+
ops["source_dirname"] = plane_dir.name
|
|
613
|
+
ops["source_input"] = str(input_path.name)
|
|
614
|
+
ops["nplanes"] = 1
|
|
615
|
+
ops["nchannels"] = 1
|
|
616
|
+
|
|
617
|
+
print(f" Output: {plane_dir}")
|
|
618
|
+
|
|
619
|
+
raw_bin = plane_dir / "data_raw.bin"
|
|
620
|
+
reg_bin = plane_dir / "data.bin"
|
|
621
|
+
stat_file = plane_dir / "stat.npy"
|
|
622
|
+
|
|
623
|
+
# Skip rules (mirrors lsp):
|
|
624
|
+
# - motion correction reruns only when forced or data.bin is missing
|
|
625
|
+
# - CNMF reruns only when forced or stat.npy is missing
|
|
626
|
+
# - data_raw.bin is rewritten only when motion correction will actually
|
|
627
|
+
# consume it; otherwise a prior `keep_raw=False` would waste a full
|
|
628
|
+
# binary write on every rerun even though we already have data.bin.
|
|
629
|
+
do_reg = ops.get("do_motion_correction", ops.get("do_registration", 1))
|
|
630
|
+
do_detect = ops.get("do_cnmf", ops.get("roidetect", 1))
|
|
631
|
+
needs_reg = bool(do_reg) and (force_reg or not reg_bin.exists())
|
|
632
|
+
needs_detect = bool(do_detect) and (force_detect or not stat_file.exists())
|
|
633
|
+
needs_raw = needs_reg and (force_reg or not raw_bin.exists())
|
|
634
|
+
|
|
635
|
+
if needs_raw and isinstance(input_arr, np.ndarray):
|
|
636
|
+
# in-memory arrays can't be staged through mbo_utilities — fall back
|
|
637
|
+
# to a direct binary dump in the same suite2p layout.
|
|
638
|
+
arr3d = np.asarray(input_arr).squeeze()
|
|
639
|
+
if arr3d.ndim != 3:
|
|
640
|
+
raise ValueError(f"expected 3D array (T, Y, X), got {arr3d.shape}")
|
|
641
|
+
T, Ly, Lx = arr3d.shape
|
|
642
|
+
ops["Ly"], ops["Lx"] = Ly, Lx
|
|
643
|
+
ops["nframes"] = T
|
|
644
|
+
bin_start = time.time()
|
|
645
|
+
print(f" Writing data_raw.bin {arr3d.shape}...")
|
|
646
|
+
np.clip(arr3d, np.iinfo(np.int16).min, np.iinfo(np.int16).max
|
|
647
|
+
).astype(np.int16).tofile(str(raw_bin))
|
|
648
|
+
add_processing_step(
|
|
649
|
+
ops, "binary_write",
|
|
650
|
+
input_files=[str(input_path)],
|
|
651
|
+
duration_seconds=time.time() - bin_start,
|
|
652
|
+
extra={"plane": plane, "shape": [T, Ly, Lx]},
|
|
653
|
+
)
|
|
654
|
+
elif needs_raw:
|
|
655
|
+
print(f" Writing data_raw.bin...")
|
|
656
|
+
bin_start = time.time()
|
|
657
|
+
md_combined = {**metadata, **ops}
|
|
658
|
+
write_planes = [plane] if is_volumetric_source else None
|
|
659
|
+
write_kw = dict(writer_kwargs)
|
|
660
|
+
if frame_indices is not None:
|
|
661
|
+
write_kw["frames"] = [int(i) + 1 for i in frame_indices]
|
|
662
|
+
write_kw.pop("num_frames", None)
|
|
663
|
+
|
|
664
|
+
imwrite(
|
|
665
|
+
input_arr,
|
|
666
|
+
plane_dir,
|
|
667
|
+
ext=".bin",
|
|
668
|
+
metadata=md_combined,
|
|
669
|
+
output_name="data_raw.bin",
|
|
670
|
+
overwrite=True,
|
|
671
|
+
planes=write_planes,
|
|
672
|
+
show_progress=False,
|
|
673
|
+
**write_kw,
|
|
674
|
+
)
|
|
675
|
+
if ops_file.exists():
|
|
676
|
+
ops = np.load(ops_file, allow_pickle=True).item()
|
|
677
|
+
add_processing_step(
|
|
678
|
+
ops, "binary_write",
|
|
679
|
+
input_files=[str(input_path)],
|
|
680
|
+
duration_seconds=time.time() - bin_start,
|
|
681
|
+
extra={"plane": plane, "shape": [
|
|
682
|
+
ops.get("nframes", 0), ops.get("Ly", 0), ops.get("Lx", 0)
|
|
683
|
+
]},
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
Ly = int(ops.get("Ly", 0))
|
|
687
|
+
Lx = int(ops.get("Lx", 0))
|
|
688
|
+
T_raw = int(ops.get("nframes", 0))
|
|
689
|
+
if not (Ly and Lx and T_raw):
|
|
690
|
+
# ops written by mbo_utilities is the source of truth; fall back to
|
|
691
|
+
# inferring from the binary size if the writer left them empty.
|
|
692
|
+
if raw_bin.exists():
|
|
693
|
+
nbytes = raw_bin.stat().st_size
|
|
694
|
+
if Ly and Lx:
|
|
695
|
+
T_raw = nbytes // (2 * Ly * Lx)
|
|
696
|
+
ops["nframes"] = T_raw
|
|
697
|
+
if not (Ly and Lx and T_raw):
|
|
698
|
+
raise RuntimeError(
|
|
699
|
+
"could not determine (T, Ly, Lx) for data.bin layout — "
|
|
700
|
+
"ensure mbo_utilities.imwrite populated ops with Lx/Ly/nframes."
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
if needs_reg:
|
|
704
|
+
print(" Running motion correction...")
|
|
705
|
+
mc_start = time.time()
|
|
706
|
+
try:
|
|
707
|
+
mc_result = _run_motion_correction(raw_bin, T_raw, Ly, Lx, ops, plane_dir)
|
|
708
|
+
ops.update(mc_result)
|
|
709
|
+
T_reg, Ly_reg, Lx_reg = _convert_mmap_to_bin(
|
|
710
|
+
Path(mc_result["mmap_file"]), reg_bin,
|
|
711
|
+
)
|
|
712
|
+
ops["Ly"], ops["Lx"], ops["nframes"] = Ly_reg, Lx_reg, T_reg
|
|
713
|
+
try:
|
|
714
|
+
Path(mc_result["mmap_file"]).unlink(missing_ok=True)
|
|
715
|
+
except Exception:
|
|
716
|
+
pass
|
|
717
|
+
add_processing_step(
|
|
718
|
+
ops, "motion_correction",
|
|
719
|
+
input_files=[str(raw_bin)],
|
|
720
|
+
duration_seconds=time.time() - mc_start,
|
|
721
|
+
extra={"shape": [T_reg, Ly_reg, Lx_reg]},
|
|
722
|
+
)
|
|
723
|
+
except Exception as e:
|
|
724
|
+
print(f" Motion correction failed: {e}")
|
|
725
|
+
traceback.print_exc()
|
|
726
|
+
ops["mcorr_error"] = str(e)
|
|
727
|
+
elif do_reg and reg_bin.exists():
|
|
728
|
+
print(" Motion correction cached, skipping.")
|
|
729
|
+
|
|
730
|
+
# frame counts after registration
|
|
731
|
+
T = int(ops.get("nframes", T_raw))
|
|
732
|
+
Ly = int(ops.get("Ly", Ly))
|
|
733
|
+
Lx = int(ops.get("Lx", Lx))
|
|
734
|
+
|
|
735
|
+
# full-frame defaults so plotting helpers don't trip on missing keys
|
|
736
|
+
ops.setdefault("yrange", np.array([0, Ly], dtype=np.int32))
|
|
737
|
+
ops.setdefault("xrange", np.array([0, Lx], dtype=np.int32))
|
|
738
|
+
ops.setdefault("badframes", np.zeros(T, dtype=bool))
|
|
739
|
+
|
|
740
|
+
if needs_detect and reg_bin.exists():
|
|
741
|
+
print(" Running CNMF...")
|
|
742
|
+
cnmf_start = time.time()
|
|
743
|
+
try:
|
|
744
|
+
mmap_file = _ensure_caiman_mmap(plane_dir, reg_bin, T, Ly, Lx)
|
|
745
|
+
cnmf_result = _run_cnmf(mmap_file, ops, plane_dir, T, Ly, Lx)
|
|
746
|
+
ops.update(cnmf_result["ops"])
|
|
747
|
+
add_processing_step(
|
|
748
|
+
ops, "cnmf",
|
|
749
|
+
duration_seconds=time.time() - cnmf_start,
|
|
750
|
+
extra={"n_cells": cnmf_result["n_cells"]},
|
|
751
|
+
)
|
|
752
|
+
try:
|
|
753
|
+
mmap_file.unlink(missing_ok=True)
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
except Exception as e:
|
|
757
|
+
print(f" CNMF failed: {e}")
|
|
758
|
+
traceback.print_exc()
|
|
759
|
+
ops["cnmf_error"] = str(e)
|
|
760
|
+
elif do_detect and stat_file.exists():
|
|
761
|
+
print(" CNMF cached, skipping.")
|
|
762
|
+
|
|
763
|
+
# post-processing: dff, roi stats, figures
|
|
764
|
+
F_file = plane_dir / "F.npy"
|
|
765
|
+
Fneu_file = plane_dir / "Fneu.npy"
|
|
766
|
+
if F_file.exists() and Fneu_file.exists():
|
|
767
|
+
print(" Computing dF/F...")
|
|
768
|
+
dff_start = time.time()
|
|
769
|
+
F = np.load(F_file)
|
|
770
|
+
Fneu = np.load(Fneu_file)
|
|
771
|
+
F_for_dff = F - 0.7 * Fneu if correct_neuropil else F
|
|
772
|
+
try:
|
|
773
|
+
from lbm_suite2p_python.postprocessing import dff_rolling_percentile
|
|
774
|
+
except ImportError:
|
|
775
|
+
from lbm_caiman_python.postprocessing import dff_rolling_percentile
|
|
776
|
+
dff = dff_rolling_percentile(
|
|
777
|
+
F_for_dff,
|
|
778
|
+
window_size=dff_window_size,
|
|
779
|
+
percentile=dff_percentile,
|
|
780
|
+
smooth_window=dff_smooth_window,
|
|
781
|
+
fs=float(ops.get("fs", ops.get("fr", 30.0))),
|
|
782
|
+
tau=float(ops.get("tau", ops.get("decay_time", 1.0))),
|
|
783
|
+
)
|
|
784
|
+
np.save(plane_dir / "dff.npy", dff)
|
|
785
|
+
add_processing_step(
|
|
786
|
+
ops, "dff_calculation",
|
|
787
|
+
duration_seconds=time.time() - dff_start,
|
|
788
|
+
extra={"percentile": dff_percentile},
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
# persist post-processing knobs into ops for mbo studio
|
|
792
|
+
ops["dff_window_size"] = dff_window_size
|
|
793
|
+
ops["dff_percentile"] = dff_percentile
|
|
794
|
+
ops["dff_smooth_window"] = dff_smooth_window
|
|
795
|
+
ops["correct_neuropil"] = bool(correct_neuropil)
|
|
796
|
+
ops["accept_all_cells"] = bool(accept_all_cells)
|
|
797
|
+
ops["save_json"] = bool(save_json)
|
|
798
|
+
ops["rastermap_kwargs"] = rastermap_kwargs
|
|
799
|
+
|
|
800
|
+
if accept_all_cells and (plane_dir / "iscell.npy").exists():
|
|
801
|
+
iscell_orig = np.load(plane_dir / "iscell.npy", allow_pickle=True)
|
|
802
|
+
np.save(plane_dir / "iscell_suite2p.npy", iscell_orig.copy())
|
|
803
|
+
iscell_new = iscell_orig.copy()
|
|
804
|
+
iscell_new[:, 0] = 1
|
|
805
|
+
np.save(plane_dir / "iscell.npy", iscell_new)
|
|
806
|
+
|
|
807
|
+
np.save(ops_file, ops)
|
|
808
|
+
|
|
809
|
+
# roi_stats.npy (uses lsp helper which expects all suite2p files in place)
|
|
810
|
+
try:
|
|
811
|
+
from lbm_suite2p_python.postprocessing import compute_roi_stats
|
|
812
|
+
compute_roi_stats(plane_dir)
|
|
813
|
+
except Exception as e:
|
|
814
|
+
print(f" Warning: ROI stats failed: {e}")
|
|
815
|
+
|
|
816
|
+
# figures
|
|
817
|
+
try:
|
|
818
|
+
from lbm_suite2p_python.zplane import plot_zplane_figures
|
|
819
|
+
plot_zplane_figures(
|
|
820
|
+
plane_dir,
|
|
821
|
+
dff_percentile=dff_percentile,
|
|
822
|
+
dff_window_size=dff_window_size,
|
|
823
|
+
dff_smooth_window=dff_smooth_window,
|
|
824
|
+
correct_neuropil=correct_neuropil,
|
|
825
|
+
run_rastermap=rastermap_kwargs is not None,
|
|
826
|
+
rastermap_kwargs=rastermap_kwargs,
|
|
827
|
+
)
|
|
828
|
+
except Exception as e:
|
|
829
|
+
print(f" Warning: figure generation failed: {e}")
|
|
830
|
+
|
|
831
|
+
if save_json:
|
|
832
|
+
try:
|
|
833
|
+
from lbm_suite2p_python.postprocessing import ops_to_json
|
|
834
|
+
ops_to_json(ops_file)
|
|
835
|
+
except Exception as e:
|
|
836
|
+
print(f" Warning: ops_to_json failed: {e}")
|
|
837
|
+
|
|
838
|
+
# cleanup bin files per keep_raw/keep_reg
|
|
839
|
+
if not keep_raw and raw_bin.exists():
|
|
840
|
+
raw_bin.unlink()
|
|
841
|
+
if not keep_reg and reg_bin.exists():
|
|
842
|
+
reg_bin.unlink()
|
|
843
|
+
|
|
844
|
+
return ops_file
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _run_motion_correction(raw_bin, T, Ly, Lx, ops, output_dir):
|
|
848
|
+
"""Run CaImAn rigid/PWrigid motion correction reading from ``data_raw.bin``.
|
|
849
|
+
|
|
850
|
+
CaImAn's ``MotionCorrect`` accepts file paths; rather than re-encoding
|
|
851
|
+
to TIFF we read the suite2p binary into a numpy memmap, save once as a
|
|
852
|
+
CaImAn-format mmap, and let MotionCorrect consume that.
|
|
853
|
+
"""
|
|
854
|
+
from caiman.motion_correction import MotionCorrect
|
|
855
|
+
|
|
856
|
+
# Stage a CaImAn-format mmap CaImAn can read directly. The basename
|
|
857
|
+
# prefix is distinct from the CNMF mmap (``Yr_…``) so motion-correction
|
|
858
|
+
# input and CNMF input don't collide, and the filename ends in a
|
|
859
|
+
# trailing underscore so caiman.paths.decode_mmap_filename_dict skips
|
|
860
|
+
# its `int(fpart[-1])` step — any non-int / non-empty trailing token
|
|
861
|
+
# crashes the parser.
|
|
862
|
+
in_mmap = output_dir / f"mcinput_d1_{Ly}_d2_{Lx}_d3_1_order_C_frames_{T}_.mmap"
|
|
863
|
+
if not in_mmap.exists():
|
|
864
|
+
src = np.memmap(str(raw_bin), dtype=np.int16, mode="r", shape=(T, Ly, Lx))
|
|
865
|
+
fp = np.memmap(str(in_mmap), mode="w+", dtype=np.float32,
|
|
866
|
+
shape=(Ly * Lx, T), order="F")
|
|
867
|
+
for t in range(T):
|
|
868
|
+
fp[:, t] = src[t].ravel(order="F").astype(np.float32)
|
|
869
|
+
del fp
|
|
870
|
+
|
|
871
|
+
mc = MotionCorrect(
|
|
872
|
+
[str(in_mmap)],
|
|
873
|
+
dview=None,
|
|
874
|
+
max_shifts=ops.get("max_shifts", (6, 6)),
|
|
875
|
+
strides=ops.get("strides", (48, 48)),
|
|
876
|
+
overlaps=ops.get("overlaps", (24, 24)),
|
|
877
|
+
max_deviation_rigid=ops.get("max_deviation_rigid", 3),
|
|
878
|
+
pw_rigid=ops.get("pw_rigid", True),
|
|
879
|
+
gSig_filt=ops.get("gSig_filt", (2, 2)),
|
|
880
|
+
border_nan=ops.get("border_nan", "copy"),
|
|
881
|
+
niter_rig=ops.get("niter_rig", 1),
|
|
882
|
+
splits_rig=ops.get("splits_rig", 14),
|
|
883
|
+
upsample_factor_grid=ops.get("upsample_factor_grid", 4),
|
|
884
|
+
)
|
|
885
|
+
mc.motion_correct(save_movie=True)
|
|
886
|
+
|
|
887
|
+
# collect outputs in a single dict so the caller can fold them into ops
|
|
888
|
+
shifts_rig = np.asarray(mc.shifts_rig) if mc.shifts_rig else np.zeros((T, 2))
|
|
889
|
+
template = mc.total_template_rig
|
|
890
|
+
if template is None and hasattr(mc, "total_template_els"):
|
|
891
|
+
template = mc.total_template_els
|
|
892
|
+
|
|
893
|
+
np.save(output_dir / "mcorr_shifts.npy", shifts_rig)
|
|
894
|
+
if template is not None:
|
|
895
|
+
np.save(output_dir / "mcorr_template.npy", template)
|
|
896
|
+
|
|
897
|
+
mmap_out = Path(mc.mmap_file[0]) if mc.mmap_file else None
|
|
898
|
+
# consolidate the mc output into the plane directory
|
|
899
|
+
if mmap_out is not None and mmap_out.parent != output_dir:
|
|
900
|
+
import shutil
|
|
901
|
+
dst = output_dir / mmap_out.name
|
|
902
|
+
if mmap_out.exists() and mmap_out != dst:
|
|
903
|
+
shutil.move(str(mmap_out), str(dst))
|
|
904
|
+
mmap_out = dst
|
|
905
|
+
|
|
906
|
+
# cleanup the input-side mmap CaImAn no longer needs
|
|
907
|
+
try:
|
|
908
|
+
in_mmap.unlink(missing_ok=True)
|
|
909
|
+
except Exception:
|
|
910
|
+
pass
|
|
911
|
+
|
|
912
|
+
result = {
|
|
913
|
+
"shifts_rig": shifts_rig,
|
|
914
|
+
"yoff": shifts_rig[:, 0].astype(np.float32),
|
|
915
|
+
"xoff": shifts_rig[:, 1].astype(np.float32),
|
|
916
|
+
"corrXY": np.ones(T, dtype=np.float32),
|
|
917
|
+
"refImg": template,
|
|
918
|
+
"meanImg": template if template is not None else None,
|
|
919
|
+
"mmap_file": str(mmap_out) if mmap_out else None,
|
|
920
|
+
}
|
|
921
|
+
return result
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _run_cnmf(mmap_file, ops, output_dir, T, Ly, Lx):
|
|
925
|
+
"""Run CaImAn CNMF on a registered movie and emit suite2p-style files."""
|
|
926
|
+
import caiman as cm
|
|
927
|
+
from caiman.source_extraction.cnmf import CNMF
|
|
928
|
+
|
|
929
|
+
Yr, dims_mm, T_mm = cm.load_memmap(str(mmap_file))
|
|
930
|
+
images = np.reshape(Yr.T, [T_mm] + list(dims_mm), order="F")
|
|
931
|
+
print(f" CNMF input: shape={images.shape}")
|
|
932
|
+
|
|
933
|
+
n_processes = ops.get("n_processes")
|
|
934
|
+
if n_processes is None:
|
|
935
|
+
import multiprocessing
|
|
936
|
+
n_processes = max(1, multiprocessing.cpu_count() - 1)
|
|
937
|
+
|
|
938
|
+
gnb = ops.get("gnb", ops.get("nb", 1))
|
|
939
|
+
merge_thresh = ops.get("merge_thresh", ops.get("merge_thr", 0.8))
|
|
940
|
+
gSig = ops.get("gSig", (4, 4))
|
|
941
|
+
gSiz = ops.get("gSiz", None)
|
|
942
|
+
|
|
943
|
+
cnmf_kwargs = dict(
|
|
944
|
+
n_processes=n_processes,
|
|
945
|
+
k=ops.get("K", 50),
|
|
946
|
+
gSig=gSig,
|
|
947
|
+
p=ops.get("p", 1),
|
|
948
|
+
merge_thresh=merge_thresh,
|
|
949
|
+
method_init=ops.get("method_init", "greedy_roi"),
|
|
950
|
+
ssub=ops.get("ssub", 1),
|
|
951
|
+
tsub=ops.get("tsub", 1),
|
|
952
|
+
rf=ops.get("rf"),
|
|
953
|
+
stride=ops.get("stride"),
|
|
954
|
+
gnb=gnb,
|
|
955
|
+
low_rank_background=ops.get("low_rank_background", True),
|
|
956
|
+
update_background_components=ops.get("update_background_components", True),
|
|
957
|
+
rolling_sum=ops.get("rolling_sum", True),
|
|
958
|
+
only_init_patch=ops.get("only_init", False),
|
|
959
|
+
normalize_init=ops.get("normalize_init", True),
|
|
960
|
+
ring_size_factor=ops.get("ring_size_factor", 1.5),
|
|
961
|
+
fr=float(ops.get("fr", ops.get("fs", 30.0))),
|
|
962
|
+
decay_time=ops.get("decay_time", 0.4),
|
|
963
|
+
min_SNR=ops.get("min_SNR", 2.5),
|
|
964
|
+
)
|
|
965
|
+
if gSiz is not None:
|
|
966
|
+
cnmf_kwargs["gSiz"] = gSiz
|
|
967
|
+
|
|
968
|
+
cnmf = CNMF(**cnmf_kwargs)
|
|
969
|
+
cnmf.params.quality["rval_thr"] = ops.get("rval_thr", 0.85)
|
|
970
|
+
cnmf.params.quality["min_cnn_thr"] = ops.get("min_cnn_thr", 0.99)
|
|
971
|
+
cnmf.params.quality["use_cnn"] = ops.get("use_cnn", False)
|
|
972
|
+
|
|
973
|
+
cnmf.fit(images)
|
|
974
|
+
try:
|
|
975
|
+
cnmf.estimates.evaluate_components(images, cnmf.params, dview=None)
|
|
976
|
+
except Exception as e:
|
|
977
|
+
print(f" Component evaluation failed: {e}")
|
|
978
|
+
|
|
979
|
+
est = cnmf.estimates
|
|
980
|
+
n_total = est.A.shape[1] if (est.A is not None) else 0
|
|
981
|
+
n_accepted = (
|
|
982
|
+
len(est.idx_components) if getattr(est, "idx_components", None) is not None
|
|
983
|
+
else n_total
|
|
984
|
+
)
|
|
985
|
+
print(f" CNMF: {n_total} components, {n_accepted} accepted")
|
|
986
|
+
|
|
987
|
+
# synthesize suite2p outputs
|
|
988
|
+
stat = _stat_from_A(est.A, (Ly, Lx), C=est.C)
|
|
989
|
+
np.save(output_dir / "stat.npy", stat)
|
|
990
|
+
|
|
991
|
+
iscell = _iscell_from_estimates(est, n_total)
|
|
992
|
+
np.save(output_dir / "iscell.npy", iscell)
|
|
993
|
+
|
|
994
|
+
# F = raw fluorescence ≈ denoised + residual; Fneu zeros (CaImAn has no neuropil)
|
|
995
|
+
F = (est.C + (est.YrA if getattr(est, "YrA", None) is not None else 0.0)).astype(np.float32)
|
|
996
|
+
np.save(output_dir / "F.npy", F)
|
|
997
|
+
np.save(output_dir / "Fneu.npy", np.zeros_like(F))
|
|
998
|
+
|
|
999
|
+
spks = est.S if getattr(est, "S", None) is not None else np.zeros_like(F)
|
|
1000
|
+
np.save(output_dir / "spks.npy", spks.astype(np.float32))
|
|
1001
|
+
|
|
1002
|
+
# registration imagery: max_proj, Vcorr, meanImgE
|
|
1003
|
+
max_proj = np.asarray(images).max(axis=0).astype(np.float32)
|
|
1004
|
+
mean_img = np.asarray(images).mean(axis=0).astype(np.float32)
|
|
1005
|
+
vcorr = _local_correlations(images)
|
|
1006
|
+
mean_e = _enhanced_mean_image(mean_img)
|
|
1007
|
+
|
|
1008
|
+
ops_update = {
|
|
1009
|
+
"Ly": Ly,
|
|
1010
|
+
"Lx": Lx,
|
|
1011
|
+
"nframes": T,
|
|
1012
|
+
"max_proj": max_proj,
|
|
1013
|
+
"Vcorr": vcorr if vcorr is not None else max_proj,
|
|
1014
|
+
"meanImg": mean_img,
|
|
1015
|
+
"meanImgE": mean_e,
|
|
1016
|
+
}
|
|
1017
|
+
# refImg keeps the registration template when present, else falls back to mean
|
|
1018
|
+
if "refImg" not in ops or ops.get("refImg") is None:
|
|
1019
|
+
ops_update["refImg"] = mean_img
|
|
1020
|
+
|
|
1021
|
+
return {
|
|
1022
|
+
"ops": ops_update,
|
|
1023
|
+
"n_cells": int(n_accepted),
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _generate_volume_outputs(ops_files, save_path, rastermap_kwargs=None):
|
|
1028
|
+
"""Write zstats.npy and volume-level figures via lsp helpers."""
|
|
1029
|
+
try:
|
|
1030
|
+
from lbm_suite2p_python.volume import (
|
|
1031
|
+
get_volume_stats,
|
|
1032
|
+
plot_volume_diagnostics,
|
|
1033
|
+
plot_orthoslices,
|
|
1034
|
+
plot_3d_roi_map,
|
|
1035
|
+
plot_volume_trace_figures,
|
|
1036
|
+
)
|
|
1037
|
+
from lbm_suite2p_python.zplane import plot_volume_accepted_rejected_overlay
|
|
1038
|
+
except ImportError as e:
|
|
1039
|
+
print(f" Volume helpers unavailable: {e}")
|
|
1040
|
+
return
|
|
1041
|
+
|
|
1042
|
+
print("\nGenerating volume statistics...")
|
|
1043
|
+
try:
|
|
1044
|
+
get_volume_stats(ops_files, overwrite=True)
|
|
1045
|
+
except Exception as e:
|
|
1046
|
+
print(f" get_volume_stats failed: {e}")
|
|
1047
|
+
|
|
1048
|
+
print("Generating volume figures...")
|
|
1049
|
+
for label, fn in (
|
|
1050
|
+
("volume_diagnostics", lambda: plot_volume_diagnostics(ops_files, save_path)),
|
|
1051
|
+
("orthoslices", lambda: plot_orthoslices(ops_files, save_path / "orthoslices.png")),
|
|
1052
|
+
("3d_roi_map", lambda: plot_3d_roi_map(ops_files, save_path)),
|
|
1053
|
+
("accepted_rejected", lambda: plot_volume_accepted_rejected_overlay(ops_files, save_path)),
|
|
1054
|
+
("trace_figures", lambda: plot_volume_trace_figures(ops_files, save_path)),
|
|
1055
|
+
):
|
|
1056
|
+
try:
|
|
1057
|
+
fn()
|
|
1058
|
+
except Exception as e:
|
|
1059
|
+
print(f" {label} failed: {e}")
|