lbm_suite2p_python 3.0.1__tar.gz → 3.0.2__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 (34) hide show
  1. {lbm_suite2p_python-3.0.1/lbm_suite2p_python.egg-info → lbm_suite2p_python-3.0.2}/PKG-INFO +3 -3
  2. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/__init__.py +2 -0
  3. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/cellpose.py +2 -5
  4. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/cli.py +7 -5
  5. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/conversion.py +0 -2
  6. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/db_settings.py +58 -9
  7. lbm_suite2p_python-3.0.2/lbm_suite2p_python/default_ops.py +84 -0
  8. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/gui.py +0 -1
  9. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/postprocessing.py +226 -5
  10. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/run_lsp.py +273 -404
  11. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/volume.py +220 -11
  12. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/zplane.py +307 -315
  13. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2/lbm_suite2p_python.egg-info}/PKG-INFO +3 -3
  14. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python.egg-info/SOURCES.txt +0 -1
  15. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python.egg-info/requires.txt +1 -1
  16. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/pyproject.toml +106 -107
  17. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/tests/test_frame_count_aliases.py +315 -317
  18. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/tests/test_pipeline_parameters.py +2 -2
  19. lbm_suite2p_python-3.0.1/lbm_suite2p_python/_padding_shim.py +0 -140
  20. lbm_suite2p_python-3.0.1/lbm_suite2p_python/default_ops.py +0 -230
  21. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/LICENSE.md +0 -0
  22. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/MANIFEST.in +0 -0
  23. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/README.md +0 -0
  24. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/__main__.py +0 -0
  25. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/_benchmarking.py +0 -0
  26. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/grid_search.py +0 -0
  27. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/merging.py +0 -0
  28. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python/utils.py +0 -0
  29. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python.egg-info/dependency_links.txt +0 -0
  30. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python.egg-info/entry_points.txt +0 -0
  31. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/lbm_suite2p_python.egg-info/top_level.txt +0 -0
  32. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/setup.cfg +0 -0
  33. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/tests/test_refactored_pipeline.py +0 -0
  34. {lbm_suite2p_python-3.0.1 → lbm_suite2p_python-3.0.2}/tests/test_run_volume.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lbm_suite2p_python
3
- Version: 3.0.1
4
- Summary: Light Beads Microscopy Pipeline using Suite2p
3
+ Version: 3.0.2
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
7
7
  Keywords: Pipeline,Numpy,Microscopy,ScanImage,Suite2p,tiff
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
11
11
  Requires-Python: <3.14,>=3.12.7
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE.md
14
- Requires-Dist: mbo_utilities>=3.0.0
14
+ Requires-Dist: mbo_utilities>=3.0.1
15
15
  Requires-Dist: suite2p>=1.0.0.1
16
16
  Requires-Dist: setuptools<81
17
17
  Provides-Extra: rastermap
@@ -48,6 +48,7 @@ from lbm_suite2p_python.volume import (
48
48
  plot_orthoslices,
49
49
  plot_3d_roi_map,
50
50
  plot_3d_rastermap_clusters,
51
+ plot_volume_trace_figures,
51
52
  plot_volume_signal,
52
53
  plot_volume_neuron_counts,
53
54
  consolidate_volume,
@@ -149,6 +150,7 @@ __all__ = [
149
150
  "plot_orthoslices",
150
151
  "plot_3d_roi_map",
151
152
  "plot_3d_rastermap_clusters",
153
+ "plot_volume_trace_figures",
152
154
  "plot_projection",
153
155
  "plot_volume_signal",
154
156
  "plot_volume_neuron_counts",
@@ -194,10 +194,8 @@ def _masks_to_stat(masks, img=None, compute_overlap=True):
194
194
 
195
195
  # Build pixel count map for overlap detection
196
196
  if compute_overlap and masks.ndim == 2:
197
- # Count how many ROIs claim each pixel (for overlap computation)
198
- # Since cellpose masks are non-overlapping by design, we check boundaries
199
- from scipy import ndimage
200
- # Dilate each mask slightly to find potential overlaps at boundaries
197
+ # cellpose masks are non-overlapping by construction; track per-pixel
198
+ # claim count so callers can detect dilated-boundary overlaps
201
199
  overlap_map = np.zeros(masks.shape, dtype=np.int32)
202
200
  for roi_id in range(1, n_rois + 1):
203
201
  roi_mask = masks == roi_id
@@ -1611,7 +1609,6 @@ def prepare_training_data(
1611
1609
  train_cellpose : Train model on prepared data
1612
1610
  save_gui_results : Save results in GUI-compatible format
1613
1611
  """
1614
- import shutil
1615
1612
  import tifffile
1616
1613
 
1617
1614
  output_dir = Path(output_dir)
@@ -23,13 +23,10 @@ Examples:
23
23
  """
24
24
 
25
25
  import argparse
26
- import json
27
26
  import sys
28
27
  from pathlib import Path
29
28
  from typing import Any
30
29
 
31
- import numpy as np
32
-
33
30
 
34
31
  def _snake_to_kebab(name: str) -> str:
35
32
  """convert snake_case to kebab-case for CLI args."""
@@ -95,7 +92,6 @@ def _get_ops_help() -> dict[str, str]:
95
92
  def build_parser() -> argparse.ArgumentParser:
96
93
  """build the argument parser with all pipeline and ops parameters."""
97
94
  from lbm_suite2p_python.default_ops import s2p_ops
98
- from lbm_suite2p_python import __version__
99
95
 
100
96
  parser = argparse.ArgumentParser(
101
97
  prog="lsp",
@@ -193,6 +189,11 @@ Examples:
193
189
  "--dff-smooth", type=int, dest="dff_smooth_window",
194
190
  help="smoothing window for dF/F"
195
191
  )
192
+ dff.add_argument(
193
+ "--correct-neuropil", dest="correct_neuropil",
194
+ action=argparse.BooleanOptionalAction, default=True,
195
+ help="subtract 0.7*Fneu before dF/F (default: on; use --no-correct-neuropil to disable)"
196
+ )
196
197
 
197
198
  # cell filter options
198
199
  filters = parser.add_argument_group("cell filter options")
@@ -491,7 +492,7 @@ def main():
491
492
 
492
493
  # run pipeline
493
494
  try:
494
- results = lsp.pipeline(
495
+ lsp.pipeline(
495
496
  input_data=input_path,
496
497
  save_path=output_path,
497
498
  ops=ops,
@@ -505,6 +506,7 @@ def main():
505
506
  dff_window_size=args.dff_window_size,
506
507
  dff_percentile=args.dff_percentile,
507
508
  dff_smooth_window=args.dff_smooth_window,
509
+ correct_neuropil=args.correct_neuropil,
508
510
  cell_filters=cell_filters,
509
511
  accept_all_cells=args.accept_all_cells,
510
512
  save_json=args.save_json,
@@ -746,8 +746,6 @@ def compare_detections(path_a, path_b, iou_threshold=0.5):
746
746
  dict
747
747
  Comparison results with matched pairs and unique ROIs.
748
748
  """
749
- from scipy.ndimage import label
750
-
751
749
  path_a, path_b = Path(path_a), Path(path_b)
752
750
 
753
751
  # load masks from both
@@ -189,6 +189,31 @@ _ANATOMICAL_ONLY_TO_IMG: dict[int, str] = {
189
189
  }
190
190
  _UPSTREAM_TO_FORK_RENAMES = {v: k for k, v in _FORK_TO_UPSTREAM_RENAMES.items()}
191
191
 
192
+ # per-section flat-key disambiguation. Several upstream sections share
193
+ # an upstream key. When flattened to ops, the second iteration clobbers
194
+ # the first; on the reverse derivation, the surviving value gets fanned
195
+ # back into BOTH sections — silently corrupting one of them.
196
+ # This map gives the colliding (section, upstream_key) pair its own
197
+ # distinct flat-ops key so the round-trip preserves both values.
198
+ #
199
+ # Discovered collisions (audit via _SETTINGS_SECTIONS + detection groups):
200
+ # "batch_size" — registration vs extraction.
201
+ # registration keeps the legacy flat name "batch_size"
202
+ # (matches the historical fork ops shape);
203
+ # extraction gets "extract_batch_size".
204
+ # "block_size" — registration vs detection (suite2p uses the same
205
+ # upstream key for the rigid-block geometry AND the
206
+ # pca-denoise / sparsery block size).
207
+ # registration keeps the legacy flat name "block_size";
208
+ # detection gets "det_block_size".
209
+ #
210
+ # Both rename targets match the field names that mbo's GUI dataclass
211
+ # already uses (`extract_batch_size`, `det_block_size_y/_x`).
212
+ _SECTION_FLAT_RENAMES: dict[tuple[str, str], str] = {
213
+ ("extraction", "batch_size"): "extract_batch_size",
214
+ ("detection", "block_size"): "det_block_size",
215
+ }
216
+
192
217
  # baseline values differ between fork and upstream:
193
218
  # fork dcnv.preprocess branches on "maximin" / "constant" / "constant_prctile"
194
219
  # upstream dcnv.preprocess branches on "maximin" / "constant" / "prctile"
@@ -284,9 +309,15 @@ def ops_to_db_settings(ops: dict) -> tuple[dict, dict]:
284
309
  for section, keys in _SETTINGS_SECTIONS.items():
285
310
  bucket: dict[str, Any] = {}
286
311
  for key in keys:
287
- if key not in lookup:
312
+ # per-section flat-key disambiguation. e.g. extraction reads
313
+ # its batch_size from ops["extract_batch_size"] (not
314
+ # ops["batch_size"]) so registration's batch_size doesn't get
315
+ # spread into both sections during the flat→structured
316
+ # derivation. Mirror of the write side in db_settings_to_ops.
317
+ flat_key = _SECTION_FLAT_RENAMES.get((section, key), key)
318
+ if flat_key not in lookup:
288
319
  continue
289
- value = lookup[key]
320
+ value = lookup[flat_key]
290
321
  if section == "run" and key == "do_registration":
291
322
  value = _ensure_do_registration_int(value)
292
323
  if section == "registration" and key == "block_size" and isinstance(value, list):
@@ -316,11 +347,16 @@ def ops_to_db_settings(ops: dict) -> tuple[dict, dict]:
316
347
  for key in _DETECTION_TOP_KEYS:
317
348
  if key == "algorithm":
318
349
  continue
319
- if key in lookup:
320
- value = lookup[key]
321
- if key == "block_size" and isinstance(value, list):
322
- value = tuple(value)
323
- detection[key] = value
350
+ # per-section flat-key disambiguation. detection.block_size reads
351
+ # from ops["det_block_size"] (not ops["block_size"], which holds
352
+ # the registration block size). Mirror of the write side.
353
+ flat_key = _SECTION_FLAT_RENAMES.get(("detection", key), key)
354
+ if flat_key not in lookup:
355
+ continue
356
+ value = lookup[flat_key]
357
+ if key == "block_size" and isinstance(value, list):
358
+ value = tuple(value)
359
+ detection[key] = value
324
360
 
325
361
  sparsery: dict[str, Any] = {}
326
362
  for key in _DETECTION_SPARSERY_KEYS:
@@ -410,7 +446,14 @@ def db_settings_to_ops(db: dict | None, settings: dict | None) -> dict:
410
446
  value = section_dict[key]
411
447
  if section == "dcnv_preprocess" and key == "baseline":
412
448
  value = _BASELINE_UPSTREAM_TO_FORK.get(str(value), value)
413
- target_name = _UPSTREAM_TO_FORK_RENAMES.get(key, key)
449
+ # per-section disambiguation first (e.g. extraction.batch_size
450
+ # → extract_batch_size to avoid colliding with
451
+ # registration.batch_size). Fall back to the global
452
+ # upstream→fork rename map.
453
+ target_name = _SECTION_FLAT_RENAMES.get(
454
+ (section, key),
455
+ _UPSTREAM_TO_FORK_RENAMES.get(key, key),
456
+ )
414
457
  ops[target_name] = value
415
458
 
416
459
  # mirror align_by_chan2 back to align_by_chan for fork consumers
@@ -423,7 +466,13 @@ def db_settings_to_ops(db: dict | None, settings: dict | None) -> dict:
423
466
  for key in _DETECTION_TOP_KEYS:
424
467
  if key in detection:
425
468
  value = detection[key]
426
- target_name = _UPSTREAM_TO_FORK_RENAMES.get(key, key)
469
+ # per-section disambiguation first (e.g. detection.block_size
470
+ # → det_block_size to avoid colliding with
471
+ # registration.block_size).
472
+ target_name = _SECTION_FLAT_RENAMES.get(
473
+ ("detection", key),
474
+ _UPSTREAM_TO_FORK_RENAMES.get(key, key),
475
+ )
427
476
  ops[target_name] = value
428
477
 
429
478
  # reverse the algorithm → sparse_mode derivation for fork consumers
@@ -0,0 +1,84 @@
1
+ """Default ops for the LBM pipeline.
2
+
3
+ Historically this module hard-coded a flat ops dict whose values
4
+ diverged from suite2p's own defaults in a handful of fields
5
+ (`batch_size=500`, `chan2_thres=0.65`, `tau=1.3`, `diameter=4`, etc.).
6
+ That divergence made the round-trip through `settings.npy` confusing —
7
+ on-disk values that matched the lsp default were flagged as "modified"
8
+ against suite2p's schema, and vice versa.
9
+
10
+ This module now exposes exactly suite2p's defaults (the values from
11
+ `suite2p.default_settings()` + `suite2p.default_db()`), flattened to the
12
+ fork's flat-ops shape via the same `db_settings_to_ops` translation
13
+ that the rest of lsp uses. There is now ONE source of truth for "what
14
+ default means": suite2p's parameter schema. Any LBM-specific tweaks
15
+ (diameter, tau, etc.) belong in user code, not in the default.
16
+ """
17
+
18
+ from mbo_utilities.metadata import get_param, get_voxel_size
19
+
20
+
21
+ def s2p_ops() -> dict:
22
+ """Suite2p's default ops in flat (fork-style) form.
23
+
24
+ Composed from `suite2p.default_settings()` + `suite2p.default_db()`
25
+ via `db_settings_to_ops`, so the rename map and per-section
26
+ flat-key disambiguation (e.g. extraction.batch_size →
27
+ extract_batch_size) are applied consistently.
28
+ """
29
+ from suite2p import default_settings, default_db
30
+ from lbm_suite2p_python.db_settings import db_settings_to_ops
31
+
32
+ return db_settings_to_ops(default_db(), default_settings())
33
+
34
+
35
+ def default_ops(metadata: dict | None = None, ops: dict | None = None) -> dict:
36
+ """Return default ops for the LBM Suite2p pipeline.
37
+
38
+ Parameters
39
+ ----------
40
+ metadata : dict, optional
41
+ Source-data metadata. When provided, `fs` and the (`dx`, `dy`)
42
+ voxel-size pair are pulled in from the metadata and overlaid
43
+ onto the suite2p defaults.
44
+ ops : dict, optional
45
+ A user-supplied ops dict to start from. When None, starts from
46
+ `s2p_ops()` (suite2p's defaults).
47
+
48
+ Returns
49
+ -------
50
+ dict
51
+ Flat ops dict ready to be passed to `pipeline()` / `run_plane()`.
52
+
53
+ Notes
54
+ -----
55
+ `nplanes=1` and `nchannels=1` are forced at the end. The lsp
56
+ pipeline always processes one plane at a time, so these stay fixed
57
+ regardless of caller input.
58
+
59
+ Examples
60
+ --------
61
+ >>> import lbm_suite2p_python as lsp
62
+ >>> ops = lsp.default_ops()
63
+ >>> lsp.run_plane(
64
+ ... ops=ops,
65
+ ... input_tiff="D:/demo/raw_data/raw_file_00001.tif",
66
+ ... save_path="D:/demo/results",
67
+ ... save_folder="v1",
68
+ ... )
69
+ """
70
+ if ops is None:
71
+ ops = s2p_ops()
72
+
73
+ if metadata is not None:
74
+ fs = get_param(metadata, "fs")
75
+ if fs is not None:
76
+ ops["fs"] = fs
77
+ voxel = get_voxel_size(metadata)
78
+ if voxel.dx != 1.0 or voxel.dy != 1.0:
79
+ ops["dx"] = voxel.dx
80
+ ops["dy"] = voxel.dy
81
+
82
+ ops["nplanes"] = 1
83
+ ops["nchannels"] = 1
84
+ return ops
@@ -9,7 +9,6 @@ Usage:
9
9
  """
10
10
 
11
11
  import argparse
12
- import sys
13
12
  import tempfile
14
13
  from pathlib import Path
15
14
 
@@ -1,6 +1,5 @@
1
1
  import json
2
2
  from pathlib import Path
3
- from typing import Callable
4
3
 
5
4
  import numpy as np
6
5
  from scipy.ndimage import percentile_filter
@@ -514,6 +513,222 @@ def filter_by_eccentricity(
514
513
  }
515
514
 
516
515
 
516
+ def _load_F_Fneu(plane_dir, F, Fneu):
517
+ """Load F.npy / Fneu.npy from plane_dir if not already provided."""
518
+ if F is None and plane_dir is not None:
519
+ f_path = plane_dir / "F.npy"
520
+ if f_path.exists():
521
+ F = np.load(f_path, allow_pickle=True)
522
+ if Fneu is None and plane_dir is not None:
523
+ fn_path = plane_dir / "Fneu.npy"
524
+ if fn_path.exists():
525
+ Fneu = np.load(fn_path, allow_pickle=True)
526
+ return F, Fneu
527
+
528
+
529
+ def _resolve_dff_window(window_size, ops):
530
+ """Same auto-window rule that dff_rolling_percentile uses."""
531
+ if window_size is not None:
532
+ return max(3, int(window_size))
533
+ fs = float((ops or {}).get("fs", 0) or 0)
534
+ tau = float((ops or {}).get("tau", 0) or 0)
535
+ w = int(10 * tau * fs) if (fs > 0 and tau > 0) else 300
536
+ return max(3, w)
537
+
538
+
539
+ def _compute_dff_baseline(F, Fneu, correct_neuropil, neuropil_coef, percentile, window_size):
540
+ """
541
+ Compute the rolling-percentile baseline that dff_rolling_percentile
542
+ will see, given the neuropil-correction toggle. Returns the per-cell
543
+ f0 array (same shape as F).
544
+ """
545
+ if correct_neuropil:
546
+ if Fneu is None:
547
+ raise ValueError("correct_neuropil=True requires Fneu")
548
+ f_in = F - neuropil_coef * Fneu
549
+ else:
550
+ f_in = F
551
+ return np.array(
552
+ [percentile_filter(f, percentile, size=window_size, mode="nearest") for f in f_in]
553
+ )
554
+
555
+
556
+ def filter_by_negative_baseline(
557
+ plane_dir=None,
558
+ iscell=None,
559
+ stat=None,
560
+ ops=None,
561
+ F=None,
562
+ Fneu=None,
563
+ correct_neuropil: bool = True,
564
+ neuropil_coef: float = 0.7,
565
+ percentile: int = 20,
566
+ window_size: int = None,
567
+ save: bool = False,
568
+ ):
569
+ """
570
+ Reject ROIs whose rolling-percentile baseline ever goes negative.
571
+
572
+ Real fluorescence is nonnegative. A negative rolling baseline means the
573
+ trace dropped below zero somewhere — usually neuropil over-subtraction
574
+ or registration drop-out. These cells produce divide-by-near-zero
575
+ blowups in dF/F.
576
+
577
+ Parameters mirror :func:`dff_rolling_percentile` so the baseline matches
578
+ what the dF/F plotting will see. Returns the standard
579
+ ``(iscell_filtered, removed_mask, info)`` triple.
580
+ """
581
+ iscell, stat, ops, plane_dir = _load_plane_data(plane_dir, iscell, stat, ops)
582
+ iscell_orig = _normalize_iscell(iscell)
583
+ F, Fneu = _load_F_Fneu(plane_dir, F, Fneu)
584
+ if F is None:
585
+ raise ValueError("filter_by_negative_baseline requires F (pass F or plane_dir with F.npy)")
586
+
587
+ window_size = _resolve_dff_window(window_size, ops)
588
+ f0 = _compute_dff_baseline(F, Fneu, correct_neuropil, neuropil_coef, percentile, window_size)
589
+ f0_min = f0.min(axis=1)
590
+
591
+ valid = f0_min >= 0
592
+ removed_mask = ~valid & iscell_orig
593
+ iscell_filtered = iscell_orig & valid
594
+ n_removed = int(removed_mask.sum())
595
+
596
+ if n_removed > 0:
597
+ print(f"filter_by_negative_baseline: removed {n_removed} ROIs (rolling p{percentile} F0 < 0)")
598
+
599
+ if save and plane_dir is not None:
600
+ _save_filtered_iscell(plane_dir, iscell_filtered, iscell_orig)
601
+
602
+ return iscell_filtered, removed_mask, {
603
+ "f0_min": f0_min,
604
+ "window_size": int(window_size),
605
+ "percentile": int(percentile),
606
+ "correct_neuropil": bool(correct_neuropil),
607
+ "neuropil_coef": float(neuropil_coef) if correct_neuropil else 0.0,
608
+ "n_removed": n_removed,
609
+ }
610
+
611
+
612
+ def filter_by_min_baseline_abs(
613
+ plane_dir=None,
614
+ iscell=None,
615
+ stat=None,
616
+ ops=None,
617
+ F=None,
618
+ Fneu=None,
619
+ correct_neuropil: bool = True,
620
+ neuropil_coef: float = 0.7,
621
+ percentile: int = 20,
622
+ window_size: int = None,
623
+ min_F0_abs: float = 1.0,
624
+ save: bool = False,
625
+ ):
626
+ """
627
+ Reject ROIs whose median rolling baseline is below an absolute floor.
628
+
629
+ Catches very dim cells where any small fluctuation dominates dF/F.
630
+ The threshold is in raw photon-count units (same units as F.npy).
631
+
632
+ Parameters
633
+ ----------
634
+ min_F0_abs : float, default 1.0
635
+ Reject cells whose median(rolling p-th percentile baseline) is
636
+ below this value.
637
+ """
638
+ iscell, stat, ops, plane_dir = _load_plane_data(plane_dir, iscell, stat, ops)
639
+ iscell_orig = _normalize_iscell(iscell)
640
+ F, Fneu = _load_F_Fneu(plane_dir, F, Fneu)
641
+ if F is None:
642
+ raise ValueError("filter_by_min_baseline_abs requires F (pass F or plane_dir with F.npy)")
643
+
644
+ window_size = _resolve_dff_window(window_size, ops)
645
+ f0 = _compute_dff_baseline(F, Fneu, correct_neuropil, neuropil_coef, percentile, window_size)
646
+ f0_median = np.median(f0, axis=1)
647
+
648
+ valid = f0_median >= min_F0_abs
649
+ removed_mask = ~valid & iscell_orig
650
+ iscell_filtered = iscell_orig & valid
651
+ n_removed = int(removed_mask.sum())
652
+
653
+ if n_removed > 0:
654
+ print(f"filter_by_min_baseline_abs: removed {n_removed} ROIs (median F0 < {min_F0_abs:g})")
655
+
656
+ if save and plane_dir is not None:
657
+ _save_filtered_iscell(plane_dir, iscell_filtered, iscell_orig)
658
+
659
+ return iscell_filtered, removed_mask, {
660
+ "f0_median": f0_median,
661
+ "min_F0_abs": float(min_F0_abs),
662
+ "window_size": int(window_size),
663
+ "percentile": int(percentile),
664
+ "correct_neuropil": bool(correct_neuropil),
665
+ "neuropil_coef": float(neuropil_coef) if correct_neuropil else 0.0,
666
+ "n_removed": n_removed,
667
+ }
668
+
669
+
670
+ def filter_by_min_baseline_rel(
671
+ plane_dir=None,
672
+ iscell=None,
673
+ stat=None,
674
+ ops=None,
675
+ F=None,
676
+ Fneu=None,
677
+ correct_neuropil: bool = True,
678
+ neuropil_coef: float = 0.7,
679
+ percentile: int = 20,
680
+ window_size: int = None,
681
+ min_F0_rel: float = 0.05,
682
+ save: bool = False,
683
+ ):
684
+ """
685
+ Reject ROIs whose minimum rolling baseline collapses far below the
686
+ cell's own typical raw brightness.
687
+
688
+ Catches transient baseline drop-outs (e.g. registration / motion
689
+ artifacts) that don't manifest as a low median baseline but still
690
+ cause dF/F spikes when the divisor briefly approaches zero.
691
+
692
+ Parameters
693
+ ----------
694
+ min_F0_rel : float, default 0.05
695
+ Reject cells whose ``min(rolling baseline) < min_F0_rel * median(F_raw)``.
696
+ """
697
+ iscell, stat, ops, plane_dir = _load_plane_data(plane_dir, iscell, stat, ops)
698
+ iscell_orig = _normalize_iscell(iscell)
699
+ F, Fneu = _load_F_Fneu(plane_dir, F, Fneu)
700
+ if F is None:
701
+ raise ValueError("filter_by_min_baseline_rel requires F (pass F or plane_dir with F.npy)")
702
+
703
+ window_size = _resolve_dff_window(window_size, ops)
704
+ f0 = _compute_dff_baseline(F, Fneu, correct_neuropil, neuropil_coef, percentile, window_size)
705
+ f0_min = f0.min(axis=1)
706
+ f_raw_median = np.median(F, axis=1)
707
+ threshold = min_F0_rel * f_raw_median
708
+
709
+ valid = f0_min >= threshold
710
+ removed_mask = ~valid & iscell_orig
711
+ iscell_filtered = iscell_orig & valid
712
+ n_removed = int(removed_mask.sum())
713
+
714
+ if n_removed > 0:
715
+ print(f"filter_by_min_baseline_rel: removed {n_removed} ROIs (min F0 < {min_F0_rel:g} * median(F_raw))")
716
+
717
+ if save and plane_dir is not None:
718
+ _save_filtered_iscell(plane_dir, iscell_filtered, iscell_orig)
719
+
720
+ return iscell_filtered, removed_mask, {
721
+ "f0_min": f0_min,
722
+ "f_raw_median": f_raw_median,
723
+ "min_F0_rel": float(min_F0_rel),
724
+ "window_size": int(window_size),
725
+ "percentile": int(percentile),
726
+ "correct_neuropil": bool(correct_neuropil),
727
+ "neuropil_coef": float(neuropil_coef) if correct_neuropil else 0.0,
728
+ "n_removed": n_removed,
729
+ }
730
+
731
+
517
732
  def apply_filters(
518
733
  plane_dir=None,
519
734
  iscell=None,
@@ -540,7 +755,7 @@ def apply_filters(
540
755
  Suite2p ops dictionary.
541
756
  filters : list of dict
542
757
  List of filter configurations. Each dict must have:
543
- - 'name': str - filter function name (e.g., 'max_diameter', 'area', 'eccentricity')
758
+ - 'name': str - filter function name (e.g., 'max_diameter', 'area', 'eccentricity', 'baseline')
544
759
  - Additional keys are passed as kwargs to the filter function.
545
760
 
546
761
  Available filters:
@@ -548,6 +763,9 @@ def apply_filters(
548
763
  - 'max_diameter': filter_by_max_diameter (max_diameter_um, max_diameter_px, min_diameter_um, min_diameter_px)
549
764
  - 'area': filter_by_area (min_area_px, max_area_px, min_mult, max_mult)
550
765
  - 'eccentricity': filter_by_eccentricity (max_ratio, min_ratio)
766
+ - 'negative_baseline': filter_by_negative_baseline (correct_neuropil, percentile, window_size, neuropil_coef)
767
+ - 'min_baseline_abs': filter_by_min_baseline_abs (min_F0_abs, correct_neuropil, percentile, window_size, neuropil_coef)
768
+ - 'min_baseline_rel': filter_by_min_baseline_rel (min_F0_rel, correct_neuropil, percentile, window_size, neuropil_coef)
551
769
 
552
770
  save : bool, default False
553
771
  If True, save final filtered iscell.npy to plane_dir.
@@ -586,6 +804,9 @@ def apply_filters(
586
804
  "max_diameter": filter_by_max_diameter,
587
805
  "area": filter_by_area,
588
806
  "eccentricity": filter_by_eccentricity,
807
+ "negative_baseline": filter_by_negative_baseline,
808
+ "min_baseline_abs": filter_by_min_baseline_abs,
809
+ "min_baseline_rel": filter_by_min_baseline_rel,
589
810
  }
590
811
 
591
812
  iscell, stat, ops, plane_dir = _load_plane_data(plane_dir, iscell, stat, ops)
@@ -613,8 +834,10 @@ def apply_filters(
613
834
  # Don't save intermediate results, only final
614
835
  config["save"] = False
615
836
 
616
- # Apply filter
837
+ # Apply filter — pass plane_dir so filters that need extra files
838
+ # (e.g. F.npy / Fneu.npy for baseline) can find them.
617
839
  iscell_current, removed, info = filter_fn(
840
+ plane_dir=plane_dir,
618
841
  iscell=iscell_current,
619
842
  stat=stat,
620
843
  ops=ops,
@@ -1049,8 +1272,6 @@ def compute_trace_quality_score(
1049
1272
  if weights is None:
1050
1273
  weights = {'snr': 1.0, 'skewness': 0.8, 'shot_noise': 0.5}
1051
1274
 
1052
- n_neurons = F.shape[0]
1053
-
1054
1275
  # neuropil correction and rectification
1055
1276
  if Fneu is not None:
1056
1277
  F_corr = F - 0.7 * Fneu