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.
@@ -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}")