batch2p 0.1.0__tar.gz

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,3 @@
1
+ /scratches/
2
+ /.idea
3
+ .venv
@@ -0,0 +1 @@
1
+ 3.12
batch2p-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: batch2p
3
+ Version: 0.1.0
4
+ Author: Francesco P. Battaglia
5
+ License-Expression: GPL-3.0-or-later
6
+ Classifier: Operating System :: OS Independent
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pynapple
11
+ Requires-Dist: suite2p
12
+ Requires-Dist: tifffile
13
+ Requires-Dist: tifftrim
14
+ Requires-Dist: totalsync-2p
15
+ Requires-Dist: totalsync-utils
16
+ Provides-Extra: gui
17
+ Requires-Dist: pyqt5; extra == 'gui'
@@ -0,0 +1,72 @@
1
+ # batch2p
2
+
3
+ Batch 2-photon preprocessing pipeline. Runs configurable source extraction (suite2p or suite3d) from a single JSON configuration file.
4
+
5
+
6
+ This package streamlines source extraction from tiff files (at the moment ScanImage files are supported). [Suite2p](https://suite2p.readthedocs.io/en/latest/) and [Suite3d](https://github.com/alihaydaroglu/suite3d/)
7
+ are currently supported, with features like:
8
+ - file concatenation
9
+ - flexible parameters handling (also including parameter "sweeps")
10
+ - creation of batch files for SLURM
11
+ - if the experiment used [totalsync](https://github.com/NeuroNetMem/totalsync) for synchronization, imaging and other experimental data (eg. behavioral) are automatically synchronized.
12
+ ## Installation
13
+
14
+ ### From PyPI
15
+
16
+ ```bash
17
+ pip install batch2p
18
+ ```
19
+
20
+ ### From source (using pip)
21
+
22
+ ```bash
23
+ git clone https://github.com/NeuroNetMem/batch2p.git
24
+ cd batch2p
25
+ pip install . # (or uv pip install ".[all]" )
26
+ ```
27
+
28
+ this will install batch2p without GUI support, (which is suitable for headless servers). To install the GUI, replace the last line with
29
+ ```bash
30
+ pip install ".[gui]" # (or uv pip install ".[gui]" )
31
+ ```
32
+
33
+ ### From source (using uv)
34
+
35
+ ```bash
36
+ git clone https://github.com/NeuroNetMem/batch2p.git
37
+ cd batch2p
38
+ uv pip install .
39
+ ```
40
+
41
+ ### suite3d (optional)
42
+
43
+ If you need suite3d support, install it manually from source **after** installing batch2p. See instructions at https://github.com/alihaydaroglu/suite3d.
44
+
45
+ Briefly,
46
+
47
+ - Clone the suite3d repository (in a different directory):
48
+
49
+ ```bash
50
+ git clone https://github.com/alihaydaroglu/suite3d.git
51
+ ```
52
+
53
+ ```bash
54
+ cd suite3d
55
+ pip install ".[all]" # (or uv pip install ".[all]" )
56
+ ```
57
+
58
+ Then, install the suite3d cuda dependencies:
59
+
60
+ ```bash
61
+ pip install "cupy-cuda13x" # for CUDA 13.X, for other CUDA versions change accordingly
62
+ ```
63
+
64
+
65
+
66
+ ## Usage
67
+
68
+ Detailed usage documentation can be found in the [docs](docs) directory.
69
+
70
+ - [batch2p](docs/batch2p.md) — single-session pipeline
71
+ - [batch2p-multi](docs/batch2p_multi.md) — multi-session batch runner
72
+ - [batch2p-gui](docs/batch2p_gui.md) — graphical interface
@@ -0,0 +1 @@
1
+ """batch2p – batch 2-photon preprocessing package."""
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Batch 2-photon preprocessing – CLI entry point.
4
+
5
+ Usage:
6
+ batch2p <data_suite3d.json> [--working-dir <dir>]
7
+
8
+ The data JSON file must contain a "source_extraction" field specifying the
9
+ algorithm (e.g. "suite3d"). Synchronization is handled generically and depends
10
+ only on the original TIF and .b64 files.
11
+ """
12
+ import argparse
13
+ import json
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ # os.chdir(os.path.dirname(os.path.abspath("")))
21
+
22
+ from totalsync_2p.sync import synchronize
23
+ from batch2p.extractors import get_extractor
24
+
25
+
26
+ class TeeStream:
27
+ """Write to both a stream and a file simultaneously."""
28
+ def __init__(self, stream, file):
29
+ self.stream = stream
30
+ self.file = file
31
+
32
+ def write(self, data):
33
+ self.stream.write(data)
34
+ self.file.write(data)
35
+
36
+ def flush(self):
37
+ self.stream.flush()
38
+ self.file.flush()
39
+
40
+ def fileno(self):
41
+ return self.stream.fileno()
42
+
43
+
44
+ def collect_tifs(data: dict) -> list[Path]:
45
+ root = Path(data["root_path"]) if "root_path" in data else Path(".")
46
+ tifs = []
47
+ for item in data["data"]:
48
+ p = root / item
49
+ if p.suffix.lower() in (".tif", ".tiff"):
50
+ tifs.append(p)
51
+ elif p.is_dir():
52
+ tifs.extend(sorted(p.glob("*.tif")) + sorted(p.glob("*.tiff")))
53
+ else:
54
+ raise TypeError(f"Unexpected file type: {p}")
55
+ return tifs
56
+
57
+
58
+ def split_tifs(tifs: list[Path], chunk_size: int, tmp_dir: Path,
59
+ block_size: int = 3, add_offset: bool = False) -> list[Path]:
60
+ from tifftrim.trim import split_3d_tiff_into_chunks
61
+ split = []
62
+ for tif in tifs:
63
+ chunks = split_3d_tiff_into_chunks(
64
+ tif, tmp_dir, chunk_size,
65
+ block_size=block_size,
66
+ add_offset=add_offset,
67
+ )
68
+ split.extend(chunks)
69
+ return split
70
+
71
+
72
+ def _can_create_dir(p: Path) -> bool:
73
+ """Return True if p already exists as a directory or could be created."""
74
+ p = p.resolve()
75
+ candidate = p
76
+ while not candidate.exists():
77
+ parent = candidate.parent
78
+ if parent == candidate:
79
+ return False # reached filesystem root with nothing existing
80
+ candidate = parent
81
+ return candidate.is_dir() and os.access(candidate, os.W_OK)
82
+
83
+
84
+ def dry_run(args, data: dict) -> int:
85
+ """Check all inputs exist and all output dirs are creatable. Returns exit code."""
86
+ errors = []
87
+ ok_lines = []
88
+
89
+ def check_file(p: Path, label: str):
90
+ if p.exists() and p.is_file():
91
+ ok_lines.append(f" [OK] {label}: {p}")
92
+ else:
93
+ errors.append(f" [MISSING] {label}: {p}")
94
+
95
+ def check_dir(p: Path, label: str):
96
+ if p.exists() and p.is_dir():
97
+ ok_lines.append(f" [OK] {label}: {p}")
98
+ elif _can_create_dir(p):
99
+ ok_lines.append(f" [OK] {label}: {p} (will be created)")
100
+ else:
101
+ errors.append(f" [MISSING] {label}: {p} (cannot create)")
102
+
103
+ # ── Input files ───────────────────────────────────────────────────────────
104
+ print("Checking input files...")
105
+
106
+ params_file = Path(data["params_file"])
107
+ check_file(params_file, "params file")
108
+
109
+ root = Path(data["root_path"]) if "root_path" in data else Path(".")
110
+ for item in data["data"]:
111
+ p = root / item
112
+ if p.suffix.lower() in (".tif", ".tiff"):
113
+ check_file(p, "tif")
114
+ elif p.is_dir():
115
+ tifs = sorted(p.glob("*.tif")) + sorted(p.glob("*.tiff"))
116
+ if tifs:
117
+ for t in tifs:
118
+ ok_lines.append(f" [OK] tif: {t}")
119
+ else:
120
+ errors.append(f" [MISSING] tif dir (empty or missing): {p}")
121
+ else:
122
+ if p.exists():
123
+ errors.append(f" [ERROR] unexpected file type: {p}")
124
+ else:
125
+ errors.append(f" [MISSING] tif: {p}")
126
+
127
+ if "behavior_data" in data:
128
+ for item in data["behavior_data"]:
129
+ p = root / item
130
+ check_file(p, "b64")
131
+ pinsheet_file = Path(data["pinsheet_file"])
132
+ if not pinsheet_file.is_absolute():
133
+ pinsheet_file = root / pinsheet_file
134
+ check_file(pinsheet_file, "pinsheet file")
135
+
136
+ # ── Output directories ────────────────────────────────────────────────────
137
+ print("Checking output directories...")
138
+
139
+ job_id = data.get("job_id", "")
140
+ check_dir(Path(data["job_root_dir"]), "job root dir")
141
+ results_root = Path(data.get("results_root_dir", str(Path(data["job_root_dir"]) / "results")))
142
+ check_dir(results_root, "results root dir")
143
+ check_dir(results_root / job_id, "results dir")
144
+
145
+ if "temp_dir" in data:
146
+ check_dir(Path(data["temp_dir"]), "temp/scratch dir")
147
+
148
+ working_dir_base = args.working_dir or (Path(data["working_dir"]) if "working_dir" in data else None)
149
+ if working_dir_base is not None:
150
+ check_dir(Path(working_dir_base), "working dir")
151
+
152
+ # ── Report ────────────────────────────────────────────────────────────────
153
+ for line in ok_lines:
154
+ print(line)
155
+ if errors:
156
+ print("\nERRORS:")
157
+ for line in errors:
158
+ print(line, file=sys.stderr)
159
+ print(f"\nDry run FAILED: {len(errors)} issue(s) found.", file=sys.stderr)
160
+ return 1
161
+
162
+ print(f"\nDry run OK: all files present and output directories are accessible.")
163
+ return 0
164
+
165
+
166
+ def collect_b64s(data: dict) -> list[Path]:
167
+ root = Path(data["root_path"]) if "root_path" in data else Path(".")
168
+ b64s = []
169
+ for item in data["behavior_data"]:
170
+ p = root / item
171
+ if p.suffix.lower() != ".b64":
172
+ raise TypeError(f"Expected a .b64 file in behavior_data, got: {p}")
173
+ b64s.append(p)
174
+ return b64s
175
+
176
+
177
+ def copy_files_to_working_dir(files: list[Path], dest_dir: Path) -> list[Path]:
178
+ dest_dir.mkdir(parents=True, exist_ok=True)
179
+ copied = []
180
+ for f in files:
181
+ dest = dest_dir / f.name
182
+ print(f" {f} -> {dest}")
183
+ shutil.copy2(f, dest)
184
+ copied.append(dest)
185
+ return copied
186
+
187
+
188
+ def main():
189
+ parser = argparse.ArgumentParser(description="Batch 2-photon preprocessing")
190
+ parser.add_argument("data_json", type=Path, help="Path to data JSON file")
191
+ parser.add_argument("--working-dir", type=Path, default=None,
192
+ help="Local scratch directory. Input files are copied here; "
193
+ "results are copied back to their original destinations on completion.")
194
+ parser.add_argument("--debug", action="store_true",
195
+ help="Debug mode: skip cleanup of temp/working directories on error.")
196
+ parser.add_argument("--sync-only", action="store_true",
197
+ help="Skip source extraction; assume results already exist and only run synchronization.")
198
+ parser.add_argument("--dry-run", action="store_true",
199
+ help="Check that all input files exist and all output directories are "
200
+ "creatable, then exit without running anything.")
201
+ args = parser.parse_args()
202
+ sync_only = args.sync_only
203
+
204
+ with open(args.data_json) as f:
205
+ data = json.load(f)
206
+
207
+ if args.dry_run:
208
+ sys.exit(dry_run(args, data))
209
+
210
+ algorithm = data.get("source_extraction", "suite3d")
211
+ extractor = get_extractor(algorithm, data)
212
+
213
+ job_id = data["job_id"]
214
+ original_job_root_dir = Path(data["job_root_dir"])
215
+ original_results_root_dir = Path(data.get("results_root_dir",
216
+ str(original_job_root_dir / "results")))
217
+ original_results_path = original_results_root_dir / job_id
218
+
219
+ fill_tsync_gaps = bool(data.get("fill_tsync_gaps", False))
220
+ ignore_barcode = bool(data.get("ignore_barcode", False))
221
+ block_size = int(data.get("block_size", 3))
222
+ working_dir_base = args.working_dir or (Path(data["working_dir"]) if "working_dir" in data else None)
223
+
224
+ if working_dir_base is not None:
225
+ working_dir_base.mkdir(parents=True, exist_ok=True)
226
+ working_dir = Path(tempfile.mkdtemp(prefix=f"batch2p_{job_id}_", dir=working_dir_base))
227
+ else:
228
+ working_dir = None
229
+
230
+ tifs = collect_tifs(data)
231
+ original_tifs = tifs
232
+ print("Input files:")
233
+ for t in tifs:
234
+ print(f" {t}")
235
+
236
+ has_behavior = "behavior_data" in data
237
+ if has_behavior:
238
+ b64_files = collect_b64s(data)
239
+ if len(b64_files) != len(original_tifs):
240
+ raise ValueError(
241
+ f"behavior_data has {len(b64_files)} entries but data has "
242
+ f"{len(original_tifs)} tif files; counts must match."
243
+ )
244
+ pinsheet_file = Path(data["pinsheet_file"])
245
+ if not pinsheet_file.is_absolute():
246
+ root = Path(data["root_path"]) if "root_path" in data else Path(".")
247
+ pinsheet_file = root / pinsheet_file
248
+ else:
249
+ b64_files = []
250
+ pinsheet_file = None
251
+
252
+ if working_dir is not None:
253
+ job_root_dir = working_dir / original_job_root_dir.name
254
+ results_root_dir = working_dir / original_results_root_dir.name
255
+ else:
256
+ job_root_dir = original_job_root_dir
257
+ results_root_dir = original_results_root_dir
258
+
259
+ results_path = results_root_dir / job_id
260
+ results_path.mkdir(parents=True, exist_ok=True)
261
+
262
+ log_path = results_path / f"{job_id}.log"
263
+ log_file = open(log_path, "w", buffering=1) # line-buffered
264
+ original_stdout = sys.stdout
265
+ original_stderr = sys.stderr
266
+ sys.stdout = TeeStream(original_stdout, log_file)
267
+ sys.stderr = TeeStream(original_stderr, log_file)
268
+
269
+ tmp_dir = None
270
+ _exception_raised = False
271
+ try:
272
+ if sync_only:
273
+ if not original_results_path.exists():
274
+ raise FileNotFoundError(
275
+ f"--sync_only: no existing results found at {original_results_path}"
276
+ )
277
+ if working_dir is not None:
278
+ print(f"\nSession temp directory: {working_dir}")
279
+ print(f"Copying existing results to working directory: {results_path} ...")
280
+ for item in original_results_path.iterdir():
281
+ if item.suffix == ".log":
282
+ continue
283
+ dest = results_path / item.name
284
+ if item.is_dir():
285
+ shutil.copytree(str(item), str(dest))
286
+ else:
287
+ shutil.copy2(str(item), str(dest))
288
+ print("Done copying.")
289
+ else:
290
+ if working_dir is not None:
291
+ print(f"\nSession temp directory: {working_dir}")
292
+ print("Copying input files to working directory...")
293
+ tifs = copy_files_to_working_dir(tifs, working_dir / "input_tifs")
294
+ if has_behavior:
295
+ b64_files = copy_files_to_working_dir(b64_files, working_dir / "input_b64s")
296
+ print("Done copying.")
297
+
298
+ # Save pre-split tif paths for behavioral sync (b64 files are per-session, not per-chunk)
299
+ tifs_for_sync = list(tifs)
300
+
301
+ if not sync_only:
302
+ chunk_size = int(data.get("tiff_trim_size", 0))
303
+ if chunk_size:
304
+ if working_dir is not None:
305
+ tmp_dir = working_dir / "split_tifs"
306
+ tmp_dir.mkdir(parents=True, exist_ok=True)
307
+ else:
308
+ tmp_parent = Path(data["temp_dir"]) if "temp_dir" in data else None
309
+ if tmp_parent is not None:
310
+ tmp_parent.mkdir(parents=True, exist_ok=True)
311
+ tmp_dir = Path(tempfile.mkdtemp(prefix="batch2p_", dir=tmp_parent))
312
+ print(f"\nSplitting TIFFs into chunks of {chunk_size} frames -> {tmp_dir}")
313
+ add_offset = bool(data.get("add_offset", False))
314
+ tifs = split_tifs(tifs, chunk_size, tmp_dir, block_size=block_size, add_offset=add_offset)
315
+ print("Split files:")
316
+ for t in tifs:
317
+ print(f" {t}")
318
+
319
+ # When a working directory is used, let the extractor know so it can place
320
+ # any algorithm-level scratch files (e.g. suite2p fast_disk) there.
321
+ if working_dir is not None:
322
+ data["temp_dir"] = str(working_dir)
323
+
324
+ # Save reproducibility files
325
+ extractor.save_reproducibility_info(results_path)
326
+
327
+ saved_data = dict(data)
328
+ saved_data["data"] = [str(t) for t in original_tifs]
329
+ saved_data.pop("root_path", None)
330
+ saved_data["results_root_dir"] = str(original_results_root_dir)
331
+ saved_data["job_root_dir"] = str(original_job_root_dir)
332
+ with open(results_path / "data_used.json", "w") as f:
333
+ json.dump(saved_data, f, indent=2)
334
+
335
+ # Run source extraction
336
+ extractor.run(tifs, job_root_dir, job_id, results_path)
337
+
338
+ if has_behavior:
339
+ print("\nRunning behavioral synchronization...")
340
+ behavior_sync_dir = results_root_dir / "behavior_sync"
341
+ behavior_sync_dir.mkdir(parents=True, exist_ok=True)
342
+ sync_results = []
343
+ for tif, b64 in zip(tifs_for_sync, b64_files):
344
+ print(f" Synchronizing {tif.name} + {b64.name}")
345
+ stats = synchronize(str(tif), str(b64), str(behavior_sync_dir), str(pinsheet_file),
346
+ fill_gaps=fill_tsync_gaps, ignore_barcode=ignore_barcode)
347
+ sync_results.append(stats)
348
+ print("Behavioral synchronization done.")
349
+
350
+ print(f"\nCreating behavior-synced {algorithm} outputs...")
351
+ extractor.create_synced_outputs(
352
+ tif_files=tifs_for_sync,
353
+ sync_results=sync_results,
354
+ results_path=results_path,
355
+ behavior_sync_dir=behavior_sync_dir,
356
+ block_size=block_size,
357
+ )
358
+ print("Synced outputs done.")
359
+
360
+ except Exception:
361
+ _exception_raised = True
362
+ raise
363
+ finally:
364
+ sys.stdout = original_stdout
365
+ sys.stderr = original_stderr
366
+ log_file.flush()
367
+ log_file.close()
368
+
369
+ skip_cleanup = args.debug and _exception_raised
370
+
371
+ if tmp_dir is not None and tmp_dir.exists() and working_dir is None:
372
+ if skip_cleanup:
373
+ print(f"\n[debug] Keeping temporary directory on error: {tmp_dir}")
374
+ else:
375
+ shutil.rmtree(tmp_dir)
376
+ print(f"\nCleaned up temporary directory: {tmp_dir}")
377
+
378
+ if working_dir is not None and working_dir.exists():
379
+ if skip_cleanup:
380
+ print(f"\n[debug] Keeping session temp directory on error: {working_dir}")
381
+ else:
382
+ try:
383
+ print(f"\nCopying results back to {original_results_path} ...")
384
+ if original_results_path.exists():
385
+ shutil.rmtree(original_results_path)
386
+ original_results_root_dir.mkdir(parents=True, exist_ok=True)
387
+ shutil.copytree(str(results_path), str(original_results_path))
388
+
389
+ job_subdir = extractor.get_job_subdir(job_id)
390
+ working_job_path = job_root_dir / job_subdir
391
+ original_job_path = original_job_root_dir / job_subdir
392
+ if working_job_path.exists():
393
+ print(f"Copying job directory back to {original_job_path} ...")
394
+ if original_job_path.exists():
395
+ shutil.rmtree(original_job_path)
396
+ if original_job_root_dir.exists() and not original_job_root_dir.is_dir():
397
+ raise RuntimeError(
398
+ f"Cannot create job root directory: {original_job_root_dir} "
399
+ f"already exists as a non-directory file. "
400
+ f"Remove or rename it and retry."
401
+ )
402
+ original_job_root_dir.mkdir(parents=True, exist_ok=True)
403
+ shutil.copytree(str(working_job_path), str(original_job_path))
404
+
405
+ if has_behavior:
406
+ working_behavior_sync = results_root_dir / "behavior_sync"
407
+ original_behavior_sync = original_results_root_dir / "behavior_sync"
408
+ if working_behavior_sync.exists():
409
+ print(f"Copying behavior_sync back to {original_behavior_sync} ...")
410
+ if original_behavior_sync.exists():
411
+ shutil.rmtree(original_behavior_sync)
412
+ original_results_root_dir.mkdir(parents=True, exist_ok=True)
413
+ shutil.copytree(str(working_behavior_sync), str(original_behavior_sync))
414
+
415
+ print(f"Cleaning up session temp directory: {working_dir} ...")
416
+ shutil.rmtree(working_dir)
417
+ print("Done.")
418
+ except Exception as copy_err:
419
+ if _exception_raised:
420
+ print(f"\nWARNING: copy-back failed ({type(copy_err).__name__}: {copy_err}); "
421
+ f"working directory preserved at: {working_dir}", file=sys.stderr)
422
+ else:
423
+ raise
424
+
425
+
426
+ if __name__ == "__main__":
427
+ main()
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Compute baseline-subtracted neuropil-corrected fluorescence (F_sub.npy).
3
+
4
+ Looks for F.npy and Fneu.npy in the current directory and writes F_sub.npy
5
+ to the same directory.
6
+
7
+ F_sub = dcnv.preprocess(F - neucoeff * Fneu)
8
+
9
+ Parameters are read from a params.json file supplied via --params-file.
10
+ The JSON may use the same two-level structure as suite2p batch params:
11
+ - 'extraction:' section: neuropil_coefficient (fallback: 'neucoeff', default 0.7)
12
+ - 'dcnv_preprocess' section: baseline, win_baseline, sig_baseline,
13
+ prctile_baseline (fallbacks from flat keys)
14
+ - flat keys: fs, batch_size, torch_device
15
+
16
+ With --normalise, also computes dF/F from F_sub and writes dFF.npy:
17
+
18
+ dFF = (F_sub - F0) / F0
19
+
20
+ where F0 is a rolling percentile baseline (percentile_filter). Options
21
+ --dff-window-sec, --dff-percentile, and --dff-abs-floor control the
22
+ computation.
23
+ """
24
+ import argparse
25
+ import json
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+
30
+
31
+ def _detect_torch_device() -> str:
32
+ try:
33
+ import torch
34
+ if torch.cuda.is_available():
35
+ return "cuda"
36
+ except ImportError:
37
+ pass
38
+ try:
39
+ import cupy
40
+ if cupy.cuda.runtime.getDeviceCount() > 0:
41
+ return "cuda"
42
+ except Exception:
43
+ pass
44
+ return "cpu"
45
+
46
+
47
+ def compute_F_sub(work_dir: Path, settings: dict) -> np.ndarray:
48
+ import torch
49
+ from suite2p.extraction import dcnv
50
+
51
+ f_path = work_dir / "F.npy"
52
+ fneu_path = work_dir / "Fneu.npy"
53
+
54
+ if not f_path.exists():
55
+ raise FileNotFoundError(f"F.npy not found in {work_dir}")
56
+ if not fneu_path.exists():
57
+ raise FileNotFoundError(f"Fneu.npy not found in {work_dir}")
58
+
59
+ F = np.load(f_path)
60
+ Fneu = np.load(fneu_path)
61
+
62
+ extraction = settings.get('extraction:', {})
63
+ neucoeff = float(extraction.get('neuropil_coefficient', settings.get('neucoeff', 0.7)))
64
+ print(f"neuropil_coefficient: {neucoeff}")
65
+ Fc = F - neucoeff * Fneu
66
+
67
+ dcnv_section = settings.get('dcnv_preprocess', {})
68
+ def _p(key, default):
69
+ return dcnv_section.get(key, settings.get(key, default))
70
+
71
+ torch_device = settings.get('torch_device') or _detect_torch_device()
72
+ device = torch.device(torch_device)
73
+ print(f"torch_device: {torch_device}")
74
+
75
+ F_sub = dcnv.preprocess(
76
+ F=Fc,
77
+ baseline=_p('baseline', 'maximin'),
78
+ win_baseline=float(_p('win_baseline', 60.0)),
79
+ sig_baseline=float(_p('sig_baseline', 10.0)),
80
+ fs=float(settings.get('fs', 10.0)),
81
+ prctile_baseline=float(_p('prctile_baseline', 8.0)),
82
+ batch_size=int(settings.get('batch_size', 200)),
83
+ device=device,
84
+ )
85
+
86
+ out_path = work_dir / "F_sub.npy"
87
+ np.save(out_path, F_sub)
88
+ print(f"Saved {out_path} shape={F_sub.shape}")
89
+ return F_sub
90
+
91
+
92
+ def compute_dff(f_sub, fs=29.96, window_sec=300, percentile=8, abs_floor=10):
93
+ from scipy.ndimage import percentile_filter
94
+ window_frames = int(window_sec * fs)
95
+ n_cells, n_frames = f_sub.shape
96
+ dff = np.empty_like(f_sub)
97
+ f0_clamped = np.empty_like(f_sub)
98
+ for i in range(n_cells):
99
+ f0 = percentile_filter(f_sub[i], percentile, size=window_frames, mode='nearest')
100
+ f0c = np.maximum(f0, abs_floor)
101
+ dff[i] = (f_sub[i] - f0c) / f0c
102
+ f0_clamped[i] = f0c
103
+ return dff, f0_clamped
104
+
105
+
106
+ def main():
107
+ parser = argparse.ArgumentParser(description=__doc__,
108
+ formatter_class=argparse.RawDescriptionHelpFormatter)
109
+ parser.add_argument('--params-file', required=True, type=Path,
110
+ help='Path to params.json')
111
+ parser.add_argument('--normalise', '--normalize', action='store_true',
112
+ help='Also compute dF/F from F_sub and save as dFF.npy.')
113
+ parser.add_argument('--dff-window-sec', type=float, default=300,
114
+ help='Rolling baseline window in seconds for dF/F (default: 300).')
115
+ parser.add_argument('--dff-percentile', type=float, default=8,
116
+ help='Percentile used for the F0 baseline estimate (default: 8).')
117
+ parser.add_argument('--dff-abs-floor', type=float, default=10,
118
+ help='Absolute fluorescence floor clamped onto F0 (default: 10).')
119
+ args = parser.parse_args()
120
+
121
+ params_file = args.params_file
122
+ if not params_file.exists():
123
+ parser.error(f"params file not found: {params_file}")
124
+
125
+ with open(params_file) as f:
126
+ settings = json.load(f)
127
+ settings.pop('comments', None)
128
+
129
+ work_dir = Path.cwd()
130
+ print(f"Working directory: {work_dir}")
131
+ F_sub = compute_F_sub(work_dir, settings)
132
+
133
+ if args.normalise:
134
+ fs = float(settings.get('fs', 10.0))
135
+ print(f"Computing dF/F (window={args.dff_window_sec}s, "
136
+ f"percentile={args.dff_percentile}, abs_floor={args.dff_abs_floor}, fs={fs})")
137
+ dff, _ = compute_dff(F_sub, fs=fs,
138
+ window_sec=args.dff_window_sec,
139
+ percentile=args.dff_percentile,
140
+ abs_floor=args.dff_abs_floor)
141
+ dff_path = work_dir / "dFF.npy"
142
+ np.save(dff_path, dff)
143
+ print(f"Saved {dff_path} shape={dff.shape}")
144
+
145
+
146
+ if __name__ == '__main__':
147
+ main()