A growing list of known problems, their symptoms, and what to check. Add new entries when you diagnose something non-obvious. Keep entries short, lead with the symptom (that's how a future agent will search), and include file:line references where the failure mode lives.
Symptoms
- Extracted F traces drop to ≈ 0 (or strongly negative after Fneu subtraction) for a stretch of consecutive frames, then recover. Often population-wide, not per-cell.
- A burst of frames where
sub_pixel_shifts[:, 0](z) sits at exactly±(pc_size_z + 0.5)— the literal corner of the rigid search window. With defaultpc_size = (2, 40, 40)that's±2.5. - The post-registration log line "Saturation diagnostic: N/M
frames (P%) hit the z-axis search-window edge ±X.X" — emitted by
_log_rigid_saturation_diagnosticwhen any axis hits the edge.
Cause
Phase-correlation peak failure, not real drift. When the PC volume
has no clean maximum (low SNR, illumination dropout, sample
disturbance, or simply too few z planes to support a real 3D
correlation), est_sub_pixel_shift
(reg_3d.py:172) falls back to the corner of the
search window via its periodic-wrap term. The "saturation" is the
estimator giving up, not real ±N-plane drift.
Validation: PC peak heights at saturated frames are typically much
lower than at unsaturated frames (often 50%+ lower median). See the
ATL020 diagnostic workup at
/mnt/md0/s3d-revisions/figures/atl020-saturation-diag/review.html
for the full pattern.
Notation gotcha (related, easy to misread the offsets dict):
sub_pixel_shifts is not the sub-pixel offset alone. Despite the
name, est_sub_pixel_shift returns shift + 0.5*sub_pixel where
shift is the integer-from-center, so sub_pixel_shifts is the
full shift (integer + half-parabolic). The applied shift in
reg_3d.py:1158-1168 uses sub_pixel_shifts
directly and never re-adds int_shift. Do not compute
int_shift + sub_pixel_shifts — it double-counts the integer.
A frame at the negative-z corner reads
int_shift_z = -pc_size_z AND sub_pixel_shifts_z = -(pc_size_z+0.5)
simultaneously; the actual applied shift is just the second value.
Fixes
- First check
pc_size_z. It's the maximum integer shift searched in z. The hard upper bound isnz_planes / 2, since the 3D phase-correlation output only has as many z slices as the recording. With 4 imaged planes you cannot widen beyondpc_size_z = 2. Trying anyway crashes inprocess_phase_corr_gpu(shape-mismatch on the centered-window slicing). - If you have plenty of z planes and saturation is occurring at
the existing window, widening
pc_size = (5, 40, 40)is reasonable to test — but verify the wider window relaxes the saturated frames to sensible values rather than just relocating the corner. - If you have few z planes (≤4), or saturation persists at the
hardware limit, disable z correction:
3d_reg=False. The 2D path runs per-plane y/x rigid registration without z, which avoids the failure mode entirely. Cell footprints and downstream extraction are unaffected by skipping z; only the F-trace zeroing artifact goes away. - Do not "fix" by clamping or remapping
sub_pixel_shiftsafter the fact. The right answer is either to make the algorithm estimate well (more planes, better SNR) or to bypass z entirely.
Reference incident
ATL020_2023-04-12-GRN: 440/6708 frames pegged at z=−2.5 in the
688–1023 s window, zeroing F traces population-wide. 4 z-planes,
default pc_size_z = 2. PC peak heights at saturated frames had
median 0.0148 vs 0.0370 unsat. Fix: re-register with 3d_reg=False.
Memo: dev/coordination/rebuttal.md
"ATL020: disable z-axis registration".
Symptoms
- Job crashes (or the process is killed by the OS) during the load
step of
run_init_pass()orregister(), before any registration log lines appear. MemoryErrorfrom inside_load_scanimage_tifsat thetifffile.imread(tif_path)call, or system OOM-killer fires.preregister_tifsprints a level-0 warning at job creation: "Detected unusually large TIFF file(s). These may exhaust RAM during the load step...". Default thresholds: > 1000 frames OR10 GB per file.
Cause
The ScanImage 2P loader reads each tif into RAM in a single
tifffile.imread(path) call (see s3dio.py:169).
The pipeline's unit of work is one whole tif: it must fit in memory.
A 20 000-frame × 30-plane × 512² × int16 tif is ≈ 600 GB; even a
5000-frame tif at a large FOV easily exceeds 100 GB. LBM/FACED data
go through different paths and aren't affected by this specific
failure mode (their crashes look different).
Fix
Pre-split the offending tifs into volume-aligned chunks using the
shipped CLI utility. The split is page-granular and streams through
the source via tifffile.memmap, so it works on files larger than
RAM:
s3d-split-tiff <tif_path> <output_dir> \
--frames-per-chunk 500 \
--n-ch-tif <planes_per_volume> \
--num-colors <num_colors>
--frames-per-chunk is rounded down to a multiple of
n_ch_tif * num_colors so each chunk holds whole volumes; only the
final chunk may have a partial trailing volume, which the existing
extra-frames spillover logic in s3dio handles transparently. Output
files are named <src_basename>_<NNNN>.tif.
Then point your Job at the split tifs instead of the original.
Suite3D sees N normal small tifs and runs unchanged.
Cost
I/O-bound: read + write of the entire source. On NVMe RAID
(/mnt/md0), a 600 GB source splits in ~5-15 min. On slower
storage, longer. This is a one-time cost per problematic dataset;
subsequent suite3d runs use the split files directly.
Why no in-pipeline fix
Avoiding the read+write would require refactoring suite3d's loader
and iter_step.py batching to operate on sub-file segments rather
than whole tifs (~500-700 LOC across 4-5 files, plus a regression
test matrix). For an edge case affecting one experiment, the
splitting utility's wall-clock cost is cheaper than the engineering
cost.
Reference
CLI is registered in pyproject.toml as the s3d-split-tiff
entry point; implementation in
tiff_utils.py:split_oversized_tiff.
Preregister warning at
job.py:_warn_if_oversized_tifs.
Symptoms
cupy.cuda.memory.OutOfMemoryErrorduringregister()on the 3D-GPU path. The error names a specific allocation that failed and the amount already held.- Two distinct failure sites depending on the cause:
- Rigid step: OOM inside
reg_3d_gpuon the full-volume FFT. Held memory ≈bs * voxels * ~50 bytes. Hits when batchsize is too large for the volume size. - Nonrigid step: OOM inside
reg_3d_gpu_blockson a single contiguous(bs, nblocks, bz, by, bx)complex64 tensor. The failed alloc size equalsbs * nblocks * bz * by * bx * 8bytes exactly.
- Rigid step: OOM inside
- A level-0 warning at the start of
register_dataset_gpu_3d: "gpu_reg_batchsize=N likely exceeds GPU memory budget ... Auto- clamping to gpu_reg_batchsize=M." — emitted by_estimate_max_gpu_batchsize.
Cause
Both rigid and nonrigid 3D registration scale memory linearly in
gpu_reg_batchsize, but with different coefficients. The two terms:
- Rigid workspace ≈
bs * voxels * 8 bytes * cuFFT-factorwhere the cuFFT-factor is ~5-8× the complex64 volume size (forward FFT + reference + intermediate buffers). - Nonrigid block tensor =
bs * nblocks * bz * by * bx * 8 bytes, a single contiguous alloc. For FACED-shaped volumes (40 z planes → 8 z-blocks, plus dense y/x decomposition with 33% overlap) the block count is large enough that this term dominates.
A single param gpu_reg_batchsize controls both steps. On a FACED
volume with 20 GB of GPU memory, the rigid step is comfortable at
bs=15-20 but the nonrigid block-tensor allocation forces the safe
ceiling to bs=10.
Reference numbers (NaJi-GCaMP6sVS-ED, 40 × 270 × 512 × 803, RTX A4500 with 20 GB):
| Config | Result | Held at OOM | Failed alloc |
|---|---|---|---|
| bs=10, NR=off | runs | ~6.5 GB live | — |
| bs=30, NR=on | OOM rigid | 19.7 GB | 3.7 GB |
| bs=20, NR=on | OOM rigid | 19.0 GB | 2.5 GB |
| bs=15, NR=on | OOM NR | 15.3 GB | 7.0 GB |
| bs=10, NR=on | runs | — | — |
Fix
The pipeline auto-clamps by default: _estimate_max_gpu_batchsize is
called at the top of register_dataset_gpu_3d (after the block grid
is built if nonrigid=True) and clamps gpu_reg_batchsize down to fit
the current GPU's free memory. The estimate is conservative — it
applies a safety factor (default 0.8) and reserves 2 GB for cuFFT
plan cache and miscellaneous cupy state.
- To disable auto-clamp: set
auto_adjust_batchsize=Falsein params. Useful if you know your batchsize is fine and want to skip the prediction (e.g. running on a different GPU than the one the model was calibrated on). - If you still OOM with auto-clamp on: lower
gpu_mem_safety_factor(default 0.8). Try 0.6 first. - Manual ceiling for FACED-sized volumes on 20 GB GPUs:
gpu_reg_batchsize=10withnonrigid=Trueis the empirical ceiling.
Calibration caveat
The cuFFT workspace factor (~5-8×) is empirical and may not generalize
across CUDA/cupy/cuFFT versions. The 2 GB fixed overhead absorbs the
plan-cache allocation that happens lazily on the first batch. If first-
batch OOMs are still common on a different GPU, raise
_GPU_FIXED_OVERHEAD_GB in iter_step.py.
Reference
Memo: dev/coordination/rebuttal.md
"Nonrigid 3D GPU memory characterization — gpu_reg_batchsize ceiling
on FACED/A4500" (from @datasets, 2026-05-13). Implementation:
_estimate_max_gpu_batchsize and the call site
inside register_dataset_gpu_3d.
What it does
intensity_thresh is the per-voxel cutoff above which a voxel
contributes to the correlation map. It's applied during calculate_corr_map()
to gate which voxels enter the temporal correlation accumulator.
How to read the symptom
- Corrmap too empty (vmap mostly 0, only the brightest voxels
light up, segmentation finds very few cells):
intensity_threshis too high — most real cells are being gated out before they ever contribute. Common on dim modalities (axonal boutons, FACED) at the defaultintensity_thresh=5. - Corrmap too busy / noisy (vmap has lots of background, vessels
and out-of-cell structure show up, segmentation finds a flood of
spurious ROIs):
intensity_threshis too low — noise voxels are contributing.
Action
Sweep intensity_thresh for any new modality. Good first sweep:
{0.5, 1, 2.5, 5, 10}. The right value scales with the F units of
the data — bright soma data (e.g. V1, hippocampus) tolerates the
default 5; bouton-scale or dim signals usually want 0.5–2.5.
Knob for "I want more cells": lowering intensity_thresh is a
reliable way to get more ROIs — it gates fewer voxels out of the
correlation accumulator, so more seeds survive and segmentation finds
more cells. The trade-off is that it also lets more noise into the
corrmap (dimmer background, vessels, out-of-cell structure light up),
so some of the extra ROIs may be spurious. If you turn it down for
recall, eyeball the corrmap and the resulting ROI map for junk. Worked
example: TC040 seg-param grid — dropping intensity_thresh 5→1 (with
everything else fixed) roughly doubled the surviving ROI count
(~640 → ~1600 at npix≥20). See the publicator seg-param grid ledger
(dev/coordination/scratchboards/publicator/main.md §10a).
Quick iteration recipe: resume the job from corrmap onwards
(re-running calculate_corr_map only takes ~1 min on a typical
volume), regen a per-plane mean image | corrmap | corrmap+ROIs
diagnostic, eyeball it. See
pub/fig-quality-datasets/resume_baruchin_corrmap.py
and make_baruchin_diagnostic_html.py for a worked example on
axonal boutons (default 5 → empty; 2.5 → reasonable).
Reference
Default lives in suite3d/default_params.py
under the CORRMAP block. Used in
calculate_corr_map.
If a run produces a flood of teeny ROIs (median ~9–11 px, most ROIs
≤15 px) where you expect proper cell-sized ROIs (~100–220 px), the
first thing to check is intensity_thresh, not the spatial filter.
intensity_thresh is applied in detection3d.py::get_vmap3d as
Vt += mov[t]**2 * (mov[t] > intensity_thresh), where mov is the
sd-normalized, neuropil-subtracted, cell-filtered movie. So it is
effectively "how many noise-σ a binned frame must exceed to contribute
to the correlation map". Set it too low and the corrmap fills with
noise; the greedy extractor then seeds on noise peaks and carves crumbs.
Worked example — TC030, all runs at peak_thresh=0.03, segmentation_timebin=2:
| run | intensity_thresh |
segmentation_spatial_filt |
vox_snr_thresh |
roi_dilations_per_iter |
nROI | median px |
|---|---|---|---|---|---|---|
scratch_params_2kf |
5 | 2 | 0.05 | 1 | 563 | 220 |
caiman-comparison |
5 | 1 | 0.05 | 2 | 666 | 64 |
scratch-params-dup |
5 | 1 | 0.10 | 1 | 1018 | 11 |
seg_it1_sf2_snr0.05 |
1 | 2 | 0.05 | 1 | 3780 | 9 |
seg_it1 |
1 | 1 | 0.10 | 1 | 7835 | 9 |
Rows 1 and 4 differ in exactly one parameter, intensity_thresh, and
median ROI size collapses 220 px → 9 px. That is the only controlled
comparison in this set. Raising segmentation_spatial_filt from 1 to 2
does not rescue an under-gated corrmap (row 4 is sf=2 and still 9 px);
it roughly halves the ROI count (7835 → 3780) but not the fragmentation.
The other rows are confounded — rows 1 vs 3 change sf, vox_snr_thresh
and roi_dilations_per_iter together, so they cannot be used to attribute
the effect to sf alone. (An earlier version of this note did exactly that
and was wrong.)
There is no portable value: intensity_thresh tracks each modality's
per-voxel SNR. Bright multi-plane 2P wants ~5. LBM wants ~1 — at 5, 87% of
its corrmap voxels floor out and segmentation finds implausible 20-plane
z-columns. High-zoom axonal boutons sit around 1.5. Tune it per dataset,
and say so in the methods.
It is not a frame-count problem — the same params over thousands of
frames behave the same as over hundreds, provided
n_frames / detection_timebin leaves enough correlation timebins
(≳100; 400 frames / timebin 6 ≈ 66 bins is too few and degenerates).