lbm_suite2p_python 3.0.0__tar.gz → 3.0.1__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.0/lbm_suite2p_python.egg-info → lbm_suite2p_python-3.0.1}/PKG-INFO +3 -6
  2. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/__init__.py +4 -0
  3. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/conversion.py +3 -1
  4. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/db_settings.py +60 -19
  5. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/default_ops.py +10 -2
  6. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/postprocessing.py +9 -1
  7. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/run_lsp.py +104 -22
  8. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/volume.py +22 -12
  9. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/zplane.py +382 -0
  10. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1/lbm_suite2p_python.egg-info}/PKG-INFO +3 -6
  11. lbm_suite2p_python-3.0.1/lbm_suite2p_python.egg-info/requires.txt +12 -0
  12. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/pyproject.toml +4 -9
  13. lbm_suite2p_python-3.0.0/lbm_suite2p_python.egg-info/requires.txt +0 -16
  14. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/LICENSE.md +0 -0
  15. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/MANIFEST.in +0 -0
  16. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/README.md +0 -0
  17. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/__main__.py +0 -0
  18. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/_benchmarking.py +0 -0
  19. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/_padding_shim.py +0 -0
  20. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/cellpose.py +0 -0
  21. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/cli.py +0 -0
  22. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/grid_search.py +0 -0
  23. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/gui.py +0 -0
  24. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/merging.py +0 -0
  25. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python/utils.py +0 -0
  26. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python.egg-info/SOURCES.txt +0 -0
  27. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python.egg-info/dependency_links.txt +0 -0
  28. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python.egg-info/entry_points.txt +0 -0
  29. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/lbm_suite2p_python.egg-info/top_level.txt +0 -0
  30. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/setup.cfg +0 -0
  31. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/tests/test_frame_count_aliases.py +0 -0
  32. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/tests/test_pipeline_parameters.py +0 -0
  33. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/tests/test_refactored_pipeline.py +0 -0
  34. {lbm_suite2p_python-3.0.0 → lbm_suite2p_python-3.0.1}/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.0
3
+ Version: 3.0.1
4
4
  Summary: Light Beads Microscopy Pipeline using Suite2p
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: homepage, https://github.com/MillerBrainObservatory/LBM-Suite2p-Python
@@ -11,18 +11,15 @@ 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>=2.7.7
14
+ Requires-Dist: mbo_utilities>=3.0.0
15
15
  Requires-Dist: suite2p>=1.0.0.1
16
16
  Requires-Dist: setuptools<81
17
17
  Provides-Extra: rastermap
18
18
  Requires-Dist: rastermap; extra == "rastermap"
19
19
  Provides-Extra: cellpose
20
20
  Requires-Dist: cellpose>=4.0.6; extra == "cellpose"
21
- Provides-Extra: torch
22
- Requires-Dist: torch>=2.7.0; extra == "torch"
23
- Requires-Dist: torchvision>=0.22.0; extra == "torch"
24
21
  Provides-Extra: all
25
- Requires-Dist: lbm_suite2p_python[cellpose,rastermap,torch]; extra == "all"
22
+ Requires-Dist: lbm_suite2p_python[cellpose,rastermap]; extra == "all"
26
23
  Dynamic: license-file
27
24
 
28
25
  <p align="center">
@@ -39,6 +39,8 @@ from lbm_suite2p_python.zplane import (
39
39
  plot_filtered_cells,
40
40
  plot_diameter_histogram,
41
41
  plot_projection,
42
+ plot_accepted_rejected_overlay,
43
+ plot_volume_accepted_rejected_overlay,
42
44
  )
43
45
 
44
46
  from lbm_suite2p_python.volume import (
@@ -151,4 +153,6 @@ __all__ = [
151
153
  "plot_volume_signal",
152
154
  "plot_volume_neuron_counts",
153
155
  "consolidate_volume",
156
+ "plot_accepted_rejected_overlay",
157
+ "plot_volume_accepted_rejected_overlay",
154
158
  ]
@@ -20,6 +20,8 @@ from pathlib import Path
20
20
  import numpy as np
21
21
  from mbo_utilities.file_io import load_npy
22
22
 
23
+ from lbm_suite2p_python.db_settings import _ensure_diameter_array
24
+
23
25
 
24
26
  # file signatures for format detection
25
27
  SUITE2P_REQUIRED = ["stat.npy", "iscell.npy", "ops.npy"]
@@ -436,7 +438,7 @@ def cellpose_to_suite2p(
436
438
  meta_path = cellpose_dir / "cellpose_meta.npy"
437
439
  if meta_path.exists():
438
440
  cp_meta = np.load(meta_path, allow_pickle=True).item()
439
- ops["diameter"] = cp_meta.get("diameter", ops.get("diameter"))
441
+ ops["diameter"] = _ensure_diameter_array(cp_meta.get("diameter", ops.get("diameter")))
440
442
  ops["cellprob_threshold"] = cp_meta.get("cellprob_threshold")
441
443
  ops["flow_threshold"] = cp_meta.get("flow_threshold")
442
444
 
@@ -172,6 +172,20 @@ _FORK_TO_UPSTREAM_RENAMES: dict[str, str] = {
172
172
  "nbinned": "nbins",
173
173
  "high_pass": "highpass_time",
174
174
  "spatial_hp_cp": "highpass_spatial",
175
+ "pretrained_model": "cellpose_model",
176
+ }
177
+
178
+ # fork's anatomical_only int (1-4) selects which image cellpose runs on.
179
+ # upstream replaced this with a string in cellpose_settings.img. mapping:
180
+ # 1 -> 'max_proj / meanImg' (log ratio)
181
+ # 2 -> 'meanImg'
182
+ # 3 -> enhanced_mean_img (REMOVED upstream — no equivalent string;
183
+ # falls through to max_proj branch)
184
+ # 4 -> 'max_proj' (anything not matching the two strings hits else: img=max_proj)
185
+ _ANATOMICAL_ONLY_TO_IMG: dict[int, str] = {
186
+ 1: "max_proj / meanImg",
187
+ 2: "meanImg",
188
+ 4: "max_proj",
175
189
  }
176
190
  _UPSTREAM_TO_FORK_RENAMES = {v: k for k, v in _FORK_TO_UPSTREAM_RENAMES.items()}
177
191
 
@@ -189,17 +203,24 @@ _BASELINE_UPSTREAM_TO_FORK = {
189
203
  }
190
204
 
191
205
 
192
- def _ensure_diameter_list(value: Any) -> list[float]:
193
- """Upstream expects diameter as [dy, dx]. Fork may store a scalar int."""
206
+ def _ensure_diameter_array(value: Any) -> np.ndarray:
207
+ """Coerce diameter to an `np.ndarray` of shape (2,), mirroring suite2p
208
+ `pipeline_s2p.py:150-160`: scalar -> [d, d]; list/tuple -> array; size-1
209
+ array -> [d, d]; size>=2 ndarray passes through. None falls back to
210
+ [6., 6.] (fork-only — upstream doesn't accept None at this point).
211
+ """
194
212
  if value is None:
195
- return [12.0, 12.0]
213
+ return np.array([6.0, 6.0])
214
+ if not isinstance(value, (list, tuple, np.ndarray)):
215
+ return np.array([value, value])
196
216
  if isinstance(value, (list, tuple)):
197
217
  if len(value) == 0:
198
- return [12.0, 12.0]
199
- if len(value) == 1:
200
- return [float(value[0]), float(value[0])]
201
- return [float(value[0]), float(value[1])]
202
- return [float(value), float(value)]
218
+ return np.array([6.0, 6.0])
219
+ value = np.array(value)
220
+ if value.size == 1:
221
+ v = value.item()
222
+ return np.array([v, v])
223
+ return value
203
224
 
204
225
 
205
226
  def _ensure_do_registration_int(value: Any) -> int:
@@ -255,7 +276,7 @@ def ops_to_db_settings(ops: dict) -> tuple[dict, dict]:
255
276
  for key in _SETTINGS_TOP_LEVEL:
256
277
  if key in lookup:
257
278
  if key == "diameter":
258
- settings[key] = _ensure_diameter_list(lookup[key])
279
+ settings[key] = _ensure_diameter_array(lookup[key])
259
280
  else:
260
281
  settings[key] = lookup[key]
261
282
 
@@ -319,6 +340,33 @@ def ops_to_db_settings(ops: dict) -> tuple[dict, dict]:
319
340
  for key in _DETECTION_CELLPOSE_KEYS:
320
341
  if key in lookup:
321
342
  cellpose[key] = lookup[key]
343
+
344
+ # fork's anatomical_only int picks which image cellpose runs on.
345
+ # upstream encodes this via cellpose_settings.img; without this
346
+ # mapping anatomical_only=4 silently degrades to anatomical_only=1
347
+ # (the upstream default 'max_proj / meanImg' = log ratio image)
348
+ # because the translator only sets algorithm='cellpose' otherwise.
349
+ anat = lookup.get("anatomical_only")
350
+ if anat and "img" not in cellpose:
351
+ try:
352
+ anat_int = int(anat)
353
+ except (TypeError, ValueError):
354
+ anat_int = None
355
+ if anat_int in _ANATOMICAL_ONLY_TO_IMG:
356
+ cellpose["img"] = _ANATOMICAL_ONLY_TO_IMG[anat_int]
357
+ elif anat_int == 3:
358
+ # upstream removed enhanced_mean_img — closest fallback is
359
+ # 'meanImg' (same source family, no median-ratio enhancement).
360
+ # warn the caller so they know something changed.
361
+ import warnings
362
+ warnings.warn(
363
+ "anatomical_only=3 (enhanced_mean_img) is no longer supported "
364
+ "upstream; falling back to cellpose_settings.img='meanImg'. "
365
+ "Use anatomical_only in {1, 2, 4} to avoid this fallback.",
366
+ stacklevel=2,
367
+ )
368
+ cellpose["img"] = "max_proj"
369
+
322
370
  if cellpose:
323
371
  detection["cellpose_settings"] = cellpose
324
372
 
@@ -348,16 +396,9 @@ def db_settings_to_ops(db: dict | None, settings: dict | None) -> dict:
348
396
  if key in settings:
349
397
  value = settings[key]
350
398
  if key == "diameter":
351
- # fork's run_plane_bin / cellpose path / rigid reg assume a
352
- # scalar diameter (e.g. `np.isnan(ops["diameter"])`), but
353
- # upstream stores it as [dy, dx]. Collapse to a single
354
- # value for fork consumers — use dy if equal, else the
355
- # mean rounded to 1 decimal.
356
- if isinstance(value, (list, tuple)) and len(value) >= 1:
357
- if len(value) >= 2 and float(value[0]) != float(value[1]):
358
- value = round((float(value[0]) + float(value[1])) / 2, 1)
359
- else:
360
- value = float(value[0])
399
+ # keep upstream's [dy, dx] np.ndarray shape fork consumers
400
+ # that need a scalar should use np.mean / [0] explicitly.
401
+ value = _ensure_diameter_array(value)
361
402
  ops[key] = value
362
403
 
363
404
  # flat sections
@@ -119,6 +119,14 @@ def s2p_ops():
119
119
  1.0, # adjust the automatically determined threshold by this scalar multiplier
120
120
  "max_overlap":
121
121
  0.75, # cells with more overlap than this get removed during triage, before refinement
122
+ # disable upstream's npix_norm filter (suite2p>=1.0.0.x) — its
123
+ # detect.py call site applies 0.0/100.0 if these aren't set, which
124
+ # culls 100x-median-sized ROIs. suite2p_mbo had no such filter and
125
+ # LBM cells at diameter=4 trip the upper bound when many small
126
+ # cells skew the median low. -1 / inf mirrors roi_stats's own
127
+ # function-level defaults (effectively off).
128
+ "npix_norm_min": -1.0,
129
+ "npix_norm_max": float("inf"),
122
130
  "high_pass":
123
131
  100, # running mean subtraction across bins with a window of size "high_pass" (use low values for 1P)
124
132
  "spatial_hp_detect":
@@ -130,7 +138,7 @@ def s2p_ops():
130
138
  3,
131
139
  # run cellpose to get masks on 1: max_proj / mean_img; 2: mean_img; 3: mean_img enhanced, 4: max_proj
132
140
  "diameter": 4, # LBM default cell diameter in pixels (cellpose estimates if 0)
133
- "cellprob_threshold": 0, # cellprob_threshold for cellpose (0 = upstream default)
141
+ "cellprob_threshold": -6, # permissive LBM default for cellpose (upstream is 0.0)
134
142
  "flow_threshold": 0, # flow_threshold for cellpose (0 = flow-check disabled)
135
143
  "spatial_hp_cp": 0.5, # high-pass image spatially by a multiple of the diameter
136
144
  # cellpose model: 'cpsam' (default) or path to custom model trained with lsp.train_cellpose()
@@ -176,7 +184,7 @@ def default_ops(metadata=None, ops=None):
176
184
  anatomical_only=3
177
185
  diameter=4
178
186
  spatial_hp_cp=0.5
179
- cellprob_threshold=0
187
+ cellprob_threshold=-6
180
188
  flow_threshold=0
181
189
  spatial_scale=1
182
190
  tau=1.3
@@ -166,7 +166,15 @@ def filter_by_diameter(
166
166
 
167
167
  radii = np.array([s["radius"] for s in stat])
168
168
  diameters_px = 2 * radii
169
- median_diam = ops.get("diameter", np.median(diameters_px))
169
+ # ops["diameter"] is np.ndarray([dy, dx]) post-pipeline; collapse to scalar
170
+ # for the multiplier comparison below.
171
+ raw_diam = ops.get("diameter")
172
+ if raw_diam is None:
173
+ median_diam = float(np.median(diameters_px))
174
+ elif isinstance(raw_diam, (list, tuple, np.ndarray)) and np.size(raw_diam) >= 1:
175
+ median_diam = float(np.mean(raw_diam))
176
+ else:
177
+ median_diam = float(raw_diam)
170
178
  lower, upper = min_mult * median_diam, max_mult * median_diam
171
179
 
172
180
  valid = (diameters_px >= lower) & (diameters_px <= upper)
@@ -99,13 +99,10 @@ def _call_upstream_pipeline(ops, f_reg, f_raw, f_reg_chan2, f_raw_chan2,
99
99
  for k, v in reg_outputs.items():
100
100
  ops[k] = v
101
101
  if isinstance(detect_outputs, dict):
102
+ from lbm_suite2p_python.db_settings import _ensure_diameter_array
102
103
  for k, v in detect_outputs.items():
103
- # don't clobber diameter — keep whatever was passed in unless upstream
104
- # actually refined it (and collapse list to scalar for fork consumers)
105
104
  if k == "diameter" and v is not None:
106
- if isinstance(v, (list, tuple)) and len(v) >= 1:
107
- v = float(v[0]) if len(v) < 2 or v[0] == v[1] else (float(v[0]) + float(v[1])) / 2
108
- ops[k] = v
105
+ ops[k] = _ensure_diameter_array(v)
109
106
  elif k != "diameter":
110
107
  ops[k] = v
111
108
  ops["plane_times"] = plane_times
@@ -420,6 +417,7 @@ from lbm_suite2p_python.zplane import (
420
417
  plot_filtered_cells,
421
418
  plot_filter_exclusions,
422
419
  plot_cell_filter_summary,
420
+ plot_volume_accepted_rejected_overlay,
423
421
  )
424
422
 
425
423
  DEFAULT_CELL_FILTERS = []
@@ -432,6 +430,43 @@ from mbo_utilities.metadata import (
432
430
 
433
431
  logger = get_logger("run_lsp")
434
432
 
433
+
434
+ _external_logging_attached = False
435
+
436
+
437
+ def _attach_external_loggers(level: int = logging.INFO) -> None:
438
+ """Surface suite2p / cellpose progress messages on stdout.
439
+
440
+ Mainline suite2p (`suite2p.detection.anatomical`,
441
+ `suite2p.registration.*`) and cellpose (`cellpose.models`) use plain
442
+ `logging.getLogger(__name__)` loggers with no handlers attached. Their
443
+ `logger.info(...)` calls — registration progress, ">>>> CELLPOSE finding
444
+ masks", median-diameter reports — propagate to root and get silently
445
+ dropped by the default WARNING-level lastResort handler. Without this
446
+ hookup the user can't tell whether detection is running or what params
447
+ cellpose actually picked. Idempotent, called once per pipeline entry.
448
+ """
449
+ global _external_logging_attached
450
+ if _external_logging_attached:
451
+ return
452
+ fmt = logging.Formatter("%(name)s: %(message)s")
453
+ for name in ("suite2p", "cellpose"):
454
+ lg = logging.getLogger(name)
455
+ if lg.level == logging.NOTSET or lg.level > level:
456
+ lg.setLevel(level)
457
+ has_stream = any(
458
+ isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler)
459
+ for h in lg.handlers
460
+ )
461
+ if not has_stream:
462
+ h = logging.StreamHandler()
463
+ h.setFormatter(fmt)
464
+ h.setLevel(level)
465
+ lg.addHandler(h)
466
+ lg.propagate = False
467
+ _external_logging_attached = True
468
+
469
+
435
470
  from lbm_suite2p_python._benchmarking import get_cpu_percent, get_ram_used
436
471
  from lbm_suite2p_python.volume import (
437
472
  plot_volume_diagnostics,
@@ -644,6 +679,8 @@ def pipeline(
644
679
  from mbo_utilities import imread
645
680
  from mbo_utilities.arrays import supports_roi
646
681
 
682
+ _attach_external_loggers()
683
+
647
684
  # 1. Handle Deprecations
648
685
  if roi is not None:
649
686
  import warnings
@@ -1204,6 +1241,9 @@ def run_volume(
1204
1241
  plot_3d_roi_map(
1205
1242
  ops_files, save_path / "roi_map_3d_plane.png", color_by="plane"
1206
1243
  )
1244
+ plot_volume_accepted_rejected_overlay(
1245
+ ops_files, save_path / "volume_segmentation_overlay.png"
1246
+ )
1207
1247
  except Exception as e:
1208
1248
  print(f"Warning: Volume plots failed: {e}")
1209
1249
  traceback.print_exc()
@@ -1445,12 +1485,31 @@ def run_plane_bin(ops) -> bool:
1445
1485
  n_func = ops.get("nframes_chan1") or ops.get("nframes") or ops.get("n_frames")
1446
1486
  if n_func is None:
1447
1487
  raise KeyError("Missing nframes_chan1 / nframes / n_frames in ops")
1488
+
1489
+ # Graceful fallback for "reload a completed plane" workflow:
1490
+ # - keep_raw=False (default) deletes data_raw.bin after a run.
1491
+ # - When the user reloads that plane_dir and clicks Run again, the
1492
+ # GUI hands us an ops where raw_file is missing but reg_file (the
1493
+ # registered data.bin) is present. Re-registration needs raw data,
1494
+ # but detection/extraction can still run against data.bin.
1495
+ # - Instead of crashing, downgrade to detection-only and log why.
1448
1496
  if run_registration and raw_file is None:
1449
- raise KeyError(
1450
- "Missing raw_file in ops — required when do_registration=1. "
1451
- "Set do_registration=0 to run detection-only against an "
1452
- "existing data.bin."
1453
- )
1497
+ _existing_reg = ops.get("reg_file")
1498
+ if _existing_reg and Path(_existing_reg).exists():
1499
+ print(
1500
+ "NOTE: raw_file missing but reg_file exists — downgrading "
1501
+ "do_registration=1 → 0 (detection/extraction only against "
1502
+ f"{Path(_existing_reg).name}). Pass keep_raw=True on the "
1503
+ "first run if you want to re-register a reloaded plane."
1504
+ )
1505
+ ops["do_registration"] = 0
1506
+ run_registration = False
1507
+ else:
1508
+ raise KeyError(
1509
+ "Missing raw_file in ops — required when do_registration=1. "
1510
+ "Set do_registration=0 to run detection-only against an "
1511
+ "existing data.bin."
1512
+ )
1454
1513
  n_func = int(n_func)
1455
1514
 
1456
1515
  # reg_file may already point at a linked source binary; only pin it
@@ -1488,16 +1547,12 @@ def run_plane_bin(ops) -> bool:
1488
1547
  ops["nframes_chan2"] = n_align
1489
1548
 
1490
1549
  if "diameter" in ops:
1491
- # save user's input diameter before suite2p/cellpose overwrites it
1492
- # cellpose estimates actual cell diameters and saves median to ops["diameter"]
1550
+ # save user's input diameter for provenance — cellpose later overwrites
1551
+ # ops["diameter"] with the estimated median from detection.
1552
+ # do not default/coerce here: db_settings._ensure_diameter_list
1553
+ # converts to [dy, dx] at the upstream-pipeline boundary, and mainline
1554
+ # suite2p / cellpose handle None / scalar / list / aspect-pair natively.
1493
1555
  ops["diameter_user"] = ops["diameter"]
1494
- if ops["diameter"] is not None and np.isnan(ops["diameter"]):
1495
- ops["diameter"] = 8
1496
- ops["diameter_user"] = 8
1497
- if (ops["diameter"] in (None, 0)) and ops.get("anatomical_only", 0) > 0:
1498
- ops["diameter"] = 8
1499
- ops["diameter_user"] = 8
1500
- print("Warning: diameter was not set, defaulting to 8.")
1501
1556
 
1502
1557
  # reset detection-derived parameters when re-running registration or detection
1503
1558
  # so compute_enhanced_mean_image() reinitializes them from diameter.
@@ -1506,8 +1561,16 @@ def run_plane_bin(ops) -> bool:
1506
1561
  # (run_registration / run_detection were resolved earlier, near the
1507
1562
  # raw_file check — keep them in scope here.)
1508
1563
  if run_registration:
1509
- # full reset: clear both registration and detection intermediates
1510
- for key in ["spatscale_pix", "Vcorr", "Vmax", "Vmap", "Vsplit", "ihop"]:
1564
+ # full reset: clear both registration and detection intermediates.
1565
+ # registration outputs (badframes/xoff/yoff/corrXY) are cleared too
1566
+ # otherwise a prior divergent run leaves badframes=True for most
1567
+ # frames and detection silently excludes them on the rerun even
1568
+ # though the new registration succeeded.
1569
+ for key in [
1570
+ "spatscale_pix", "Vcorr", "Vmax", "Vmap", "Vsplit", "ihop",
1571
+ "badframes", "xoff", "yoff", "corrXY",
1572
+ "xoff1", "yoff1", "corrXY1",
1573
+ ]:
1511
1574
  if key in ops:
1512
1575
  del ops[key]
1513
1576
  elif run_detection:
@@ -2102,10 +2165,29 @@ def run_plane(
2102
2165
  "data_path": str(input_path.resolve()),
2103
2166
  }
2104
2167
 
2168
+ # auto-resolve source_mode based on user intent + disk state:
2169
+ #
2170
+ # - explicit `do_registration=0` from the user means "I'm reusing
2171
+ # the registered binary, just sweeping detection/extraction".
2172
+ # the safe + cheap mode here is `link` — zero copies, source
2173
+ # binary is read-only, and N sweeps over the same plane don't
2174
+ # produce N redundant copies of data.bin.
2175
+ # - otherwise fall back to the original disk-based heuristic:
2176
+ # `copy` if raw exists (registration can proceed locally), else
2177
+ # `copy_reg` (no raw on disk, raw_file gets popped, downstream
2178
+ # gracefully downgrades to detection-only).
2179
+ _effective_mode = source_mode
2180
+ if _effective_mode == "auto":
2181
+ if ops_user.get("do_registration", 1) == 0:
2182
+ _effective_mode = "link"
2183
+ else:
2184
+ _effective_mode = (
2185
+ "copy" if (src_dir / "data_raw.bin").exists() else "copy_reg"
2186
+ )
2187
+
2105
2188
  # link mode can't safely co-exist with do_registration=1 because
2106
2189
  # registration would need to write the source data.bin. flip to
2107
2190
  # copy_reg and warn instead of corrupting the source binary.
2108
- _effective_mode = source_mode
2109
2191
  if _effective_mode == "link" and ops_user.get("do_registration", 1):
2110
2192
  logger.warning(
2111
2193
  "source_mode='link' requires do_registration=0 (link mode "
@@ -1068,13 +1068,13 @@ def plot_orthoslices(
1068
1068
  save_path: str | Path = None,
1069
1069
  figsize: tuple = (16, 6),
1070
1070
  use_mean: bool = True,
1071
+ interpolate: bool = False,
1071
1072
  ) -> plt.Figure:
1072
1073
  """
1073
1074
  Generate orthogonal maximum intensity projections (XY, XZ, YZ) of the volume.
1074
1075
 
1075
- Creates a 3-panel figure showing the volume from three orthogonal views,
1076
- with proper interpolation to isotropic resolution. Axes are displayed
1077
- in micrometers using voxel size metadata.
1076
+ Creates a 3-panel figure showing the volume from three orthogonal views.
1077
+ Axes are displayed in micrometers using voxel size metadata.
1078
1078
 
1079
1079
  Parameters
1080
1080
  ----------
@@ -1086,13 +1086,17 @@ def plot_orthoslices(
1086
1086
  Figure size in inches.
1087
1087
  use_mean : bool, default True
1088
1088
  If True, use meanImg. If False, use refImg (registered reference).
1089
+ interpolate : bool, default False
1090
+ If True, resample the volume in Z to isotropic resolution and use
1091
+ bilinear interpolation in the XZ/YZ panels. Off by default since
1092
+ LBM volumes are typically very thin in Z (e.g. 14 planes), where
1093
+ interpolation can be misleading.
1089
1094
 
1090
1095
  Returns
1091
1096
  -------
1092
1097
  fig : matplotlib.figure.Figure
1093
1098
  The generated figure object.
1094
1099
  """
1095
- from scipy.ndimage import zoom
1096
1100
  from lbm_suite2p_python.postprocessing import load_ops
1097
1101
 
1098
1102
  if not ops_files:
@@ -1159,15 +1163,21 @@ def plot_orthoslices(
1159
1163
  vol_y_um = ny * dy_um
1160
1164
  vol_z_um = (nz - 1) * dz_um if nz > 1 else dz_um
1161
1165
 
1162
- # interpolate volume to isotropic resolution for proper orthoslices
1163
- xy_res = (dx_um + dy_um) / 2
1164
- z_zoom = dz_um / xy_res if xy_res > 0 else 1.0
1165
- z_zoom = min(z_zoom, 10.0) # cap to avoid memory issues
1166
- if z_zoom > 1.1:
1167
- volume_resampled = zoom(volume, (z_zoom, 1, 1), order=1)
1166
+ # optionally resample volume in z to isotropic resolution
1167
+ if interpolate:
1168
+ from scipy.ndimage import zoom
1169
+ xy_res = (dx_um + dy_um) / 2
1170
+ z_zoom = dz_um / xy_res if xy_res > 0 else 1.0
1171
+ z_zoom = min(z_zoom, 10.0) # cap to avoid memory issues
1172
+ if z_zoom > 1.1:
1173
+ volume_resampled = zoom(volume, (z_zoom, 1, 1), order=1)
1174
+ else:
1175
+ volume_resampled = volume
1168
1176
  else:
1169
1177
  volume_resampled = volume
1170
1178
 
1179
+ imshow_interp = "bilinear" if interpolate else "nearest"
1180
+
1171
1181
  # compute projections
1172
1182
  xy_proj = np.max(volume, axis=0)
1173
1183
  xz_proj = np.max(volume_resampled, axis=1)
@@ -1199,7 +1209,7 @@ def plot_orthoslices(
1199
1209
  ax2.set_facecolor("black")
1200
1210
  im2 = ax2.imshow(xz_proj, cmap="magma", aspect="auto", extent=xz_extent,
1201
1211
  vmin=np.percentile(xz_proj, 1), vmax=np.percentile(xz_proj, 99.5),
1202
- interpolation="bilinear")
1212
+ interpolation=imshow_interp)
1203
1213
  ax2.set_xlabel("X (μm)", fontsize=10, fontweight="bold", color="white")
1204
1214
  ax2.set_ylabel("Z (μm)", fontsize=10, fontweight="bold", color="white")
1205
1215
  ax2.set_title("XZ Projection", fontsize=11, fontweight="bold", color="white")
@@ -1212,7 +1222,7 @@ def plot_orthoslices(
1212
1222
  ax3.set_facecolor("black")
1213
1223
  im3 = ax3.imshow(yz_proj.T, cmap="magma", aspect="auto", extent=yz_extent,
1214
1224
  vmin=np.percentile(yz_proj, 1), vmax=np.percentile(yz_proj, 99.5),
1215
- interpolation="bilinear")
1225
+ interpolation=imshow_interp)
1216
1226
  ax3.set_xlabel("Z (μm)", fontsize=10, fontweight="bold", color="white")
1217
1227
  ax3.set_ylabel("Y (μm)", fontsize=10, fontweight="bold", color="white")
1218
1228
  ax3.set_title("YZ Projection", fontsize=11, fontweight="bold", color="white")
@@ -946,6 +946,339 @@ def plot_masks(
946
946
  plt.show()
947
947
 
948
948
 
949
+ def _build_accepted_rejected_canvas(
950
+ img: np.ndarray,
951
+ stat,
952
+ iscell_mask: np.ndarray,
953
+ *,
954
+ ops: dict = None,
955
+ proj_key: str = None,
956
+ accepted_color=(0.0, 1.0, 0.0),
957
+ rejected_color=(1.0, 0.0, 0.0),
958
+ ):
959
+ """
960
+ Build an RGB canvas of a projection with accepted/rejected ROIs blended
961
+ on top using suite2p ``lam`` weights — the same per-pixel opacity used
962
+ by :func:`plot_masks`.
963
+
964
+ Returns
965
+ -------
966
+ canvas : ndarray (Ly, Lx, 3)
967
+ n_accepted, n_rejected : int
968
+ """
969
+ stat_yoff = 0
970
+ stat_xoff = 0
971
+ if ops is not None and proj_key is not None:
972
+ img, stat_yoff, stat_xoff = _crop_projection_to_valid(ops, img, proj_key)
973
+
974
+ vmin = np.nanpercentile(img, 1)
975
+ vmax = np.nanpercentile(img, 99)
976
+ normalized = (img - vmin) / (vmax - vmin + 1e-6)
977
+ normalized = np.clip(normalized, 0, 1)
978
+ normalized = np.nan_to_num(normalized, nan=0.0)
979
+ canvas = np.tile(normalized, (3, 1, 1)).transpose(1, 2, 0).astype(np.float32)
980
+
981
+ Ly, Lx = img.shape[:2]
982
+ iscell_mask = np.asarray(iscell_mask, dtype=bool)
983
+
984
+ accepted_color = np.asarray(accepted_color, dtype=np.float32)
985
+ rejected_color = np.asarray(rejected_color, dtype=np.float32)
986
+
987
+ for n, s in enumerate(stat):
988
+ ypix = np.asarray(s.get("ypix", []), dtype=int) - stat_yoff
989
+ xpix = np.asarray(s.get("xpix", []), dtype=int) - stat_xoff
990
+ lam = np.asarray(s.get("lam", []), dtype=np.float32)
991
+ if ypix.size == 0:
992
+ continue
993
+ valid = (ypix >= 0) & (ypix < Ly) & (xpix >= 0) & (xpix < Lx)
994
+ if not np.any(valid):
995
+ continue
996
+ ypix, xpix, lam = ypix[valid], xpix[valid], lam[valid]
997
+ lam = lam / (lam.max() + 1e-10)
998
+ col = accepted_color if iscell_mask[n] else rejected_color
999
+ for k in range(3):
1000
+ canvas[ypix, xpix, k] = (
1001
+ 0.5 * canvas[ypix, xpix, k] + 0.5 * col[k] * lam
1002
+ )
1003
+
1004
+ n_accepted = int(iscell_mask.sum())
1005
+ n_rejected = int((~iscell_mask).sum())
1006
+ return canvas, n_accepted, n_rejected
1007
+
1008
+
1009
+ def plot_accepted_rejected_overlay(
1010
+ img: np.ndarray,
1011
+ stat,
1012
+ iscell_mask: np.ndarray,
1013
+ savepath: str | Path = None,
1014
+ title: str = None,
1015
+ *,
1016
+ ops: dict = None,
1017
+ proj_key: str = None,
1018
+ figsize: tuple = (6, 6),
1019
+ dpi: int = 300,
1020
+ ):
1021
+ """
1022
+ Draw accepted (green) and rejected (red) ROI overlays on a projection.
1023
+
1024
+ Uses the same lam-weighted per-pixel opacity as :func:`plot_masks` so
1025
+ feathering matches the rest of the segmentation figures. Adds dark
1026
+ formatting and ``Accepted``/``Rejected`` count labels at the top.
1027
+
1028
+ Parameters
1029
+ ----------
1030
+ img : ndarray (Ly x Lx)
1031
+ Background projection image.
1032
+ stat : list[dict]
1033
+ Suite2p ROI stat dictionaries (full-frame coordinates).
1034
+ iscell_mask : ndarray[bool]
1035
+ Boolean array, True for accepted ROIs.
1036
+ savepath : str or Path, optional
1037
+ Path to save the figure. If None, displays with plt.show().
1038
+ title : str, optional
1039
+ Projection-name title rendered above the count labels.
1040
+ ops : dict, optional
1041
+ Suite2p ops dictionary. With ``proj_key``, the image is cropped to
1042
+ the valid (non-padded) region and stat coordinates are translated
1043
+ accordingly.
1044
+ proj_key : str, optional
1045
+ Projection key matching ``img`` (e.g. ``"meanImg"``, ``"max_proj"``).
1046
+ figsize : tuple, optional
1047
+ Figure size. Default (6, 6) to match other segmentation figures.
1048
+ dpi : int, optional
1049
+ Output DPI. Default 300.
1050
+ """
1051
+ canvas, n_accepted, n_rejected = _build_accepted_rejected_canvas(
1052
+ img, stat, iscell_mask, ops=ops, proj_key=proj_key,
1053
+ )
1054
+
1055
+ fig, ax = plt.subplots(figsize=figsize, facecolor="black")
1056
+ ax.set_facecolor("black")
1057
+ ax.imshow(canvas, interpolation="nearest")
1058
+
1059
+ label_y = 1.02
1060
+ if title:
1061
+ ax.text(
1062
+ 0.5, 1.02, title,
1063
+ transform=ax.transAxes,
1064
+ fontsize=12, fontweight="bold", fontname="Courier New",
1065
+ color="white", ha="center", va="bottom",
1066
+ )
1067
+ label_y = 1.10
1068
+ ax.text(
1069
+ 0.37, label_y,
1070
+ f"Accepted: {n_accepted:03d}",
1071
+ transform=ax.transAxes,
1072
+ fontsize=14, fontweight="bold", fontname="Courier New",
1073
+ color="lime", ha="right", va="bottom",
1074
+ )
1075
+ ax.text(
1076
+ 0.63, label_y,
1077
+ f"Rejected: {n_rejected:03d}",
1078
+ transform=ax.transAxes,
1079
+ fontsize=14, fontweight="bold", fontname="Courier New",
1080
+ color="red", ha="left", va="bottom",
1081
+ )
1082
+ ax.set_xticks([])
1083
+ ax.set_yticks([])
1084
+ ax.axis("off")
1085
+ plt.tight_layout()
1086
+
1087
+ if savepath:
1088
+ if Path(savepath).is_dir():
1089
+ raise ValueError("savepath must be a file path, not a directory.")
1090
+ plt.savefig(savepath, dpi=dpi, facecolor="black")
1091
+ plt.close(fig)
1092
+ else:
1093
+ plt.show()
1094
+
1095
+
1096
+ def plot_volume_accepted_rejected_overlay(
1097
+ ops_files,
1098
+ savepath: str | Path = None,
1099
+ *,
1100
+ ncols: int = None,
1101
+ figsize: tuple = None,
1102
+ dpi: int = 200,
1103
+ ):
1104
+ """
1105
+ Volumetric version of :func:`plot_accepted_rejected_overlay`.
1106
+
1107
+ One subplot per z-plane showing the projection used by cellpose with
1108
+ accepted ROIs in green and rejected in red. Uses the same lam-weighted
1109
+ opacity as :func:`plot_masks`. Per-subplot titles show the plane's
1110
+ ``Accepted: N`` (lime) and ``Rejected: N`` (red) counts; an overall
1111
+ title at the very top reports total accepted/rejected across the volume.
1112
+
1113
+ Parameters
1114
+ ----------
1115
+ ops_files : list of str or Path
1116
+ Paths to per-plane ``ops.npy`` files.
1117
+ savepath : str or Path, optional
1118
+ Where to save the figure. If None, calls plt.show().
1119
+ ncols : int, optional
1120
+ Number of columns in the subplot grid. Defaults to a near-square
1121
+ layout.
1122
+ figsize : tuple, optional
1123
+ Figure size. Defaults based on the grid shape.
1124
+ dpi : int, optional
1125
+ Output DPI. Default 200.
1126
+ """
1127
+ ops_files = [Path(p) for p in ops_files]
1128
+ n_planes = len(ops_files)
1129
+ if n_planes == 0:
1130
+ raise ValueError("ops_files is empty")
1131
+
1132
+ if ncols is None:
1133
+ ncols = int(np.ceil(np.sqrt(n_planes)))
1134
+ nrows = int(np.ceil(n_planes / ncols))
1135
+ if figsize is None:
1136
+ figsize = (4 * ncols, 4.4 * nrows)
1137
+
1138
+ proj_lookup = {
1139
+ 0: ("Vcorr", "Correlation Image"),
1140
+ 1: ("max_proj", "Max Projection"),
1141
+ 2: ("meanImg", "Mean Image"),
1142
+ 3: ("meanImgE", "Enhanced Mean Image"),
1143
+ 4: ("max_proj", "Max Projection"),
1144
+ }
1145
+ fallback_titles = {
1146
+ "meanImg": "Mean Image",
1147
+ "max_proj": "Max Projection",
1148
+ "meanImgE": "Enhanced Mean Image",
1149
+ "Vcorr": "Correlation Image",
1150
+ }
1151
+
1152
+ def _is_valid_image(im):
1153
+ if im is None:
1154
+ return False
1155
+ if isinstance(im, (int, float)) and im == 0:
1156
+ return False
1157
+ if isinstance(im, np.ndarray) and im.size == 0:
1158
+ return False
1159
+ return True
1160
+
1161
+ def _plane_num(ops, fallback):
1162
+ raw = ops.get("plane", None)
1163
+ if raw is None:
1164
+ return fallback
1165
+ if isinstance(raw, (int, np.integer)):
1166
+ return int(raw)
1167
+ digits = "".join(c for c in str(raw) if c.isdigit())
1168
+ return int(digits) if digits else fallback
1169
+
1170
+ plane_entries = []
1171
+ for i, ops_file in enumerate(ops_files):
1172
+ try:
1173
+ ops = load_ops(ops_file)
1174
+ except Exception as e:
1175
+ print(f" Warning: could not load {ops_file}: {e}")
1176
+ continue
1177
+ try:
1178
+ res = load_planar_results(ops)
1179
+ except Exception as e:
1180
+ print(f" Warning: could not load planar results for {ops_file}: {e}")
1181
+ continue
1182
+
1183
+ stat = res["stat"]
1184
+ iscell_mask = res["iscell"][:, 0].astype(bool)
1185
+
1186
+ anatomical_only = int(ops.get("anatomical_only", 0) or 0)
1187
+ proj_key, proj_title = proj_lookup.get(anatomical_only, ("meanImg", "Mean Image"))
1188
+ img = ops.get(proj_key)
1189
+ if not _is_valid_image(img):
1190
+ for fk in ("meanImg", "max_proj", "meanImgE", "Vcorr"):
1191
+ if _is_valid_image(ops.get(fk)):
1192
+ proj_key = fk
1193
+ proj_title = fallback_titles[fk]
1194
+ img = ops.get(fk)
1195
+ break
1196
+ if not _is_valid_image(img):
1197
+ continue
1198
+
1199
+ plane_entries.append({
1200
+ "plane": _plane_num(ops, i),
1201
+ "ops": ops,
1202
+ "img": img,
1203
+ "stat": stat,
1204
+ "iscell_mask": iscell_mask,
1205
+ "proj_key": proj_key,
1206
+ "proj_title": proj_title,
1207
+ })
1208
+
1209
+ if not plane_entries:
1210
+ raise RuntimeError("No valid plane data found in ops_files")
1211
+
1212
+ plane_entries.sort(key=lambda e: e["plane"])
1213
+
1214
+ fig, axes = plt.subplots(nrows, ncols, figsize=figsize, facecolor="black")
1215
+ axes = np.atleast_1d(axes).ravel()
1216
+
1217
+ total_accepted = 0
1218
+ total_rejected = 0
1219
+
1220
+ for idx, ax in enumerate(axes):
1221
+ ax.set_facecolor("black")
1222
+ if idx >= len(plane_entries):
1223
+ ax.axis("off")
1224
+ continue
1225
+ e = plane_entries[idx]
1226
+ canvas, n_acc, n_rej = _build_accepted_rejected_canvas(
1227
+ e["img"], e["stat"], e["iscell_mask"],
1228
+ ops=e["ops"], proj_key=e["proj_key"],
1229
+ )
1230
+ total_accepted += n_acc
1231
+ total_rejected += n_rej
1232
+
1233
+ ax.imshow(canvas, interpolation="nearest")
1234
+ ax.set_xticks([])
1235
+ ax.set_yticks([])
1236
+ for spine in ax.spines.values():
1237
+ spine.set_visible(False)
1238
+
1239
+ ax.text(
1240
+ 0.02, 1.10, f"plane {e['plane']:02d}",
1241
+ transform=ax.transAxes,
1242
+ fontsize=10, fontweight="bold", fontname="Courier New",
1243
+ color="white", ha="left", va="bottom",
1244
+ )
1245
+ ax.text(
1246
+ 0.48, 1.10, f"Accepted: {n_acc:03d}",
1247
+ transform=ax.transAxes,
1248
+ fontsize=10, fontweight="bold", fontname="Courier New",
1249
+ color="lime", ha="right", va="bottom",
1250
+ )
1251
+ ax.text(
1252
+ 0.52, 1.10, f"Rejected: {n_rej:03d}",
1253
+ transform=ax.transAxes,
1254
+ fontsize=10, fontweight="bold", fontname="Courier New",
1255
+ color="red", ha="left", va="bottom",
1256
+ )
1257
+
1258
+ fig.text(
1259
+ 0.49, 0.985,
1260
+ f"Total Accepted: {total_accepted:04d}",
1261
+ color="lime", fontsize=16, fontweight="bold", fontname="Courier New",
1262
+ ha="right", va="top",
1263
+ )
1264
+ fig.text(
1265
+ 0.51, 0.985,
1266
+ f"Total Rejected: {total_rejected:04d}",
1267
+ color="red", fontsize=16, fontweight="bold", fontname="Courier New",
1268
+ ha="left", va="top",
1269
+ )
1270
+
1271
+ plt.tight_layout(rect=(0, 0, 1, 0.96))
1272
+
1273
+ if savepath:
1274
+ if Path(savepath).is_dir():
1275
+ raise ValueError("savepath must be a file path, not a directory.")
1276
+ plt.savefig(savepath, dpi=dpi, facecolor="black")
1277
+ plt.close(fig)
1278
+ else:
1279
+ plt.show()
1280
+
1281
+
949
1282
  def plot_projection(
950
1283
  ops,
951
1284
  output_directory=None,
@@ -2873,6 +3206,8 @@ def plot_zplane_figures(
2873
3206
  "meanImg_segmentation": plane_dir / "03_mean_segmentation.png",
2874
3207
  "meanImgE": plane_dir / "04_mean_enhanced.png",
2875
3208
  "meanImgE_segmentation": plane_dir / "04_mean_enhanced_segmentation.png",
3209
+ # rejected-cell overlay on the projection used for cellpose detection
3210
+ "rejected_segmentation": plane_dir / "04b_rejected_segmentation.png",
2876
3211
  # Diagnostics and analysis
2877
3212
  "quality_diagnostics": plane_dir / "05_quality_diagnostics.png",
2878
3213
  "registration": plane_dir / "06_registration.png",
@@ -2910,6 +3245,7 @@ def plot_zplane_figures(
2910
3245
  "meanImg_segmentation",
2911
3246
  "meanImgE",
2912
3247
  "meanImgE_segmentation",
3248
+ "rejected_segmentation",
2913
3249
  "quality_diagnostics",
2914
3250
  "registration",
2915
3251
  "traces_raw_20",
@@ -3004,6 +3340,52 @@ def plot_zplane_figures(
3004
3340
  except Exception as e:
3005
3341
  print(f" Warning: {img_key} segmentation failed: {e}")
3006
3342
 
3343
+ # rejected-cell overlay on the projection actually used for cellpose
3344
+ # detection. anatomical_only mapping (from default_ops.py):
3345
+ # 0 -> Vcorr (functional sparse mode)
3346
+ # 1 -> max_proj / meanImg (combined; we display max_proj as the
3347
+ # closest visualizable proxy since the ratio isn't stored)
3348
+ # 2 -> meanImg
3349
+ # 3 -> meanImgE
3350
+ # 4 -> max_proj
3351
+ try:
3352
+ if n_rejected > 0:
3353
+ anatomical_only = int(output_ops.get("anatomical_only", 0) or 0)
3354
+ proj_lookup = {
3355
+ 0: ("Vcorr", "Correlation Image"),
3356
+ 1: ("max_proj", "Max Projection (max_proj / meanImg)"),
3357
+ 2: ("meanImg", "Mean Image"),
3358
+ 3: ("meanImgE", "Enhanced Mean Image"),
3359
+ 4: ("max_proj", "Max Projection"),
3360
+ }
3361
+ rej_key, rej_title = proj_lookup.get(anatomical_only, ("meanImg", "Mean Image"))
3362
+ rej_img = output_ops.get(rej_key)
3363
+ # fallback chain if the chosen projection isn't available
3364
+ if not _is_valid_image(rej_img):
3365
+ for fk in ("meanImg", "max_proj", "meanImgE", "Vcorr"):
3366
+ if _is_valid_image(output_ops.get(fk)):
3367
+ rej_key = fk
3368
+ rej_title = {
3369
+ "meanImg": "Mean Image",
3370
+ "max_proj": "Max Projection",
3371
+ "meanImgE": "Enhanced Mean Image",
3372
+ "Vcorr": "Correlation Image",
3373
+ }[fk]
3374
+ rej_img = output_ops.get(fk)
3375
+ break
3376
+ if _is_valid_image(rej_img):
3377
+ plot_accepted_rejected_overlay(
3378
+ img=rej_img,
3379
+ stat=stat_full,
3380
+ iscell_mask=iscell_mask,
3381
+ savepath=expected_files["rejected_segmentation"],
3382
+ title=rej_title,
3383
+ ops=output_ops,
3384
+ proj_key=rej_key,
3385
+ )
3386
+ except Exception as e:
3387
+ print(f" Warning: rejected segmentation failed: {e}")
3388
+
3007
3389
  # correlation image (Vcorr) - cropped space. Render the no-mask
3008
3390
  # version through the cropping helper so it matches the mask
3009
3391
  # variant's extent.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lbm_suite2p_python
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: Light Beads Microscopy Pipeline using Suite2p
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: homepage, https://github.com/MillerBrainObservatory/LBM-Suite2p-Python
@@ -11,18 +11,15 @@ 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>=2.7.7
14
+ Requires-Dist: mbo_utilities>=3.0.0
15
15
  Requires-Dist: suite2p>=1.0.0.1
16
16
  Requires-Dist: setuptools<81
17
17
  Provides-Extra: rastermap
18
18
  Requires-Dist: rastermap; extra == "rastermap"
19
19
  Provides-Extra: cellpose
20
20
  Requires-Dist: cellpose>=4.0.6; extra == "cellpose"
21
- Provides-Extra: torch
22
- Requires-Dist: torch>=2.7.0; extra == "torch"
23
- Requires-Dist: torchvision>=0.22.0; extra == "torch"
24
21
  Provides-Extra: all
25
- Requires-Dist: lbm_suite2p_python[cellpose,rastermap,torch]; extra == "all"
22
+ Requires-Dist: lbm_suite2p_python[cellpose,rastermap]; extra == "all"
26
23
  Dynamic: license-file
27
24
 
28
25
  <p align="center">
@@ -0,0 +1,12 @@
1
+ mbo_utilities>=3.0.0
2
+ suite2p>=1.0.0.1
3
+ setuptools<81
4
+
5
+ [all]
6
+ lbm_suite2p_python[cellpose,rastermap]
7
+
8
+ [cellpose]
9
+ cellpose>=4.0.6
10
+
11
+ [rastermap]
12
+ rastermap
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lbm_suite2p_python"
7
- version = "3.0.0"
7
+ version = "3.0.1"
8
8
  description = "Light Beads Microscopy Pipeline using Suite2p"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -18,7 +18,7 @@ classifiers=[
18
18
  ]
19
19
 
20
20
  dependencies = [
21
- "mbo_utilities>=2.7.7",
21
+ "mbo_utilities>=3.0.0",
22
22
  "suite2p>=1.0.0.1",
23
23
  "setuptools<81",
24
24
  ]
@@ -35,14 +35,9 @@ rastermap = [
35
35
  cellpose = [
36
36
  "cellpose>=4.0.6",
37
37
  ]
38
- # PyTorch for neural network operations (Cellpose, etc.)
39
- torch = [
40
- "torch>=2.7.0",
41
- "torchvision>=0.22.0",
42
- ]
43
38
  # All optional dependencies
44
39
  all = [
45
- "lbm_suite2p_python[rastermap,cellpose,torch]",
40
+ "lbm_suite2p_python[rastermap,cellpose]",
46
41
  ]
47
42
 
48
43
  [dependency-groups]
@@ -70,7 +65,7 @@ docs = [
70
65
  "scikit-image",
71
66
  "scipy",
72
67
  "pandas",
73
- "suite2p_mbo",
68
+ "suite2p",
74
69
  ]
75
70
 
76
71
  # https://github.com/charliermarsh/ruff
@@ -1,16 +0,0 @@
1
- mbo_utilities>=2.7.7
2
- suite2p>=1.0.0.1
3
- setuptools<81
4
-
5
- [all]
6
- lbm_suite2p_python[cellpose,rastermap,torch]
7
-
8
- [cellpose]
9
- cellpose>=4.0.6
10
-
11
- [rastermap]
12
- rastermap
13
-
14
- [torch]
15
- torch>=2.7.0
16
- torchvision>=0.22.0