lbm_suite2p_python 3.0.4__tar.gz → 3.0.6__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.
Files changed (32) hide show
  1. {lbm_suite2p_python-3.0.4/lbm_suite2p_python.egg-info → lbm_suite2p_python-3.0.6}/PKG-INFO +1 -1
  2. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/cli.py +79 -2
  3. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/db_settings.py +1 -1
  4. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/run_lsp.py +525 -120
  5. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6/lbm_suite2p_python.egg-info}/PKG-INFO +1 -1
  6. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/pyproject.toml +1 -1
  7. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/LICENSE.md +0 -0
  8. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/MANIFEST.in +0 -0
  9. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/README.md +0 -0
  10. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/__init__.py +0 -0
  11. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/__main__.py +0 -0
  12. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/_benchmarking.py +0 -0
  13. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/cellpose.py +0 -0
  14. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/conversion.py +0 -0
  15. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/default_ops.py +0 -0
  16. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/grid_search.py +0 -0
  17. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/gui.py +0 -0
  18. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/merging.py +0 -0
  19. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/postprocessing.py +0 -0
  20. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/utils.py +0 -0
  21. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/volume.py +0 -0
  22. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python/zplane.py +0 -0
  23. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python.egg-info/SOURCES.txt +0 -0
  24. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python.egg-info/dependency_links.txt +0 -0
  25. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python.egg-info/entry_points.txt +0 -0
  26. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python.egg-info/requires.txt +0 -0
  27. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/lbm_suite2p_python.egg-info/top_level.txt +0 -0
  28. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/setup.cfg +0 -0
  29. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/tests/test_frame_count_aliases.py +0 -0
  30. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/tests/test_pipeline_parameters.py +0 -0
  31. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/tests/test_refactored_pipeline.py +0 -0
  32. {lbm_suite2p_python-3.0.4 → lbm_suite2p_python-3.0.6}/tests/test_run_volume.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lbm_suite2p_python
3
- Version: 3.0.4
3
+ Version: 3.0.6
4
4
  Summary: Calcium Imaging Pipeline built with Suite2p, Cellpose and Rastermap
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: homepage, https://github.com/MillerBrainObservatory/LBM-Suite2p-Python
@@ -174,6 +174,24 @@ Examples:
174
174
  "--accept-all-cells", action="store_true",
175
175
  help="mark all detected ROIs as accepted cells"
176
176
  )
177
+ pipeline.add_argument(
178
+ "--workers", type=int, default=4,
179
+ help="number of zplane worker processes (default: 4). "
180
+ "Use 1 for sequential, or 0 / negative for auto = "
181
+ "min(num_planes, cpu_count//2, 8). "
182
+ "Cellpose on GPU may OOM with multiple workers."
183
+ )
184
+ pipeline.add_argument(
185
+ "--skip-volumetric", action="store_true", dest="skip_volumetric",
186
+ help="skip merge_mrois, volume_stats, and volumetric plots after per-plane processing"
187
+ )
188
+ pipeline.add_argument(
189
+ "--threads-per-worker", type=int, dest="threads_per_worker", default=2,
190
+ help="cap BLAS / OMP / numba / torch threads per worker process "
191
+ "(default: 2). Total CPU load ~ workers * threads_per_worker. "
192
+ "Set to 0 or a negative value to use library defaults "
193
+ "(typically 1 thread per core, which oversubscribes when workers > 1)."
194
+ )
177
195
 
178
196
  # dff options
179
197
  dff = parser.add_argument_group("dff options")
@@ -324,6 +342,30 @@ def list_ops():
324
342
  print(f" --{_snake_to_kebab(key):<22} {default_str:<20} {help_text}")
325
343
 
326
344
 
345
+ class _Tee:
346
+ """Duplicate writes to multiple text streams (e.g., stdout + file)."""
347
+
348
+ def __init__(self, *streams):
349
+ self.streams = streams
350
+
351
+ def write(self, data):
352
+ for s in self.streams:
353
+ try:
354
+ s.write(data)
355
+ except Exception:
356
+ pass
357
+
358
+ def flush(self):
359
+ for s in self.streams:
360
+ try:
361
+ s.flush()
362
+ except Exception:
363
+ pass
364
+
365
+ def isatty(self):
366
+ return False
367
+
368
+
327
369
  def build_cell_filters(args) -> list | None:
328
370
  """build cell filters list from CLI args."""
329
371
  filters = []
@@ -457,8 +499,28 @@ def main():
457
499
 
458
500
  output_path.mkdir(parents=True, exist_ok=True)
459
501
 
502
+ log_path = output_path / "log.txt"
503
+ log_file = open(log_path, "w", encoding="utf-8", buffering=1)
504
+ _orig_stdout, _orig_stderr = sys.stdout, sys.stderr
505
+ sys.stdout = _Tee(_orig_stdout, log_file)
506
+ sys.stderr = _Tee(_orig_stderr, log_file)
507
+ print(f"Logging to: {log_path}")
508
+
509
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
510
+ try:
511
+ _mbo_version = _pkg_version("mbo_utilities")
512
+ except PackageNotFoundError:
513
+ _mbo_version = "unknown"
514
+ try:
515
+ _s2p_version = _pkg_version("suite2p")
516
+ except PackageNotFoundError:
517
+ _s2p_version = "not installed"
518
+
460
519
  print(f"\n{'='*60}")
461
520
  print(f"LBM Suite2p Pipeline v{__version__}")
521
+ print(f" mbo_utilities v{_mbo_version}")
522
+ print(f" lbm_suite2p_python v{__version__}")
523
+ print(f" suite2p v{_s2p_version}")
462
524
  print(f"{'='*60}")
463
525
  print(f"Input: {input_path}")
464
526
  print(f"Output: {output_path}")
@@ -491,6 +553,8 @@ def main():
491
553
  print(f"\n{'='*60}\n")
492
554
 
493
555
  # run pipeline
556
+ import time
557
+ _pipeline_start = time.time()
494
558
  try:
495
559
  lsp.pipeline(
496
560
  input_data=input_path,
@@ -501,8 +565,8 @@ def main():
501
565
  num_timepoints=args.num_timepoints,
502
566
  keep_reg=args.keep_reg,
503
567
  keep_raw=args.keep_raw,
504
- force_reg=args.force_reg,
505
- force_detect=args.force_detect,
568
+ force_reg=args.force_reg or args.overwrite,
569
+ force_detect=args.force_detect or args.overwrite,
506
570
  dff_window_size=args.dff_window_size,
507
571
  dff_percentile=args.dff_percentile,
508
572
  dff_smooth_window=args.dff_smooth_window,
@@ -511,11 +575,18 @@ def main():
511
575
  accept_all_cells=args.accept_all_cells,
512
576
  save_json=args.save_json,
513
577
  reader_kwargs=reader_kwargs if reader_kwargs else None,
578
+ workers=args.workers,
579
+ skip_volumetric=args.skip_volumetric,
580
+ threads_per_worker=args.threads_per_worker,
514
581
  )
515
582
 
583
+ _elapsed = time.time() - _pipeline_start
584
+ _h, _rem = divmod(int(_elapsed), 3600)
585
+ _m, _s = divmod(_rem, 60)
516
586
  print(f"\n{'='*60}")
517
587
  print(f"Processing complete!")
518
588
  print(f"Results saved to: {output_path}")
589
+ print(f"Total elapsed: {_h:02d}:{_m:02d}:{_s:02d} ({_elapsed:.1f}s)")
519
590
  print(f"{'='*60}\n")
520
591
 
521
592
  return 0
@@ -525,6 +596,12 @@ def main():
525
596
  import traceback
526
597
  traceback.print_exc()
527
598
  return 1
599
+ finally:
600
+ sys.stdout, sys.stderr = _orig_stdout, _orig_stderr
601
+ try:
602
+ log_file.close()
603
+ except Exception:
604
+ pass
528
605
 
529
606
 
530
607
  if __name__ == "__main__":
@@ -190,7 +190,7 @@ _ANATOMICAL_ONLY_TO_IMG: dict[int, str] = {
190
190
  _UPSTREAM_TO_FORK_RENAMES = {v: k for k, v in _FORK_TO_UPSTREAM_RENAMES.items()}
191
191
 
192
192
  # per-section flat-key disambiguation. Several upstream sections share
193
- # an upstream key. When flattened to ops, the second iteration clobbers
193
+ # an upstream key. When flattened to ops, the second iteration overwrites
194
194
  # the first; on the reverse derivation, the surviving value gets fanned
195
195
  # back into BOTH sections — silently corrupting one of them.
196
196
  # This map gives the colliding (section, upstream_key) pair its own
@@ -1,5 +1,11 @@
1
1
  import logging
2
+ import logging.handlers
3
+ import multiprocessing as mp
4
+ import os
5
+ import pickle
6
+ import sys
2
7
  import time
8
+ from concurrent.futures import ProcessPoolExecutor, as_completed
3
9
  from datetime import datetime
4
10
  from pathlib import Path
5
11
  import traceback
@@ -228,7 +234,7 @@ def _resolve_source_plane_dir(input_arr, input_path, target_plane, source_plane_
228
234
  def _copy_if_needed(src: Path, dst: Path, *, overwrite_empty: bool = True) -> bool:
229
235
  """Copy src -> dst when dst is missing (or empty, if overwrite_empty).
230
236
 
231
- Returns True when a copy actually happened. Never clobbers a
237
+ Returns True when a copy actually happened. Never overwrites a
232
238
  non-empty destination.
233
239
  """
234
240
  import shutil
@@ -237,7 +243,6 @@ def _copy_if_needed(src: Path, dst: Path, *, overwrite_empty: bool = True) -> bo
237
243
  if dst.exists():
238
244
  if not (overwrite_empty and dst.stat().st_size == 0):
239
245
  return False
240
- print(f" Copying {src.name} from {src.parent} -> {dst.parent}")
241
246
  shutil.copy2(src, dst)
242
247
  return True
243
248
 
@@ -263,6 +268,7 @@ def _stage_source_into_plane_dir(
263
268
  plane_dir = Path(plane_dir)
264
269
  plane_dir.mkdir(parents=True, exist_ok=True)
265
270
 
271
+ print(f" Staging plane outputs: {src_dir.name} -> {plane_dir.name}")
266
272
  _copy_if_needed(src_dir / "ops.npy", plane_dir / "ops.npy")
267
273
  for fname in _DETECTION_OUTPUT_FILES:
268
274
  _copy_if_needed(src_dir / fname, plane_dir / fname)
@@ -439,13 +445,37 @@ from lbm_suite2p_python.utils import _is_lazy_array, _get_num_planes
439
445
  def _get_suite2p_version():
440
446
  """Get suite2p version string."""
441
447
  try:
442
- import suite2p
443
-
444
- return getattr(suite2p, "__version__", "unknown")
445
- except ImportError:
448
+ return version("suite2p")
449
+ except PackageNotFoundError:
446
450
  return "not installed"
447
451
 
448
452
 
453
+ def _apply_thread_limits(threads_per_worker: int | None) -> None:
454
+ """Cap BLAS / OMP / numba / torch thread counts per process.
455
+
456
+ Sets env vars so any child process spawned later inherits the cap,
457
+ and calls torch.set_num_threads for the current process (where the
458
+ BLAS env vars may have been read at import time and cannot be
459
+ changed without threadpoolctl). No-op when value is None or <= 0.
460
+ """
461
+ if not threads_per_worker or threads_per_worker <= 0:
462
+ return
463
+ n = str(int(threads_per_worker))
464
+ for var in (
465
+ "OMP_NUM_THREADS",
466
+ "MKL_NUM_THREADS",
467
+ "OPENBLAS_NUM_THREADS",
468
+ "NUMEXPR_NUM_THREADS",
469
+ "NUMBA_NUM_THREADS",
470
+ ):
471
+ os.environ[var] = n
472
+ try:
473
+ import torch
474
+ torch.set_num_threads(int(threads_per_worker))
475
+ except Exception:
476
+ pass
477
+
478
+
449
479
  def _add_processing_step(
450
480
  ops, step_name, input_files=None, duration_seconds=None, extra=None
451
481
  ):
@@ -523,6 +553,160 @@ def _extract_volumetric_rastermap_kwargs(rastermap_kwargs):
523
553
  return _extract_rastermap_section(rastermap_kwargs, "volumetric")
524
554
 
525
555
 
556
+ def _resolve_input_source(input_arr, input_data):
557
+ """Return a picklable source spec for parallel workers to imread().
558
+
559
+ Workers re-open the data themselves rather than receiving a pickled
560
+ lazy array (which may not survive spawn). Preference order:
561
+ 1. Original input_data if it's a str/Path/list of paths.
562
+ 2. input_arr.filenames for mbo lazy arrays backed by files.
563
+ Raises ValueError when neither is available.
564
+ """
565
+ if isinstance(input_data, (str, Path)):
566
+ return str(input_data)
567
+ if isinstance(input_data, (list, tuple)) and all(
568
+ isinstance(p, (str, Path)) for p in input_data
569
+ ):
570
+ return [str(p) for p in input_data]
571
+ if input_arr is not None and hasattr(input_arr, "filenames"):
572
+ fns = list(input_arr.filenames) if input_arr.filenames else []
573
+ if fns:
574
+ return [str(p) for p in fns]
575
+ raise ValueError(
576
+ "parallel mode (workers != 1) requires a path-based input. "
577
+ "Pass a file path, list of paths, or a lazy array loaded from disk, "
578
+ "or set workers=1 to process in-memory arrays sequentially."
579
+ )
580
+
581
+
582
+ def _resolve_worker_count(workers, num_planes):
583
+ """Resolve `workers` kwarg into a concrete worker count.
584
+
585
+ workers=1: sequential (caller short-circuits before calling this).
586
+ workers in (None, <=0): auto = min(num_planes, cpu_count//2, 8).
587
+ workers>1: clamp to num_planes.
588
+ """
589
+ if workers is None or (isinstance(workers, int) and workers <= 0):
590
+ cpu = os.cpu_count() or 1
591
+ return max(1, min(num_planes, cpu // 2 or 1, 8))
592
+ return max(1, min(int(workers), num_planes))
593
+
594
+
595
+ class _QueueStreamRedirect:
596
+ """File-like that forwards writes to a logger so print()/tqdm output
597
+ from a worker process flows through the multiprocessing log queue."""
598
+
599
+ def __init__(self, logger_, level=logging.INFO):
600
+ self._logger = logger_
601
+ self._level = level
602
+ self._buf = ""
603
+
604
+ def write(self, msg):
605
+ if not msg:
606
+ return
607
+ self._buf += msg
608
+ while "\n" in self._buf:
609
+ line, _, self._buf = self._buf.partition("\n")
610
+ if line:
611
+ self._logger.log(self._level, line)
612
+
613
+ def flush(self):
614
+ if self._buf:
615
+ self._logger.log(self._level, self._buf)
616
+ self._buf = ""
617
+
618
+ def isatty(self):
619
+ return False
620
+
621
+
622
+ def _plane_worker(input_source, current_ops, save_path, run_plane_kwargs,
623
+ reader_kwargs, log_queue):
624
+ """Process one zplane in a child process. Returns (plane_num, ops_path).
625
+
626
+ Module-level so it pickles for ProcessPoolExecutor. The fully-prepared
627
+ `current_ops` is built in the main process; this worker only re-opens
628
+ the input via imread() and calls run_plane().
629
+ """
630
+ plane_num = current_ops.get("plane")
631
+
632
+ root = logging.getLogger()
633
+ root.handlers.clear()
634
+ root.addHandler(logging.handlers.QueueHandler(log_queue))
635
+ root.setLevel(logging.INFO)
636
+ # suite2p / cellpose loggers set propagate=False, so route them directly.
637
+ for name in ("suite2p", "cellpose"):
638
+ lg = logging.getLogger(name)
639
+ lg.handlers.clear()
640
+ lg.addHandler(logging.handlers.QueueHandler(log_queue))
641
+ lg.setLevel(logging.INFO)
642
+ lg.propagate = False
643
+ global _external_logging_attached
644
+ _external_logging_attached = True
645
+
646
+ plane_logger = logging.getLogger(f"plane{plane_num:02d}")
647
+ plane_logger.setLevel(logging.INFO)
648
+ _orig_stdout, _orig_stderr = sys.stdout, sys.stderr
649
+ sys.stdout = _QueueStreamRedirect(plane_logger, logging.INFO)
650
+ sys.stderr = _QueueStreamRedirect(plane_logger, logging.INFO)
651
+
652
+ try:
653
+ from mbo_utilities import imread
654
+ arr = imread(input_source, **(reader_kwargs or {}))
655
+ ops_path = run_plane(
656
+ input_data=arr,
657
+ save_path=save_path,
658
+ ops=current_ops,
659
+ **run_plane_kwargs,
660
+ )
661
+ return plane_num, ops_path
662
+ finally:
663
+ try:
664
+ sys.stdout.flush()
665
+ sys.stderr.flush()
666
+ except Exception:
667
+ pass
668
+ sys.stdout, sys.stderr = _orig_stdout, _orig_stderr
669
+
670
+
671
+ def _prepare_plane_ops(*, base_ops, plane_idx, num_planes, input_arr,
672
+ volume_voxel, volume_source_meta, volume_source_shape,
673
+ z_selection, frame_indices):
674
+ """Build a per-plane ops dict from base ops.
675
+
676
+ Centralizes the per-plane prep so both sequential and parallel paths
677
+ apply identical voxel-size propagation and reactive metadata. Mirrors
678
+ the inline prep that previously lived in run_volume's loop.
679
+ """
680
+ plane_num = plane_idx + 1
681
+ current_ops = copy.deepcopy(load_ops(base_ops)) if base_ops else default_ops()
682
+ current_ops["plane"] = plane_num
683
+ current_ops["num_zplanes"] = num_planes
684
+
685
+ if input_arr is not None and hasattr(input_arr, "metadata"):
686
+ _source_idx = (input_arr.metadata or {}).get("selected_planes_0based")
687
+ if _source_idx is not None and plane_idx < len(_source_idx):
688
+ current_ops["source_plane_num"] = int(_source_idx[plane_idx]) + 1
689
+
690
+ if volume_voxel is not None:
691
+ if volume_voxel.dz is not None:
692
+ current_ops.setdefault("dz", volume_voxel.dz)
693
+ current_ops.setdefault("z_step", volume_voxel.dz)
694
+ if volume_voxel.dx != 1.0:
695
+ current_ops.setdefault("dx", volume_voxel.dx)
696
+ if volume_voxel.dy != 1.0:
697
+ current_ops.setdefault("dy", volume_voxel.dy)
698
+
699
+ _apply_reactive_metadata(
700
+ ops=current_ops,
701
+ source_metadata=volume_source_meta,
702
+ source_shape=volume_source_shape,
703
+ frame_indices=frame_indices,
704
+ plane_indices=z_selection,
705
+ logger=logger,
706
+ )
707
+ return current_ops
708
+
709
+
526
710
  def pipeline(
527
711
  input_data,
528
712
  save_path: str | Path = None,
@@ -547,6 +731,9 @@ def pipeline(
547
731
  save_json: bool = False,
548
732
  reader_kwargs: dict = None,
549
733
  writer_kwargs: dict = None,
734
+ workers: int | None = 1,
735
+ skip_volumetric: bool = False,
736
+ threads_per_worker: int | None = None,
550
737
  # deprecated parameters
551
738
  roi: int = None,
552
739
  num_frames: int = None,
@@ -630,6 +817,19 @@ def pipeline(
630
817
  Arguments passed to mbo_utilities.imread().
631
818
  writer_kwargs : dict, optional
632
819
  Arguments passed to binary writer (e.g., output_format).
820
+ workers : int or None, default 1
821
+ Number of zplane worker processes for volumetric input. ``1``
822
+ keeps the sequential code path. ``None`` (or any value <= 0)
823
+ auto-picks ``min(num_planes, cpu_count // 2, 8)``. Parallel mode
824
+ requires a path-based input; per-plane outputs go to disjoint
825
+ subdirectories. Cellpose on GPU may OOM with multiple workers —
826
+ reduce ``workers`` or switch cellpose to CPU when this happens.
827
+ Ignored for planar (single-plane) inputs.
828
+ skip_volumetric : bool, default False
829
+ When True, return per-plane ops_files immediately after the
830
+ per-plane loop, skipping merge_mrois, volume_stats, and
831
+ volumetric plots. Useful when farming planes across machines
832
+ and aggregating later.
633
833
  plane_name : str, optional
634
834
  Name for output directory when input is an array without
635
835
  filenames. Passed via kwargs.
@@ -667,6 +867,8 @@ def pipeline(
667
867
 
668
868
  _attach_external_loggers()
669
869
 
870
+ _apply_thread_limits(threads_per_worker)
871
+
670
872
  # 1. Handle Deprecations
671
873
  if roi is not None:
672
874
  import warnings
@@ -747,9 +949,17 @@ def pipeline(
747
949
  save_json=save_json,
748
950
  reader_kwargs=reader_kwargs,
749
951
  writer_kwargs=writer_kwargs,
952
+ workers=workers,
953
+ skip_volumetric=skip_volumetric,
954
+ threads_per_worker=threads_per_worker,
750
955
  **kwargs,
751
956
  )
752
957
  else:
958
+ if workers != 1:
959
+ logger.warning(
960
+ "workers=%r ignored: input is planar (single zplane), no parallelism applicable",
961
+ workers,
962
+ )
753
963
  # run_plane is planar-only — extract just the planar sub-dict
754
964
  planar_kwargs = _extract_planar_rastermap_kwargs(rastermap_kwargs)
755
965
  # run_plane returns a single Path, we wrap in list
@@ -947,6 +1157,9 @@ def run_volume(
947
1157
  save_json: bool = False,
948
1158
  reader_kwargs: dict = None,
949
1159
  writer_kwargs: dict = None,
1160
+ workers: int | None = 1,
1161
+ skip_volumetric: bool = False,
1162
+ threads_per_worker: int | None = None,
950
1163
  **kwargs,
951
1164
  ):
952
1165
  """
@@ -990,6 +1203,19 @@ def run_volume(
990
1203
  See pipeline() for the full schema.
991
1204
  save_json : bool, default False
992
1205
  Save ops as JSON.
1206
+ workers : int or None, default 1
1207
+ Number of zplane worker processes. ``1`` (default) keeps the
1208
+ sequential path. ``None`` or any value <= 0 auto-picks
1209
+ ``min(num_planes, cpu_count // 2, 8)``. Values > 1 run that many
1210
+ workers (clamped to num_planes). Parallel mode requires a
1211
+ path-based input. Workers reload input via ``imread`` and write
1212
+ to disjoint per-plane subdirectories.
1213
+ Caveat: cellpose on GPU may OOM with multiple workers; reduce
1214
+ ``workers`` or switch cellpose to CPU when this happens.
1215
+ skip_volumetric : bool, default False
1216
+ When True, return per-plane ops_files immediately after the
1217
+ per-plane loop, skipping merge_mrois, volume_stats, and all
1218
+ volumetric plots. Useful when farming planes across machines.
993
1219
  **kwargs
994
1220
  Additional args passed to run_plane.
995
1221
 
@@ -1002,6 +1228,8 @@ def run_volume(
1002
1228
  from mbo_utilities import imread
1003
1229
  from lbm_suite2p_python.merging import merge_mrois
1004
1230
 
1231
+ _apply_thread_limits(threads_per_worker)
1232
+
1005
1233
  # Handle input data
1006
1234
  input_arr = None
1007
1235
  input_paths = []
@@ -1072,6 +1300,7 @@ def run_volume(
1072
1300
  else None
1073
1301
  )
1074
1302
 
1303
+ _volume_start = time.time()
1075
1304
  print(
1076
1305
  f"Processing {len(planes_indices)} planes in volume (Total planes: {num_planes})"
1077
1306
  )
@@ -1087,117 +1316,206 @@ def run_volume(
1087
1316
 
1088
1317
  ops_files = []
1089
1318
 
1090
- # Iterate
1091
- for i, plane_idx in enumerate(planes_indices):
1092
- plane_num = plane_idx + 1
1319
+ # shared run_plane kwargs (identical for sequential and parallel paths)
1320
+ _run_plane_kwargs = dict(
1321
+ keep_reg=keep_reg,
1322
+ keep_raw=keep_raw,
1323
+ force_reg=force_reg,
1324
+ force_detect=force_detect,
1325
+ frame_indices=frame_indices,
1326
+ dff_window_size=dff_window_size,
1327
+ dff_percentile=dff_percentile,
1328
+ dff_smooth_window=dff_smooth_window,
1329
+ correct_neuropil=correct_neuropil,
1330
+ accept_all_cells=accept_all_cells,
1331
+ cell_filters=cell_filters,
1332
+ rastermap_kwargs=planar_rastermap_kwargs,
1333
+ save_json=save_json,
1334
+ reader_kwargs=reader_kwargs,
1335
+ writer_kwargs=writer_kwargs,
1336
+ **kwargs,
1337
+ )
1093
1338
 
1094
- if progress_callback:
1095
- progress_callback(
1096
- plane=i,
1097
- total_planes=len(planes_indices),
1098
- step="plane_start",
1099
- message=f"Plane {plane_num}",
1100
- )
1339
+ run_parallel = workers != 1
1340
+ if run_parallel:
1341
+ n_workers = _resolve_worker_count(workers, len(planes_indices))
1101
1342
 
1102
- # Prepare input for run_plane
1103
- if input_arr is not None:
1104
- # Pass the whole array, run_plane handles extraction via ops['plane']
1105
- current_input = input_arr
1106
- else:
1107
- # List of files - map plane_num to file index (assuming 1-to-1 if no explicit mapping)
1108
- # If input_paths corresponds to ALL planes, then plane_idx indexes into it
1109
- if plane_idx < len(input_paths):
1110
- current_input = input_paths[plane_idx]
1343
+ if not run_parallel:
1344
+ # Iterate sequentially (original behavior)
1345
+ for i, plane_idx in enumerate(planes_indices):
1346
+ plane_num = plane_idx + 1
1347
+
1348
+ if progress_callback:
1349
+ progress_callback(
1350
+ plane=i,
1351
+ total_planes=len(planes_indices),
1352
+ step="plane_start",
1353
+ message=f"Plane {plane_num}",
1354
+ )
1355
+
1356
+ # Prepare input for run_plane
1357
+ if input_arr is not None:
1358
+ # Pass the whole array, run_plane handles extraction via ops['plane']
1359
+ current_input = input_arr
1111
1360
  else:
1112
- # Fallback or error? Assuming input_files length matches num_planes
1113
- current_input = input_paths[0] # Should not happen if logic is correct
1114
-
1115
- # Prepare ops with plane number — deepcopy so suite2p's in-place
1116
- # mutations (badframes, meanImg, Ly/Lx, etc.) don't leak across planes
1117
- current_ops = copy.deepcopy(load_ops(ops)) if ops else default_ops()
1118
- current_ops["plane"] = plane_num
1119
- current_ops["num_zplanes"] = num_planes # useful info
1120
-
1121
- # if the source was saved as a plane-subset (e.g. every-other-plane),
1122
- # mbo stamps `selected_planes_0based` into the file metadata. use
1123
- # that to translate the local plane index back to source-plane
1124
- # identity so output dirs read `zplane07_...` rather than
1125
- # `zplane02_...`. `ops["plane"]` stays as the local index because
1126
- # downstream slicing (`planes=[ops["plane"]]` in run_plane) must
1127
- # index into the *loaded* array, which only has the saved subset.
1128
- if input_arr is not None and hasattr(input_arr, "metadata"):
1129
- _source_idx = (input_arr.metadata or {}).get("selected_planes_0based")
1130
- if _source_idx is not None and plane_idx < len(_source_idx):
1131
- current_ops["source_plane_num"] = int(_source_idx[plane_idx]) + 1
1132
-
1133
- # Propagate voxel size from volume metadata into per-plane ops
1134
- if _volume_voxel is not None:
1135
- if _volume_voxel.dz is not None:
1136
- current_ops.setdefault("dz", _volume_voxel.dz)
1137
- current_ops.setdefault("z_step", _volume_voxel.dz)
1138
- if _volume_voxel.dx != 1.0:
1139
- current_ops.setdefault("dx", _volume_voxel.dx)
1140
- if _volume_voxel.dy != 1.0:
1141
- current_ops.setdefault("dy", _volume_voxel.dy)
1142
-
1143
- # Reactively scale dz (and fs if frame_indices are provided)
1144
- # using the FULL plane selection. This MUST happen before
1145
- # run_plane fires, because run_plane sees only its own plane
1146
- # number and can't compute the implicit z-stride on its own.
1147
- # The helper overwrites dz/dx/dy/fs in current_ops so the
1148
- # `setdefault` calls above are effectively replaced when the
1149
- # reactive value differs.
1150
- _apply_reactive_metadata(
1151
- ops=current_ops,
1152
- source_metadata=_volume_source_meta,
1153
- source_shape=_volume_source_shape,
1154
- frame_indices=frame_indices,
1155
- plane_indices=_volume_z_selection,
1156
- logger=logger,
1157
- )
1361
+ # List of files - map plane_num to file index (assuming 1-to-1 if no explicit mapping)
1362
+ # If input_paths corresponds to ALL planes, then plane_idx indexes into it
1363
+ if plane_idx < len(input_paths):
1364
+ current_input = input_paths[plane_idx]
1365
+ else:
1366
+ # Fallback or error? Assuming input_files length matches num_planes
1367
+ current_input = input_paths[0] # Should not happen if logic is correct
1368
+
1369
+ current_ops = _prepare_plane_ops(
1370
+ base_ops=ops,
1371
+ plane_idx=plane_idx,
1372
+ num_planes=num_planes,
1373
+ input_arr=input_arr,
1374
+ volume_voxel=_volume_voxel,
1375
+ volume_source_meta=_volume_source_meta,
1376
+ volume_source_shape=_volume_source_shape,
1377
+ z_selection=_volume_z_selection,
1378
+ frame_indices=frame_indices,
1379
+ )
1158
1380
 
1159
- # Call run_plane
1381
+ # Call run_plane
1382
+ try:
1383
+ print(f"\n--- Volume Step: Plane {plane_num} ---")
1384
+ _plane_start = time.time()
1385
+ ops_file = run_plane(
1386
+ input_data=current_input,
1387
+ save_path=save_path,
1388
+ ops=current_ops,
1389
+ **_run_plane_kwargs,
1390
+ )
1391
+ ops_files.append(ops_file)
1392
+ print(
1393
+ f"--- Plane {plane_num} elapsed: {time.time() - _plane_start:.1f}s ---"
1394
+ )
1395
+
1396
+ if progress_callback:
1397
+ progress_callback(
1398
+ plane=i,
1399
+ total_planes=len(planes_indices),
1400
+ step="plane_done",
1401
+ message=f"Plane {plane_num} complete",
1402
+ )
1403
+ except Exception as e:
1404
+ print(f"ERROR processing plane {plane_num}: {e}")
1405
+ traceback.print_exc()
1406
+ else:
1407
+ # Parallel: resolve a picklable input source, pre-build all per-plane
1408
+ # ops in the main process, then run planes in a ProcessPoolExecutor.
1160
1409
  try:
1161
- print(f"\n--- Volume Step: Plane {plane_num} ---")
1162
- ops_file = run_plane(
1163
- input_data=current_input,
1164
- save_path=save_path,
1165
- ops=current_ops,
1166
- keep_reg=keep_reg,
1167
- keep_raw=keep_raw,
1168
- force_reg=force_reg,
1169
- force_detect=force_detect,
1410
+ input_source = _resolve_input_source(input_arr, input_data)
1411
+ except ValueError:
1412
+ raise
1413
+
1414
+ prepared = [] # list of (plane_num, current_ops)
1415
+ for plane_idx in planes_indices:
1416
+ current_ops = _prepare_plane_ops(
1417
+ base_ops=ops,
1418
+ plane_idx=plane_idx,
1419
+ num_planes=num_planes,
1420
+ input_arr=input_arr,
1421
+ volume_voxel=_volume_voxel,
1422
+ volume_source_meta=_volume_source_meta,
1423
+ volume_source_shape=_volume_source_shape,
1424
+ z_selection=_volume_z_selection,
1170
1425
  frame_indices=frame_indices,
1171
- dff_window_size=dff_window_size,
1172
- dff_percentile=dff_percentile,
1173
- dff_smooth_window=dff_smooth_window,
1174
- correct_neuropil=correct_neuropil,
1175
- accept_all_cells=accept_all_cells,
1176
- cell_filters=cell_filters,
1177
- rastermap_kwargs=planar_rastermap_kwargs,
1178
- save_json=save_json,
1179
- reader_kwargs=reader_kwargs,
1180
- writer_kwargs=writer_kwargs,
1181
- **kwargs,
1182
1426
  )
1183
- ops_files.append(ops_file)
1427
+ prepared.append((plane_idx + 1, current_ops))
1184
1428
 
1185
- if progress_callback:
1429
+ # Eager picklability check on the first plane's ops so users see
1430
+ # a clear error at submission rather than a swallowed future.
1431
+ try:
1432
+ pickle.dumps(prepared[0][1])
1433
+ pickle.dumps(_run_plane_kwargs)
1434
+ except Exception as exc:
1435
+ raise RuntimeError(
1436
+ f"parallel mode cannot pickle the prepared per-plane ops or "
1437
+ f"run_plane kwargs ({exc!r}). Use workers=1 or remove the "
1438
+ f"unpicklable item."
1439
+ ) from exc
1440
+
1441
+ print(
1442
+ f"Running {len(planes_indices)} planes across {n_workers} worker process(es)..."
1443
+ )
1444
+
1445
+ manager = mp.Manager()
1446
+ log_queue = manager.Queue()
1447
+ stdout_handler = logging.StreamHandler()
1448
+ stdout_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
1449
+ listener = logging.handlers.QueueListener(
1450
+ log_queue, stdout_handler, respect_handler_level=False
1451
+ )
1452
+ listener.start()
1453
+
1454
+ if progress_callback:
1455
+ for i, (plane_num, _) in enumerate(prepared):
1186
1456
  progress_callback(
1187
1457
  plane=i,
1188
1458
  total_planes=len(planes_indices),
1189
- step="plane_done",
1190
- message=f"Plane {plane_num} complete",
1459
+ step="plane_start",
1460
+ message=f"Plane {plane_num}",
1191
1461
  )
1192
- except Exception as e:
1193
- print(f"ERROR processing plane {plane_num}: {e}")
1194
- traceback.print_exc()
1462
+
1463
+ try:
1464
+ with ProcessPoolExecutor(max_workers=n_workers) as pool:
1465
+ future_to_plane = {}
1466
+ plane_start_times = {}
1467
+ for plane_num, current_ops in prepared:
1468
+ fut = pool.submit(
1469
+ _plane_worker,
1470
+ input_source,
1471
+ current_ops,
1472
+ save_path,
1473
+ _run_plane_kwargs,
1474
+ reader_kwargs,
1475
+ log_queue,
1476
+ )
1477
+ future_to_plane[fut] = plane_num
1478
+ plane_start_times[plane_num] = time.time()
1479
+
1480
+ done_count = 0
1481
+ for fut in as_completed(future_to_plane):
1482
+ plane_num = future_to_plane[fut]
1483
+ try:
1484
+ result_plane_num, ops_file = fut.result()
1485
+ ops_files.append((result_plane_num, ops_file))
1486
+ done_count += 1
1487
+ print(
1488
+ f"--- Plane {result_plane_num} elapsed: "
1489
+ f"{time.time() - plane_start_times[plane_num]:.1f}s ---"
1490
+ )
1491
+ if progress_callback:
1492
+ progress_callback(
1493
+ plane=done_count - 1,
1494
+ total_planes=len(planes_indices),
1495
+ step="plane_done",
1496
+ message=f"Plane {result_plane_num} complete",
1497
+ )
1498
+ except Exception as e:
1499
+ print(f"ERROR processing plane {plane_num}: {e}")
1500
+ traceback.print_exc()
1501
+ finally:
1502
+ listener.stop()
1503
+
1504
+ # Sort results back into z-order — volumetric stats/plots rely on
1505
+ # list order matching plane order.
1506
+ ops_files = [p for _, p in sorted(ops_files, key=lambda t: t[0])]
1195
1507
 
1196
1508
  if not ops_files:
1197
1509
  raise RuntimeError(
1198
1510
  "run_volume failed: All planes resulted in exceptions during processing."
1199
1511
  )
1200
1512
 
1513
+ if skip_volumetric:
1514
+ logger.info(
1515
+ "skip_volumetric=True; returning per-plane ops_files without aggregation"
1516
+ )
1517
+ return ops_files
1518
+
1201
1519
  # Post-Loop: Merging and Volume Stats
1202
1520
 
1203
1521
  # Check for multi-ROI merging using metadata (not filename heuristics)
@@ -1280,6 +1598,13 @@ def run_volume(
1280
1598
  print(f"Warning: Volume statistics failed: {e}")
1281
1599
  traceback.print_exc()
1282
1600
 
1601
+ _volume_elapsed = time.time() - _volume_start
1602
+ _h, _rem = divmod(int(_volume_elapsed), 3600)
1603
+ _m, _s = divmod(_rem, 60)
1604
+ print(
1605
+ f"\nVolume total elapsed: {_h:02d}:{_m:02d}:{_s:02d} "
1606
+ f"({_volume_elapsed:.1f}s) across {len(ops_files)} plane(s)"
1607
+ )
1283
1608
  return ops_files
1284
1609
 
1285
1610
 
@@ -1388,13 +1713,25 @@ def _should_register(ops_path: str | Path) -> bool:
1388
1713
  """
1389
1714
  Determine whether Suite2p registration still needs to be performed.
1390
1715
 
1391
- Registration is considered complete if any of the following hold:
1392
- - A reference image (refImg) exists and is a valid ndarray
1393
- - meanImg exists (Suite2p always produces it post-registration)
1394
- - Valid registration offsets (xoff/yoff) are present
1395
-
1396
1716
  Returns True if registration *should* be run, False otherwise.
1717
+
1718
+ Decision rule:
1719
+ 1. If the plane's data.bin (suite2p's registered output) is missing,
1720
+ registration has not run yet on this plane — return True
1721
+ unconditionally. ops.npy field state is not trusted here because
1722
+ write_ops merges the source array's metadata into a freshly-written
1723
+ ops.npy, and source metadata can carry suite2p-output-shaped keys
1724
+ (e.g. when imread of a directory pulls in a sibling ops.npy) that
1725
+ falsely advertise "registration done" on what is actually raw data.
1726
+ 2. With data.bin present, fall back to ops.npy field inspection
1727
+ (refImg / meanImg / xoff / yoff / regDX / regPC) to decide whether
1728
+ the previous registration is complete enough to reuse.
1397
1729
  """
1730
+ ops_path = Path(ops_path)
1731
+ reg_bin = ops_path.parent / "data.bin"
1732
+ if not reg_bin.exists():
1733
+ return True
1734
+
1398
1735
  ops = load_ops(ops_path)
1399
1736
 
1400
1737
  has_ref = isinstance(ops.get("refImg"), np.ndarray)
@@ -1411,7 +1748,10 @@ def _should_register(ops_path: str | Path) -> bool:
1411
1748
  return False
1412
1749
 
1413
1750
  has_offsets = _has_valid_offsets("xoff") or _has_valid_offsets("yoff")
1414
- has_metrics = any(k in ops for k in ("regDX", "regPC", "regPC1", "regDX1"))
1751
+ has_metrics = any(
1752
+ isinstance(ops.get(k), np.ndarray) and ops[k].size > 0
1753
+ for k in ("regDX", "regPC", "regPC1", "regDX1")
1754
+ )
1415
1755
 
1416
1756
  # registration done if any of these are true
1417
1757
  registration_done = has_ref or has_mean or has_offsets or has_metrics
@@ -1456,7 +1796,7 @@ def run_plane_bin(ops) -> bool:
1456
1796
 
1457
1797
  # resolve path keys. rewrite stale absolutes (ops was moved) but
1458
1798
  # preserve an already-valid absolute path (staging points raw_file/
1459
- # reg_file/chan2_file at the source dir — clobbering those would
1799
+ # reg_file/chan2_file at the source dir — overwriting those would
1460
1800
  # break the link).
1461
1801
  ops["save_path"] = str(ops_parent)
1462
1802
  ops["ops_path"] = str(ops_parent / "ops.npy")
@@ -1609,7 +1949,7 @@ def run_plane_bin(ops) -> bool:
1609
1949
  reg_file_chan2 = ops_parent / "data_chan2_reg.bin" if use_chan2 else None
1610
1950
 
1611
1951
  # NOTE: previous versions hard-coded `ops["anatomical_red"] = False`
1612
- # and `ops["chan2_thres"] = 0.1` here. Both were silently clobbering
1952
+ # and `ops["chan2_thres"] = 0.1` here. Both were silently overwriting
1613
1953
  # the user's settings and the suite2p schema defaults
1614
1954
  # (detection.chan2_threshold = 0.25). They've been removed — the
1615
1955
  # user's chan2 threshold now flows through unchanged. If a downstream
@@ -2026,6 +2366,10 @@ def run_plane(
2026
2366
  )
2027
2367
 
2028
2368
  existing_ops = np.load(src_dir / "ops.npy", allow_pickle=True).item()
2369
+ # remember the original acquisition source before the merge below
2370
+ # overwrites data_path with the staged binary path. needed by
2371
+ # force_reg path when the source has no data_raw.bin.
2372
+ original_data_path = existing_ops.get("data_path")
2029
2373
  metadata = {
2030
2374
  k: v
2031
2375
  for k, v in existing_ops.items()
@@ -2073,20 +2417,72 @@ def run_plane(
2073
2417
  "data_path": str(input_path.resolve()),
2074
2418
  }
2075
2419
 
2076
- # registration would need to write to data.bin, but data.bin
2077
- # lives at the source dir running it would clobber the user's
2078
- # source binary. force detection-only; user can re-register from
2079
- # the original tiff/zarr if they want fresh registration.
2080
- if ops_user.get("do_registration", 1):
2081
- logger.warning(
2082
- "do_registration=1 ignored when staging from an existing "
2083
- "registered binary into a different save_path — running "
2084
- "registration would clobber the source data.bin. Forcing "
2085
- "do_registration=0 (detection-only)."
2086
- )
2087
- ops["do_registration"] = 0
2420
+ if not force_reg:
2421
+ # registration writes to data.bin, but data.bin lives in the
2422
+ # source dir running it would overwrite the user's source
2423
+ # binary. force detection-only; user can re-register from the
2424
+ # original tiff/zarr by passing force_reg=True (Force in the
2425
+ # GUI), which routes writes into the new plane_dir.
2426
+ if ops_user.get("do_registration", 1):
2427
+ logger.warning(
2428
+ "do_registration ignored: the source directory already "
2429
+ "contains a data.bin and running registration here would "
2430
+ "overwrite it. Forcing do_registration=0 (detection-only). "
2431
+ "Select Force in the Registration column (force_reg=True) "
2432
+ "to re-register from the original input into the new "
2433
+ "save_path."
2434
+ )
2435
+ ops["do_registration"] = 0
2088
2436
 
2089
2437
  _stage_source_into_plane_dir(src_dir, plane_dir, ops)
2438
+
2439
+ if force_reg:
2440
+ # writes go to plane_dir/data.bin — discard the source pointer
2441
+ # that _stage_source_into_plane_dir set.
2442
+ ops.pop("reg_file", None)
2443
+ # existing_ops carries a `raw_file` path from the original
2444
+ # run; if keep_raw=False removed the file, the string is
2445
+ # still present but stale. existence-check, don't trust the
2446
+ # string alone.
2447
+ _raw = ops.get("raw_file")
2448
+ if not _raw or not Path(_raw).exists():
2449
+ import shutil
2450
+ target_raw = plane_dir / "data_raw.bin"
2451
+ # prefer the original acquisition (tiff/zarr) when it's
2452
+ # still on disk — avoids re-registering already-
2453
+ # registered data. Skip when it points at the same .bin
2454
+ # the user just loaded (no improvement, and imread of a
2455
+ # stale data_path could be wrong).
2456
+ use_original = (
2457
+ bool(original_data_path)
2458
+ and Path(original_data_path).exists()
2459
+ and Path(original_data_path).resolve() != input_path.resolve()
2460
+ )
2461
+ if use_original:
2462
+ print(
2463
+ f" Force registration: rewriting data_raw.bin from "
2464
+ f"{Path(original_data_path).name}"
2465
+ )
2466
+ file = imread(Path(original_data_path), **reader_kwargs)
2467
+ if hasattr(file, "metadata"):
2468
+ metadata = dict(file.metadata)
2469
+ else:
2470
+ metadata = get_metadata(Path(original_data_path))
2471
+ skip_imwrite = False
2472
+ else:
2473
+ # original acquisition is gone (or IS the input).
2474
+ # seed data_raw.bin from the staged data.bin so
2475
+ # registration has frames to work with. Re-registers
2476
+ # already-registered data (near-zero shifts) — fine
2477
+ # for detection / bad-frame re-runs.
2478
+ print(
2479
+ f" Force registration: original acquisition "
2480
+ f"unavailable; seeding data_raw.bin from "
2481
+ f"{input_path.name} (re-registering already-"
2482
+ f"registered data)"
2483
+ )
2484
+ shutil.copy2(input_path, target_raw)
2485
+ ops["raw_file"] = str(target_raw)
2090
2486
  else:
2091
2487
  skip_imwrite = False
2092
2488
 
@@ -2223,7 +2619,7 @@ def run_plane(
2223
2619
  if src_fs is None and file is not None and hasattr(file, "metadata"):
2224
2620
  src_fs = (file.metadata or {}).get("fs")
2225
2621
  if src_fs is not None:
2226
- # only override the lbm default — don't clobber a value the
2622
+ # only override the lbm default — don't overwrite a value the
2227
2623
  # user explicitly set in their ops_user dict.
2228
2624
  if ops.get("fs") in (None, 10.0) or "fs" not in ops_user:
2229
2625
  ops["fs"] = float(src_fs)
@@ -2307,6 +2703,11 @@ def run_plane(
2307
2703
  user_skip_detect = (not ops.get("roidetect", 1)) and not force_detect
2308
2704
 
2309
2705
  stat_file = plane_dir / "stat.npy"
2706
+ # If we just wrote a fresh data_raw.bin in this run, any stat.npy /
2707
+ # ops.npy left in plane_dir from a prior run is stale and must not
2708
+ # short-circuit suite2p. fresh raw data needs registration and (unless
2709
+ # user-disabled) detection regardless of leftover artifacts.
2710
+ fresh_binary_written = (not skip_imwrite) and (file is not None) and should_write
2310
2711
 
2311
2712
  if user_skip_reg and user_skip_detect:
2312
2713
  needs_reg = False
@@ -2318,6 +2719,8 @@ def run_plane(
2318
2719
  needs_detect = False
2319
2720
  if force_detect:
2320
2721
  needs_detect = True
2722
+ elif fresh_binary_written:
2723
+ needs_detect = not user_skip_detect
2321
2724
  elif not stat_file.exists():
2322
2725
  needs_detect = not user_skip_detect
2323
2726
  elif ops.get("roidetect", 1):
@@ -2331,6 +2734,8 @@ def run_plane(
2331
2734
  needs_reg = True
2332
2735
  elif user_skip_reg:
2333
2736
  needs_reg = False
2737
+ elif fresh_binary_written:
2738
+ needs_reg = True
2334
2739
  elif not ops_file.exists():
2335
2740
  needs_reg = True
2336
2741
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lbm_suite2p_python
3
- Version: 3.0.4
3
+ Version: 3.0.6
4
4
  Summary: Calcium Imaging Pipeline built with Suite2p, Cellpose and Rastermap
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: homepage, https://github.com/MillerBrainObservatory/LBM-Suite2p-Python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lbm_suite2p_python"
7
- version = "3.0.4"
7
+ version = "3.0.6"
8
8
  description = "Calcium Imaging Pipeline built with Suite2p, Cellpose and Rastermap"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"