optimouse 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.
@@ -0,0 +1,982 @@
1
+ """
2
+ Motion Correction Module for Analysis 2P Pipeline
3
+
4
+ This module handles the motion correction workflow including:
5
+ - Raw data concatenation
6
+ - Motion correction using CaImAn/Mesmerize
7
+ - Z-motion correction (optional)
8
+ - Movie output generation
9
+ - File optimization
10
+
11
+ Authors: Manuel Levy, Vincent Prevosto
12
+ Date: 2024-07-22
13
+ License: CC-BY-SA 4.0
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ # Add project root to path
22
+ PROJECT_ROOT = Path(__file__).parent.parent
23
+ if str(PROJECT_ROOT) not in sys.path:
24
+ sys.path.insert(0, str(PROJECT_ROOT))
25
+
26
+ import time
27
+ import os
28
+ import numpy as np
29
+ import h5py
30
+ from tifffile import TiffWriter, TiffFile, imread
31
+ import matplotlib.pyplot as plt
32
+ import pandas as pd
33
+ from scipy.stats import mode
34
+ from sklearn.linear_model import HuberRegressor
35
+ import shutil
36
+
37
+ # Import CaImAn and Mesmerize components
38
+ import mesmerize_core as mc
39
+ # from caiman.mmapping import load_memmap
40
+
41
+ from modules import bruker_concat_tif as ct
42
+ from modules import compute_zcorr as cz
43
+
44
+ from pipeline.utils.pipeline_utils import (
45
+ log_and_print,
46
+ create_mp4_movie,
47
+ overwrite_movie_memmap,
48
+ load_mmap_movie,
49
+ clip_range,
50
+ cat_movies_to_mp4,
51
+ memory_manager,
52
+ load_caiman_memmap,
53
+ )
54
+
55
+ def _copy_concat_sidecar(export_dir: Path, dest_movie_path: Path, allow_suite2p_dir: bool = False):
56
+ """Copy concatenation sidecar JSON next to a converted movie.
57
+
58
+ Looks for sidecar written by bruker_concat_tif (cat_tiff_bt.tiff.json or
59
+ cat_tiff.h5.json) under export_dir, and writes a copy as
60
+ f"{dest_movie_path.name}.json" next to dest_movie_path.
61
+ Also creates extracted_parameters.json in plane0 folder.
62
+ """
63
+ try:
64
+ candidates = [
65
+ export_dir / "cat_tiff_bt.tiff.json",
66
+ export_dir / "cat_tiff.h5.json",
67
+ ]
68
+ src = next((p for p in candidates if p.exists()), None)
69
+ if src is None:
70
+ return
71
+
72
+ # Copy sidecar next to movie file
73
+ dst = dest_movie_path.with_name(dest_movie_path.name + ".json")
74
+ shutil.copy2(src, dst)
75
+ log_and_print(f"Copied sidecar JSON to {dst}")
76
+
77
+ if allow_suite2p_dir:
78
+ # Move XML parameter files to plane0 folder
79
+ try:
80
+ # Create plane0 directory if it doesn't exist
81
+ plane0_dir = export_dir / "suite2p" / "plane0"
82
+ plane0_dir.mkdir(parents=True, exist_ok=True)
83
+
84
+ # Check for consolidated sidecar and copy it if needed, or handle legacy files if they exist
85
+ # (Logic to move *_imaging_params.json removed to avoid clutter/redundancy)
86
+
87
+ except Exception as e:
88
+ log_and_print(f"Warning: failed to setup suite2p directory: {e}", level='warning')
89
+
90
+
91
+ except Exception as e:
92
+ log_and_print(f"Warning: failed to copy sidecar JSON: {e}", level='warning')
93
+
94
+ def to_uint8_robust(arr, p_lo=0.1, p_hi=99.9):
95
+ # Compute robust range on the array (handles negatives, ignores outliers)
96
+ vmin, vmax = np.percentile(arr, (p_lo, p_hi))
97
+ if vmax <= vmin:
98
+ vmax = vmin + 1.0 # fallback to avoid divide-by-zero
99
+ arr = np.clip(arr, vmin, vmax)
100
+ arr = (arr - vmin) * (255.0 / (vmax - vmin))
101
+ return arr.astype(np.uint8)
102
+
103
+ def create_mcorr_movie(mcorr_movie_path, export_path, batch, index=0, format='mp4', diff_corr=True, to_uint8=True, excerpt=None):
104
+ """
105
+ Save the motion corrected movie (memmaped array) as a BigTIFF file or a mp4 movie.
106
+ If diff_corr is true (default), concatenate the original movie and the motion corrected movie horizontally.
107
+ """
108
+ # # Load the movie from the memmap file
109
+ # mcorr_movie_16bit , dims, T = load_memmap(mcorr_path)
110
+ # # Reshape the array to the desired dimensions
111
+ # mcorr_movie_16bit = np.reshape(mcorr_movie_16bit.T, [T] + list(dims), order='F')
112
+ # # At this point the images should already be transposed
113
+ # # mcorr_movie_16bit = mcorr_movie_16bit.transpose(0, 2, 1)
114
+ # # image = mcorr_movie_16bit[0,:,:]
115
+
116
+ # Load the movie from the memmap file
117
+ loaded_mcorr_movie = load_mmap_movie(mcorr_movie_path)
118
+
119
+ # If excerpt is not None, keep only the first x frames of the movie
120
+ if excerpt is not None:
121
+ loaded_mcorr_movie = loaded_mcorr_movie[:excerpt]
122
+
123
+ # Convert values to uint8
124
+ if to_uint8:
125
+ # Data is originally uint12, and is loaded as float 32, but is 16 bit range.
126
+ scale_factor = 255 / (2**16-1)
127
+ mcorr_movie_ = to_uint8_robust(loaded_mcorr_movie) #(loaded_mcorr_movie * scale_factor).astype('uint8')
128
+ else:
129
+ mcorr_movie_ = loaded_mcorr_movie.astype(np.uint16)
130
+
131
+ if format == 'mp4':
132
+ if diff_corr:
133
+ # Load the data frame from the batch
134
+ df = mc.load_batch(batch)
135
+ # Get the original movie as a memmaped numpy array
136
+ original_movie = df.iloc[index].caiman.get_input_movie()
137
+ if excerpt is not None:
138
+ original_movie = original_movie[:excerpt]
139
+
140
+ # Convert values to uint8
141
+ if to_uint8:
142
+ scale_factor = 255 / (2**16-1)
143
+ original_movie_ = to_uint8_robust(original_movie) #(original_movie * scale_factor).astype('uint8')
144
+ else:
145
+ original_movie_ = original_movie.astype(np.uint16)
146
+ # Set the path of the mp4 movie
147
+ movie_path = Path.joinpath(export_path, f"compare_og_mcorr.mp4")
148
+ # Concatenate the two movies horizontally
149
+ cat_movies_to_mp4(original_movie_, mcorr_movie_, movie_path)
150
+ log_and_print(f"Saved original vs motion corrected movie to {movie_path}.")
151
+
152
+ return movie_path
153
+ else:
154
+ # Save the motion corrected movie as a mp4 movie
155
+ # movie_path = Path.joinpath(export_path, f"mcorr.mp4")
156
+ create_mp4_movie(mcorr_movie_, export_path, 'mcorr.mp4')
157
+ log_and_print(f"Saved motion corrected movie to {export_path}/mcorr.mp4")
158
+
159
+ return Path.joinpath(export_path, 'mcorr.mp4')
160
+ else:
161
+ # Save the motion corrected movie as a BigTIFF file (8 bit by default)
162
+ if to_uint8:
163
+ mcorr_tif_path = Path.joinpath(export_path, f"mcorr_u8.tiff")
164
+ else:
165
+ mcorr_tif_path = Path.joinpath(export_path, f"mcorr_u16.tiff")
166
+ with TiffWriter(mcorr_tif_path, bigtiff=True) as tif:
167
+ tif.write(mcorr_movie_, photometric='minisblack')
168
+ if to_uint8:
169
+ log_and_print(f"Saved 8 bit motion corrected movie to {mcorr_tif_path}.")
170
+ else:
171
+ log_and_print(f"Saved 16 bit motion corrected movie to {mcorr_tif_path}.")
172
+
173
+ return mcorr_tif_path
174
+
175
+ def compute_movie_residuals(mcorr_movie_path, zcorr_movie, export_path):
176
+ """
177
+ Compute the residuals between the motion corrected movie (x/y), and the z-motion corrected movie (z).
178
+ """
179
+ # Load the motion corrected movie
180
+ # loaded_mcorr_movie , dims, T = load_memmap(clipped_mcorr_path)
181
+ # loaded_mcorr_movie = np.reshape(loaded_mcorr_movie.T, [T] + list(dims), order='F')
182
+ # loaded_mcorr_movie = loaded_mcorr_movie.transpose(0, 2, 1)
183
+ loaded_mcorr_movie = load_caiman_memmap(mcorr_movie_path)
184
+
185
+ # Compute the difference between the motion corrected movie and the z-motion corrected movie (residuals for each frame)
186
+ residual_movie = np.zeros_like(loaded_mcorr_movie)
187
+ for i, frame in enumerate(loaded_mcorr_movie):
188
+ residual_movie[i] = zcorr_movie[i] - frame
189
+
190
+ # Convert all three movies' values to uint8, then concatenate them horizontally and save as a mp4 movie
191
+ mcorr_movie_ = (loaded_mcorr_movie / (2**16-1) * 255).astype('uint8')
192
+ zcorr_movie_ = (zcorr_movie / (2**16-1) * 255).astype('uint8')
193
+ residual_movie_ = (residual_movie / (2**16-1) * 255).astype('uint8')
194
+
195
+ # Set the path of the mp4 movie
196
+ movie_path = Path.joinpath(export_path, f"compare_mcorr_zcorr_residuals.mp4")
197
+
198
+ # Concatenate the three movies horizontally
199
+ cat_movie = np.concatenate((mcorr_movie_, zcorr_movie_, residual_movie_), axis=2)
200
+ create_mp4_movie(cat_movie, export_path, 'compare_mcorr_zcorr_residuals.mp4')
201
+
202
+
203
+ def run_roi_zcorr(export_path, parameters):
204
+ """Correct ROI fluorescence traces for z-motion artifacts.
205
+
206
+ Parameters
207
+ ----------
208
+ export_path : str or Path
209
+ Directory containing extraction outputs and z-motion files.
210
+ parameters : dict
211
+ Dictionary with processing parameters (unused, reserved for future use).
212
+ """
213
+
214
+ export_path = Path(export_path)
215
+ log_and_print("Starting ROI z-motion correction.")
216
+
217
+ zcorr_file = export_path / "z_correlation.npz"
218
+ f_anat_files = sorted(export_path.glob("F_anat_non_rigid*.tiff"))
219
+
220
+ cnmf_file = export_path / "cnmf_result.hdf5"
221
+
222
+ # Locate Suite2p outputs flexibly: root, suite2p/plane0, any suite2p/plane*, or stray plane0
223
+ s2p_dir = None
224
+ s2p_files = None
225
+ candidates = []
226
+ # Root-level outputs
227
+ candidates.append(export_path)
228
+ # Standard suite2p plane0
229
+ candidates.append(export_path / "suite2p" / "plane0")
230
+ # Any suite2p plane*
231
+ candidates.extend(sorted((export_path / "suite2p").glob("plane*"))) if (export_path / "suite2p").exists() else None
232
+ # Stray plane0 directly under export_path (seen in some save_mat behaviors)
233
+ candidates.append(export_path / "plane0")
234
+
235
+ for cdir in [c for c in candidates if c]:
236
+ Fp = cdir / "F.npy"
237
+ Sp = cdir / "stat.npy"
238
+ if Fp.exists() and Sp.exists():
239
+ s2p_dir = cdir
240
+ s2p_files = [Fp, Sp]
241
+ try:
242
+ log_and_print(f"Detected Suite2p outputs in: {s2p_dir}")
243
+ except Exception:
244
+ pass
245
+ break
246
+
247
+ if not zcorr_file.exists() or not f_anat_files:
248
+ log_and_print(
249
+ "Required files for ROI z-motion correction missing. Skipping.",
250
+ level="warning",
251
+ )
252
+ return None
253
+
254
+ if cnmf_file.exists():
255
+ extractor = "cnmf"
256
+ elif s2p_dir is not None:
257
+ extractor = "suite2p"
258
+ else:
259
+ log_and_print(
260
+ "No supported extraction outputs found in export path. Skipping.",
261
+ level="warning",
262
+ )
263
+ return None
264
+
265
+ with memory_manager("roi z-motion correction"):
266
+ zpos = np.load(zcorr_file)["zpos"]
267
+ f_anat_movie = imread(str(f_anat_files[0]))
268
+ # tifffile returns (T, Y, X); transpose to (Y, X, T)
269
+ if f_anat_movie.shape[0] == zpos.shape[0]:
270
+ f_anat_movie = np.transpose(f_anat_movie, (1, 2, 0))
271
+
272
+ if extractor == "cnmf":
273
+ from caiman.source_extraction.cnmf.cnmf import CNMF
274
+
275
+ cnm = CNMF()
276
+ cnm.load(str(cnmf_file))
277
+ F = cnm.estimates.F
278
+ A = cnm.estimates.A.tocsc()
279
+ d1 = cnm.estimates.d1
280
+ d2 = cnm.estimates.d2
281
+ n_neuron, n_frame = F.shape
282
+
283
+ Fz = np.zeros((n_neuron, n_frame))
284
+ Fz_rescaled = np.zeros_like(F)
285
+ Fcorrected = np.zeros_like(F)
286
+ b = np.zeros(n_neuron)
287
+
288
+ for i in range(n_neuron):
289
+ ind = A[:, i].nonzero()[0]
290
+ y, x = np.unravel_index(ind, (d1, d2))
291
+ weights = np.full(len(x), 1 / len(x))
292
+ for w, yy, xx in zip(weights, y, x):
293
+ Fz[i] += w * f_anat_movie[yy, xx, :]
294
+ F0 = F[i].mean()
295
+ Fz0 = Fz[i].mean()
296
+ huber = HuberRegressor(fit_intercept=False)
297
+ huber.fit((Fz[i] - Fz0).reshape(-1, 1), F[i] - F0)
298
+ b[i] = huber.coef_[0]
299
+ Fz_rescaled[i] = b[i] * (Fz[i] - Fz0)
300
+ Fcorrected[i] = F[i] - Fz_rescaled[i]
301
+
302
+ else: # Suite2p
303
+ F = np.load(str(s2p_files[0]))
304
+ stat = np.load(str(s2p_files[1]), allow_pickle=True)
305
+ n_neuron, n_frame = F.shape
306
+ Fz = np.zeros((n_neuron, n_frame))
307
+ Fz_rescaled = np.zeros_like(F)
308
+ Fcorrected = np.zeros_like(F)
309
+ b = np.zeros(n_neuron)
310
+
311
+ for i, cell in enumerate(stat):
312
+ ypix = cell["ypix"].astype(int)
313
+ xpix = cell["xpix"].astype(int)
314
+ lam = cell["lam"].astype(float)
315
+ weights = lam / lam.sum() if lam.sum() != 0 else np.full(len(lam), 1 / len(lam))
316
+ for w, yy, xx in zip(weights, ypix, xpix):
317
+ Fz[i] += w * f_anat_movie[yy, xx, :]
318
+ F0 = F[i].mean()
319
+ Fz0 = Fz[i].mean()
320
+ huber = HuberRegressor(fit_intercept=False)
321
+ huber.fit((Fz[i] - Fz0).reshape(-1, 1), F[i] - F0)
322
+ b[i] = huber.coef_[0]
323
+ Fz_rescaled[i] = b[i] * (Fz[i] - Fz0)
324
+ Fcorrected[i] = F[i] - Fz_rescaled[i]
325
+
326
+ z_mode = mode(zpos, keepdims=False).mode
327
+ if np.ndim(z_mode) > 0:
328
+ z_mode = z_mode[0]
329
+ missing = np.abs(zpos - z_mode) > 5
330
+ if np.any(missing):
331
+ Fcorrected[:, missing] = np.nan
332
+ Fcorrected = (
333
+ pd.DataFrame(Fcorrected)
334
+ .interpolate(method="linear", axis=1, limit_direction="both")
335
+ .to_numpy()
336
+ )
337
+
338
+ # Save results
339
+ np.savez_compressed(export_path / "F_roi_zcorrected.npz",
340
+ F_roi_zcorrected=Fcorrected,
341
+ F_roi_zbaseline=Fz_rescaled,
342
+ roi_z_scaling=b)
343
+
344
+ try:
345
+ import matplotlib.pyplot as plt
346
+
347
+ plots_dir = export_path / "plots"
348
+ plots_dir.mkdir(parents=True, exist_ok=True)
349
+
350
+ plt.figure()
351
+ plt.plot(zpos)
352
+ plt.xlabel("Frame")
353
+ plt.ylabel("Z position (µm)")
354
+ plt.tight_layout()
355
+ plt.savefig(plots_dir / "roi_z_drift.png")
356
+ plt.close()
357
+
358
+ plt.figure()
359
+ plt.hist(b, bins=30)
360
+ plt.xlabel("Scaling factor")
361
+ plt.ylabel("Count")
362
+ plt.tight_layout()
363
+ plt.savefig(plots_dir / "roi_z_scaling_hist.png")
364
+ plt.close()
365
+ except Exception as e:
366
+ log_and_print(f"Could not save diagnostic plots: {e}", level="warning")
367
+
368
+ log_and_print("ROI z-motion correction completed.")
369
+ return export_path
370
+
371
+ def save_movie_as_h5(memmap_path, h5_path, parameters, dtype_out='uint16', scale=None, chunk_size=256):
372
+ """
373
+ Streamed save of motion-corrected movie as HDF5 (AIND / Suite2p extraction-ready).
374
+
375
+ Avoids loading full movie in RAM by iterating through CaImAn memmap frames.
376
+ """
377
+ log_and_print(f"Saving final movie to {h5_path} (dtype_out={dtype_out})")
378
+ adapter = load_caiman_memmap(memmap_path)
379
+ T, Ly, Lx = adapter.shape
380
+ log_and_print(f"Memmap adapter: frames={T}, Ly={Ly}, Lx={Lx}, dtype={adapter.dtype}")
381
+
382
+ # Metadata extraction
383
+ try:
384
+ imaging = parameters.get('imaging', {}) if parameters else {}
385
+ except Exception:
386
+ imaging = {}
387
+ frame_rate = imaging.get('fr') or imaging.get('fs') or 30.0
388
+ pixel_size_um = imaging.get('microns_per_pixel', 1.0)
389
+
390
+ if dtype_out not in ('uint16','float32'):
391
+ raise ValueError("dtype_out must be 'uint16' or 'float32'")
392
+
393
+ running_min = np.inf
394
+ running_max = -np.inf
395
+
396
+ # Create a figure of pixel values histograms
397
+ # fig, axs = plt.subplots(1, 2, figsize=(12, 6))
398
+ # axs[0].hist(adapter.flatten(), bins=100, color='gray')
399
+ # axs[0].set_title('Histogram of Pixel Values (Memmap)')
400
+ # axs[0].set_xlabel('Pixel Value')
401
+ # axs[0].set_ylabel('Frequency')
402
+
403
+ with h5py.File(h5_path, 'w') as f:
404
+ if dtype_out == 'uint16':
405
+ dset = f.create_dataset('data', shape=(T, Ly, Lx), dtype='uint16', compression=None)
406
+ else:
407
+ dset = f.create_dataset('data', shape=(T, Ly, Lx), dtype='float32', compression=None)
408
+ dset.attrs['fs'] = frame_rate
409
+ dset.attrs['n_frames'] = int(T)
410
+ dset.attrs['Ly'] = int(Ly)
411
+ dset.attrs['Lx'] = int(Lx)
412
+ dset.attrs['pixel_size_um'] = float(pixel_size_um)
413
+
414
+ for start in range(0, T, chunk_size):
415
+ stop = min(T, start + chunk_size)
416
+ block = adapter[start:stop].astype(np.float32, copy=False) # (chunk, Ly, Lx)
417
+ if scale is not None:
418
+ block *= scale
419
+ # Update stats before conversion
420
+ blk_min = float(block.min())
421
+ blk_max = float(block.max())
422
+ if blk_min < running_min: running_min = blk_min
423
+ if blk_max > running_max: running_max = blk_max
424
+
425
+ if dtype_out == 'uint16':
426
+ if block.min() < 0:
427
+ # shift to positive per-chunk if needed
428
+ block = block - block.min()
429
+ block = clip_range(block, 'uint16').astype(np.uint16, copy=False)
430
+ else: # float32
431
+ block = block.astype(np.float32, copy=False)
432
+
433
+ dset[start:stop] = np.ascontiguousarray(block)
434
+ if (start // chunk_size) % 20 == 0 or stop == T:
435
+ log_and_print(f" Wrote frames {start}:{stop} (min={blk_min:.1f}, max={blk_max:.1f})")
436
+
437
+ log_and_print(f"H5 written: {h5_path} (range min={running_min:.2f}, max={running_max:.2f})")
438
+
439
+ # Quick verification (first frame only)
440
+ try:
441
+ with h5py.File(h5_path, 'r') as f:
442
+ test = f['data'][0]
443
+ if test.shape != (Ly, Lx):
444
+ log_and_print("WARNING: First frame shape mismatch on H5 read-back", level='warning')
445
+ else:
446
+ log_and_print("H5 read-back first frame OK")
447
+ except Exception as e:
448
+ log_and_print(f"H5 verification failed: {e}", level='warning')
449
+
450
+ # choose bin edges using running_min/running_max computed above
451
+ # nbins = 100
452
+ # bin_edges = np.linspace(running_min, running_max, nbins + 1)
453
+ # hist_counts = np.zeros(nbins, dtype=np.int64)
454
+
455
+ # # Compute histogram of pixel values on h5 file
456
+ # with h5py.File(h5_path, 'r') as f:
457
+ # data = f['data'][:]
458
+ # hist_counts, _ = np.histogram(data, bins=bin_edges)
459
+
460
+ # axs[1].bar(bin_edges[:-1], hist_counts, width=np.diff(bin_edges), color='gray')
461
+ # axs[1].set_title('Histogram of Pixel Values (H5)')
462
+ # axs[1].set_xlabel('Pixel Value')
463
+ # axs[1].set_ylabel('Frequency')
464
+ # plt.tight_layout()
465
+
466
+ # # Save figure
467
+ # histo_fig_path = h5_path.parent / "plots" / "pixel_value_histogram_h5.png"
468
+ # plt.savefig(histo_fig_path)
469
+ # plt.close(fig)
470
+
471
+ adapter.close()
472
+ # Copy concatenation sidecar JSON next to the new file
473
+ allow_suite2p_dir = (
474
+ parameters
475
+ and parameters.get("params_extraction", {}).get("method") in {"suite2p", "aind"}
476
+ )
477
+ _copy_concat_sidecar(Path(h5_path).parent, Path(h5_path), allow_suite2p_dir=allow_suite2p_dir)
478
+ return Path(h5_path)
479
+
480
+ def save_movie_as_bin(memmap_path, bin_path, parameters=None, chunk_size=512, scale=None):
481
+ """
482
+ Stream-save motion-corrected movie as Suite2p-compatible .bin file (int16 C-order).
483
+ """
484
+ log_and_print(f"Saving final movie to {bin_path} (chunk_size={chunk_size})")
485
+ adapter = load_caiman_memmap(memmap_path)
486
+ T, Ly, Lx = adapter.shape
487
+ log_and_print(f"Memmap adapter: frames={T}, Ly={Ly}, Lx={Lx}, dtype={adapter.dtype}")
488
+
489
+ running_min = np.inf
490
+ running_max = -np.inf
491
+
492
+ # Create a figure of pixel values histograms
493
+ # fig, axs = plt.subplots(1, 2, figsize=(12, 6))
494
+ # axs[0].hist(adapter.flatten(), bins=100, color='gray')
495
+ # axs[0].set_title('Histogram of Pixel Values (Memmap)')
496
+ # axs[0].set_xlabel('Pixel Value')
497
+ # axs[0].set_ylabel('Frequency')
498
+
499
+ with open(bin_path, 'wb') as f:
500
+ for start in range(0, T, chunk_size):
501
+ stop = min(T, start + chunk_size)
502
+ block = adapter[start:stop].astype(np.float32, copy=False)
503
+ if scale is not None:
504
+ block *= scale
505
+ blk_min = float(block.min()); blk_max = float(block.max())
506
+ if blk_min < running_min: running_min = blk_min
507
+ if blk_max > running_max: running_max = blk_max
508
+ block = clip_range(block, 'int16').astype(np.int16, copy=False)
509
+ f.write(np.ascontiguousarray(block).tobytes())
510
+ if (start // chunk_size) % 20 == 0 or stop == T:
511
+ log_and_print(f" Wrote frames {start}:{stop} (min={blk_min:.1f}, max={blk_max:.1f})")
512
+
513
+ # Verify size
514
+ expected_size = T * Ly * Lx * 2
515
+ actual_size = Path(bin_path).stat().st_size
516
+ log_and_print(f"Binary written: {bin_path} bytes={actual_size} expected={expected_size}")
517
+ if actual_size != expected_size:
518
+ log_and_print("WARNING: Size mismatch in .bin export", level='warning')
519
+
520
+ # Quick read-back of first frame
521
+ try:
522
+ first = np.fromfile(bin_path, dtype=np.int16, count=Ly*Lx).reshape(Ly, Lx)
523
+ log_and_print(f"First frame read-back: min={first.min()} max={first.max()}")
524
+ except Exception as e:
525
+ log_and_print(f"Read-back failed: {e}", level='warning')
526
+
527
+ # # Compute histogram of pixel values
528
+ # hist, bin_edges = np.histogram(np.fromfile(bin_path, dtype=np.int16), bins=100)
529
+ # axs[1].bar(bin_edges[:-1], hist, width=np.diff(bin_edges), color='gray')
530
+ # axs[1].set_title('Histogram of Pixel Values (Bin)')
531
+ # axs[1].set_xlabel('Pixel Value')
532
+ # axs[1].set_ylabel('Frequency')
533
+ # plt.tight_layout()
534
+
535
+ # # Save figure
536
+ # histo_fig_path = bin_path.parent / "plots" / "pixel_value_histogram_bin.png"
537
+ # plt.savefig(histo_fig_path)
538
+ # plt.close(fig)
539
+
540
+ log_and_print(f"Range across stream: min={running_min:.2f}, max={running_max:.2f}")
541
+ adapter.close()
542
+ log_and_print(f"✓ Successfully saved .bin movie to {bin_path}")
543
+ # Copy concatenation sidecar JSON next to the new file
544
+ allow_suite2p_dir = (
545
+ parameters
546
+ and parameters.get("params_extraction", {}).get("method") in {"suite2p", "aind"}
547
+ )
548
+ _copy_concat_sidecar(Path(bin_path).parent, Path(bin_path), allow_suite2p_dir=allow_suite2p_dir)
549
+ return Path(bin_path)
550
+
551
+ def save_movie_as_tiff(memmap_path, tiff_path, parameters=None, chunk_size=256, dtype_out='uint16', scale=None):
552
+ """Stream-save motion-corrected movie as BigTIFF (multi-page).
553
+
554
+ Frames are written sequentially to avoid loading the full movie. By default, scales/clips to uint16.
555
+ """
556
+ log_and_print(f"Saving final movie to {tiff_path} (dtype_out={dtype_out})")
557
+ adapter = load_caiman_memmap(memmap_path)
558
+ T, Ly, Lx = adapter.shape
559
+ log_and_print(f"Memmap adapter: frames={T}, Ly={Ly}, Lx={Lx}, dtype={adapter.dtype}")
560
+
561
+ running_min = np.inf
562
+ running_max = -np.inf
563
+
564
+ # # Create a figure of pixel values histograms
565
+ # fig, axs = plt.subplots(1, 2, figsize=(12, 6))
566
+ # axs[0].hist(adapter.flatten(), bins=100, color='gray')
567
+ # axs[0].set_title('Histogram of Pixel Values (Memmap)')
568
+ # axs[0].set_xlabel('Pixel Value')
569
+ # axs[0].set_ylabel('Frequency')
570
+
571
+ # choose converter
572
+ def to_dtype(frames: np.ndarray) -> np.ndarray:
573
+ arr = frames
574
+ if scale is not None:
575
+ arr = arr.astype(np.float32) * float(scale)
576
+ if dtype_out == 'uint16':
577
+ arr = clip_range(arr, 'uint16').astype(np.uint16)
578
+ elif dtype_out == 'float32':
579
+ arr = arr.astype(np.float32)
580
+ else:
581
+ raise ValueError("dtype_out must be 'uint16' or 'float32'")
582
+ return arr
583
+
584
+ with TiffWriter(str(tiff_path), bigtiff=True) as tif:
585
+ for start in range(0, T, chunk_size):
586
+ stop = min(T, start + chunk_size)
587
+ chunk = adapter[start:stop] # (chunk, Ly, Lx)
588
+ running_min = min(running_min, float(np.min(chunk)))
589
+ running_max = max(running_max, float(np.max(chunk)))
590
+ out = to_dtype(chunk)
591
+ for k in range(out.shape[0]):
592
+ tif.write(out[k], contiguous=True, photometric='minisblack')
593
+
594
+ log_and_print(f"TIFF written: {tiff_path} (range min={running_min:.2f}, max={running_max:.2f})")
595
+ adapter.close()
596
+
597
+ # Copy concatenation sidecar JSON next to the new file
598
+ allow_suite2p_dir = (
599
+ parameters
600
+ and parameters.get("params_extraction", {}).get("method") in {"suite2p", "aind"}
601
+ )
602
+ _copy_concat_sidecar(Path(tiff_path).parent, Path(tiff_path), allow_suite2p_dir=allow_suite2p_dir)
603
+
604
+ # # choose bin edges using running_min/running_max computed above
605
+ # nbins = 100
606
+ # bin_edges = np.linspace(running_min, running_max, nbins + 1)
607
+ # hist_counts = np.zeros(nbins, dtype=np.int64)
608
+
609
+ # with TiffFile(str(tiff_path)) as tif:
610
+ # for page in tif.pages:
611
+ # arr = page.asarray()
612
+ # c, _ = np.histogram(arr, bins=bin_edges)
613
+ # hist_counts += c
614
+
615
+ # # plot as bar (centers and widths)
616
+ # centers = (bin_edges[:-1] + bin_edges[1:]) / 2
617
+ # axs[1].bar(centers, hist_counts, width=np.diff(bin_edges), color='gray')
618
+ # axs[1].set_title('Histogram of Pixel Values (TIFF)')
619
+ # axs[1].set_xlabel('Pixel Value')
620
+ # axs[1].set_ylabel('Frequency')
621
+ # plt.tight_layout()
622
+
623
+ # # Save figure
624
+ # histo_fig_path = tiff_path.parent / "plots" / "pixel_value_histogram_tiff.png"
625
+ # fig.savefig(histo_fig_path, dpi=300)
626
+ # plt.close(fig)
627
+
628
+ return Path(tiff_path)
629
+
630
+ def run_mcorr(data_path, export_path, parameters, regex_pattern, recompute=True, scale_range=False):
631
+ """
632
+ Run motion correction on a set of ome.tif files.
633
+ Concatenate the ome.tif files into a single multi-page tiff file.
634
+ Create a new batch.
635
+ Add the motion correction item to the batch.
636
+ Run the batch.
637
+
638
+ Arguments:
639
+ - data_path: Path to the folder containing the input ome.tif files.
640
+ - export_path: Path to the folder where the output files will be saved.
641
+ - parameters: Dictionary containing the parameters for motion correction.
642
+ - regex_pattern: Regular expression pattern to match the input files.
643
+ - recompute: Boolean indicating whether to recompute the motion correction.
644
+ - scale_range: Boolean indicating whether to scale the output to uint16 range.
645
+
646
+ Returns:
647
+ - batch_path: Path to the created batch file.
648
+ - index: Index of the movie in the batch.
649
+ - movie_path: Path to the concatenated movie file.
650
+
651
+ """
652
+ # Set movie path
653
+ movie_path = Path(export_path).joinpath('cat_tiff_bt.tiff')
654
+
655
+ # Set the parent raw data path before any batch operations
656
+ mc.set_parent_raw_data_path(Path(export_path))
657
+
658
+ if not recompute and movie_path.exists():
659
+ log_and_print(f"Concatenated movie already exists at {export_path}. Using existing file.")
660
+ else:
661
+ # Concatenate the ome.tif files into a single multi-page tiff file
662
+ # Using the concat_tif.py script to concatenate the tif files. If needed, install libtiff with: pip install pylibtiff
663
+ log_and_print(f"Loading and concatenating data from {data_path}.")
664
+ try:
665
+ # time0 = time.time()
666
+ ##################
667
+ ct.concatenate_files(
668
+ input_paths=data_path,
669
+ output_path=export_path,
670
+ regex=regex_pattern,
671
+ scale_range=scale_range
672
+ )
673
+ ##################
674
+ # formatted_time = time.strftime("%H:%M:%S", time.gmtime(time.time() - time0))
675
+ # print(f"Concatenation completed in {formatted_time}.")
676
+
677
+ # Check the tiff file dtype and value range
678
+ with TiffFile(movie_path) as tif:
679
+ dtype = tif.pages[0].asarray().dtype
680
+ min_val = tif.pages[0].asarray().min()
681
+ max_val = tif.pages[0].asarray().max()
682
+ mean_val = tif.pages[0].asarray().mean()
683
+ print(f"Concatenated TIFF file \n\
684
+ dtype: {dtype}, \n\
685
+ min: {min_val}, \n\
686
+ max: {max_val}, \n\
687
+ mean: {mean_val}")
688
+
689
+ # Verify that the concatenated movie is long enough for subsequent
690
+ # correlation computations. If the movie is too short, CaImAn's
691
+ # ``local_correlations_movie_parallel`` will fail silently.
692
+ with TiffFile(movie_path) as tif:
693
+ n_frames = len(tif.pages)
694
+ if n_frames < 1000:
695
+ raise ValueError(
696
+ f"Concatenated movie has {n_frames} frames which is below the safe "
697
+ "threshold (1000). Increase the '--max-frames' parameter when "
698
+ "creating test datasets or rerun with more data."
699
+ )
700
+
701
+ except Exception as e:
702
+ log_and_print(f"An error occurred while concatenating files: {e}")
703
+
704
+ # log_and_print(f"Concatenated movie path: {movie_path}.\n")
705
+
706
+ if not recompute:
707
+ # Check if a batch_*_pickle file exists in the export path
708
+ batch_files = list(Path(export_path).glob(f"batch_*.pickle"))
709
+ if len(batch_files) > 0:
710
+ # take the most recent batch file
711
+ batch_path = batch_files[-1]
712
+ log_and_print(f"Using existing batch file {batch_path}.")
713
+ # load the batch
714
+ df = mc.load_batch(batch_path)
715
+
716
+ # rows_keep = [df.iloc[0].uuid]
717
+ # for i, row in df.iterrows():
718
+ # if row.uuid not in rows_keep:
719
+ # df.caiman.remove_item(row.uuid)
720
+
721
+ # if batch_path is not defined, create a new batch
722
+ if 'batch_path' not in locals():
723
+ # create a new batch path, appendind timestamp to avoid overwriting
724
+ batch_path = Path.joinpath(export_path, f'batch_{time.strftime("%Y%m%d-%H%M%S")}.pickle')
725
+ log_and_print(f"Creating batch {batch_path}.")
726
+ df = mc.create_batch(batch_path)
727
+
728
+ # Add the input movie path to the batch
729
+ df.caiman.add_item(
730
+ algo='mcorr',
731
+ item_name=movie_path.stem,
732
+ input_movie_path=movie_path,
733
+ params=parameters
734
+ )
735
+
736
+ time0 = time.time()
737
+ mcorr_index = None
738
+ # Run batch items that haven't been run yet
739
+ for row_index, row in df.iterrows():
740
+
741
+ # If algo is not mcorr, skip (this is a safety check, in case df was reloaded from disk)
742
+ if row.algo != 'mcorr':
743
+ continue
744
+
745
+ # If already processed, skip
746
+ if row["outputs"] is not None:
747
+ log_and_print(f"Skipping batch item {row_index}, id {row.uuid}, algo {row.algo}. Already run.", level='warning')
748
+ mcorr_index = row_index
749
+ continue
750
+
751
+ log_and_print(f"Running batch item {row_index}, id {row.uuid}, algo {row.algo}.")
752
+ try:
753
+ ##########################
754
+ process = row.caiman.run()
755
+ mcorr_index = row_index
756
+ ##########################
757
+ except Exception as e:
758
+ log_and_print(f"An error occurred while running caiman for batch item {row_index}: {e}")
759
+
760
+ # on Windows you MUST reload the batch dataframe after every iteration because it uses the `local` backend.
761
+ # this is unnecessary on Linux & Mac
762
+ # "DummyProcess" is used for local backend so this is automatic
763
+ if process.__class__.__name__ == "DummyProcess":
764
+ df = df.caiman.reload_from_disk()
765
+
766
+ log_and_print(f"Batch completed for motion correction. Results saved to {batch_path}.")
767
+ formatted_time = time.strftime("%H:%M:%S", time.gmtime(time.time() - time0))
768
+ print(f"mcorr completed in {formatted_time}.")
769
+
770
+ if mcorr_index is not None:
771
+ df = df.caiman.reload_from_disk()
772
+ # Get the path to the motion corrected movie
773
+ movie_path = Path(df.iloc[mcorr_index].mcorr.get_output_path())
774
+ # Get the motion corrected output as a memmaped numpy array
775
+ # mcorr_movie = df.iloc[mcorr_index].mcorr.get_output()
776
+
777
+ # Load motion corrected movie and check dtype, and mean pixel values
778
+ mcorr_movie = load_mmap_movie(movie_path)
779
+ if mcorr_movie is not None:
780
+ shape = mcorr_movie.shape
781
+ dtype = mcorr_movie.dtype
782
+ first_frame = mcorr_movie[0]
783
+ log_and_print(f"Motion corrected movie \n\
784
+ shape: {shape}, \n\
785
+ dtype: {dtype}, \n\
786
+ first frame min: {first_frame.min()}, \n\
787
+ first frame max: {first_frame.max()}, \n\
788
+ first frame mean: {first_frame.mean()}")
789
+ else:
790
+ log_and_print("Motion corrected movie could not be loaded.", level='error')
791
+
792
+
793
+ return batch_path, 0, movie_path
794
+
795
+ def run_motion_correction_workflow(
796
+ data_path,
797
+ export_path,
798
+ parameters,
799
+ regex_pattern='*_Ch2_*.ome.tif',
800
+ recompute=True,
801
+ create_movies=True,
802
+ output_format='memmap',
803
+ ):
804
+ """
805
+ Complete motion correction workflow including z-motion correction.
806
+
807
+ Args:
808
+ data_path: List of paths containing input data
809
+ export_path: Output directory path
810
+ parameters: Dictionary containing processing parameters
811
+ regex_pattern: Pattern to match input files
812
+ recompute: Whether to recompute existing results
813
+ create_movies: Whether to create output movies
814
+ output_format: 'memmap' (default), 'h5', 'tiff', or 'bin' for final motion-corrected movie storage
815
+
816
+ Returns:
817
+ dict: Results dictionary with paths and metadata
818
+ """
819
+
820
+ results = {
821
+ 'batch_path': None,
822
+ 'movie_path': None,
823
+ 'export_path': export_path,
824
+ 'success': False
825
+ }
826
+
827
+ try:
828
+ # Get motion correction parameters
829
+ parameters_mcorr = parameters['params_mcorr']
830
+
831
+ # Run the motion correction
832
+ scale_range_raw = parameters_mcorr.get('scale_range', False)
833
+ # Handle string representations of booleans
834
+ if isinstance(scale_range_raw, str):
835
+ scale_range = scale_range_raw.lower() in ('true', '1', 'yes')
836
+ else:
837
+ scale_range = bool(scale_range_raw)
838
+
839
+ batch_path, index, movie_path = run_mcorr(
840
+ data_path, export_path, parameters_mcorr, regex_pattern, recompute, scale_range=scale_range
841
+ )
842
+
843
+ results['batch_path'] = batch_path
844
+ results['movie_path'] = movie_path
845
+
846
+ # Determine whether to clip to uint16 range
847
+ clip_movie = output_format not in ('h5', 'bin', 'tiff')
848
+
849
+ if not recompute and movie_path.exists():
850
+ log_and_print(f"Motion corrected movie already exists at {movie_path}.")
851
+ else:
852
+ if clip_movie:
853
+ log_and_print("Optimizing motion corrected movie bit depth (clipping memmap to uint16).")
854
+ overwrite_movie_memmap(movie_path, movie_path, clip=True, movie_type='mcorr')
855
+ else:
856
+ log_and_print("Skipping memmap overwrite to preserve full dynamic range for downstream export.")
857
+
858
+ # Z-motion correction (optional)
859
+ if 'zstack_path' in parameters and 'z_motion_correction' in parameters.get('params_mcorr', {}):
860
+ log_and_print("Starting z-motion correction...")
861
+ time_z0 = time.time()
862
+
863
+ zcorr_movie_path, _, _ = cz.z_motion(
864
+ movie_path, parameters, scale_range=scale_range
865
+ )
866
+
867
+ # Save corrected movie, overwriting the original
868
+ if zcorr_movie_path is not None:
869
+ overwrite_movie_memmap(
870
+ zcorr_movie_path,
871
+ movie_path,
872
+ clip=clip_movie,
873
+ movie_type='zcorr',
874
+ save_original=False,
875
+ remove_input=True
876
+ )
877
+ results['z_corrected'] = True
878
+ else:
879
+ results['z_corrected'] = False
880
+
881
+ formatted_time = time.strftime("%H:%M:%S", time.gmtime(time.time() - time_z0))
882
+ if zcorr_movie_path is not None:
883
+ log_and_print(f"Z-motion correction completed in {formatted_time}.")
884
+ else:
885
+ log_and_print(f"Z-motion computation completed in {formatted_time}.")
886
+
887
+ # Create output movies (optional)
888
+ if create_movies:
889
+ log_and_print("Creating output movies...")
890
+
891
+ # Save as BigTIFF file
892
+ create_mcorr_movie(movie_path, export_path, None, None,
893
+ format='tiff', diff_corr=False)
894
+
895
+ # Create comparison movie (first 240 frames)
896
+ create_mcorr_movie(mcorr_movie_path=movie_path, export_path=export_path,
897
+ batch=batch_path, index=index, excerpt=240)
898
+
899
+ if output_format == 'memmap':
900
+ # This is already handled by run_mcorr() -> get_output_path()
901
+ # The memmap file exists in the batch folder.
902
+ # We don't need to duplicate it or link it, as the pipeline now preserves the batch folder.
903
+ pass
904
+
905
+ results['success'] = True
906
+
907
+ if output_format == 'h5':
908
+ print("Saving motion corrected movie as .h5 file...")
909
+ h5_path = export_path / 'mcorr_movie.h5'
910
+ # Save movie as Suite2p, AIND and ImageJ-compatible .h5
911
+ results['movie_path'] = save_movie_as_h5(
912
+ memmap_path=movie_path,
913
+ h5_path=h5_path,
914
+ parameters=parameters
915
+ )
916
+ elif output_format == 'bin':
917
+ log_and_print("Saving motion corrected movie as .bin file...")
918
+ bin_path = export_path / 'mcorr_movie.bin'
919
+ # Save movie as Suite2p-compatible .bin
920
+ results['movie_path'] = save_movie_as_bin(
921
+ memmap_path=movie_path,
922
+ bin_path=bin_path,
923
+ parameters=parameters
924
+ )
925
+ elif output_format == 'tiff':
926
+ log_and_print("Saving motion corrected movie as BigTIFF file...")
927
+ tiff_path = export_path / 'mcorr_movie.tiff'
928
+ results['movie_path'] = save_movie_as_tiff(
929
+ memmap_path=movie_path,
930
+ tiff_path=tiff_path,
931
+ parameters=parameters
932
+ )
933
+
934
+ log_and_print("Motion correction workflow completed successfully.")
935
+
936
+ except Exception as e:
937
+ log_and_print(f"Motion correction workflow failed: {e}", level='error')
938
+ results['error'] = str(e)
939
+ raise
940
+
941
+ return results
942
+
943
+ if __name__ == "__main__":
944
+ import argparse
945
+
946
+ parser = argparse.ArgumentParser(description="Motion Correction Module")
947
+ parser.add_argument('input_paths', nargs='+', help='Input data paths')
948
+ parser.add_argument('-o', '--output', required=True, help='Output directory')
949
+ parser.add_argument('-p', '--params', type=str, help='Path to parameters JSON file')
950
+ parser.add_argument('-t', '--pattern', default='*_Ch2_*.ome.tif', help='File pattern')
951
+ parser.add_argument('-r', '--recompute', action='store_true', help='Recompute existing results')
952
+ parser.add_argument('-c', '--create_movies', action='store_true', help='Create output movies')
953
+ parser.add_argument('-f', '--format', choices=['memmap', 'h5', 'bin', 'tiff'], default='memmap', help='Output format for final movie')
954
+ args = parser.parse_args()
955
+
956
+ # Convert input paths to Path objects
957
+ input_paths = [Path(p) for p in args.input_paths]
958
+ output_path = Path(args.output)
959
+ pattern = args.pattern
960
+ recompute = args.recompute
961
+ create_movies = args.create_movies
962
+ save_mcorr_movie = args.format
963
+
964
+ # Load parameters from JSON file if provided
965
+ parameters = {}
966
+ if args.params:
967
+ import json
968
+ with open(args.params, 'r') as f:
969
+ parameters = json.load(f)
970
+ else:
971
+ raise ValueError("Parameters file must be provided with -p option.")
972
+
973
+ # Run the motion correction workflow
974
+ results = run_motion_correction_workflow(
975
+ data_path=input_paths,
976
+ export_path=output_path,
977
+ parameters=parameters,
978
+ regex_pattern=pattern,
979
+ recompute=recompute,
980
+ create_movies=create_movies,
981
+ save_mcorr_movie=save_mcorr_movie
982
+ )