Demo 02 β Light-beads microscopy, ~40k neurons (SS004)ΒΆ
22 planes over a 5-minute recording. LBM needs three things a conventional
2P recording does not, all handled in common/datasets.py:
- strip fusion β the mesoscope writes several ROI strips per plane
(
fuse_strips,fuse_shift_override=6); - crosstalk subtraction β the two cavities are interleaved in depth and
bleed into each other (
subtract_crosstalk,cavity_size=13); - plane reordering β
planesmaps ScanImage's interleaved order back into depth order. This mapping is microscope-specific.
It also has lower per-voxel SNR, hence intensity_thresh=1.0,
detection_timebin=6, and vox_snr_thresh=0.05.
Data: <data-root>/lbm/raw/*.tif (13 tifs, 56.2 GB). The registered movie is
~38 GB and this notebook is slow. That is expected.
%matplotlib inline
# inline PNGs, never ipywidgets: nbconvert renders a stateless widget
# as a silently empty div, and these pages go on the public web.
import sys, os
sys.path.insert(0, os.path.abspath('..'))
import numpy as np
import matplotlib.pyplot as plt
from common import extract_batch, get_params, find_tifs
from suite3d.job import Job
params = get_params('lbm')
# `get_params` asserts that fs is a plausible *volume* rate.
# Never use io.get_vol_rate() -- it returns the per-plane rate.
nz = len(params['planes'])
print(f"{nz} planes, fs = {params['fs']:.4f} Hz (volume rate), "
f"voxel {params['voxel_size_um']} um (dz, dy, dx)")
# Everything NOT listed here comes from suite3d.default_params.
for k, v in params.items():
print(f' {k:28s} {v}')
# Point SUITE3D_DEMO_DATA at your figshare download, e.g.
# export SUITE3D_DEMO_DATA=~/figshare
# Absolute paths are kept out of the notebook source on purpose: the
# executed outputs are published, and must not leak a filesystem layout.
DATA_ROOT = os.environ.get('SUITE3D_DEMO_DATA', './data')
OUT_DIR = os.environ.get('SUITE3D_DEMO_OUT', './results')
tifs = find_tifs(DATA_ROOT, 'lbm')
print(f'{len(tifs)} tifs')
job = Job(OUT_DIR, 'demo-lbm', tifs=tifs, params=params,
create=True, overwrite=True, verbosity=1)
1. InitializationΒΆ
Reads the first few tifs, builds a 3D mean image, and estimates the plane-to-plane shifts. This is the reference everything else registers to.
job.run_init_pass()
summary = job.load_summary()
ref = summary['ref_img_3d'] # (nz, ny, nx)
print('reference volume:', ref.shape)
nz = ref.shape[0]
fig, axs = plt.subplots(1, nz, figsize=(3 * nz, 3.4))
for z, ax in enumerate(np.atleast_1d(axs)):
ax.imshow(ref[z], cmap='gray',
vmin=np.percentile(ref[z], 1), vmax=np.percentile(ref[z], 99.5))
ax.set_title(f'z = {z}'); ax.axis('off')
fig.suptitle('Reference volume (mean image per plane)')
plt.tight_layout(); plt.show()
What makes this recording LBMΒΆ
The init pass estimates three things a conventional 2P recording never needs: the
crosstalk between the two cavities, the shift used to fuse the mesoscope's strips,
and the plane-to-plane offsets. planes reorders the interleaved cavities into
depth order.
# All three are estimated during the init pass.
print(f"cavity crosstalk coefficient : {summary['crosstalk_coeff']:.3f}")
print(f"strip fusion shift (px) : {summary['fuse_shift']}")
print(f"cavity_size : {params['cavity_size']} planes per cavity")
print(f"planes (acquisition order) : {list(params['planes'])}")
fig, axs = plt.subplots(1, 2, figsize=(11, 3.2))
axs[0].plot(np.asarray(summary['crosstalk_planes']), 'o-')
axs[0].set_xlabel('plane pair')
axs[0].set_ylabel('estimated crosstalk')
axs[0].set_title(f"per-plane crosstalk (coeff = {summary['crosstalk_coeff']:.3f})")
ps = np.asarray(summary['plane_shifts'])
axs[1].plot(ps[:, 0], label='y')
axs[1].plot(ps[:, 1], label='x')
axs[1].set_xlabel('plane')
axs[1].set_ylabel('shift (px)')
axs[1].legend()
axs[1].set_title('plane-to-plane shifts')
plt.tight_layout()
plt.show()
2. RegistrationΒΆ
3D rigid + non-rigid alignment of every volume to the reference, by FFT phase correlation. The rigid offsets below should look like slow drift plus small jitter; a trace that sits pinned at a constant value has hit the edge of its search window.
job.register()
reg = job.load_registration_results()
print('registration keys:', sorted(reg.keys()))
# Rigid registration offsets over time.
#
# `int_shift` is the integer voxel shift that was actually applied to each
# volume; `sub_pixel_shifts` is the raw phase-correlation estimate before
# rounding. Both are (n_volumes, 3) with columns ordered (z, y, x)
# -- see reg_3d.py.
#
# This demo sets apply_z_shift=False, so the z column below is *measured*
# but never applied to the movie.
shifts = np.asarray(reg['int_shift'])
fig, ax = plt.subplots(figsize=(9, 3))
for i, axis_name in enumerate(('z', 'y', 'x')):
ax.plot(shifts[:, i], lw=0.8, label='%s shift' % axis_name)
ax.set_xlabel('volume')
ax.set_ylabel('shift (voxels)')
ax.legend()
ax.set_title('Rigid registration offsets')
plt.tight_layout()
plt.show()
3. Correlation mapΒΆ
The heart of Suite3D. Each voxel is scored by how correlated it is with its neighbours over time, after filtering at a cell scale and a neuropil scale. Cells light up as blobs; vessels and neuropil do not.
This is computed on the whole volume at once, which is what lets a cell spanning several planes be found as one object.
job.calculate_corr_map()
cm = job.load_corr_map_results()
vmap, mean_img = cm['vmap'], cm['mean_img']
print('corr map:', vmap.shape)
nz = vmap.shape[0]
fig, axs = plt.subplots(2, nz, figsize=(3 * nz, 6.5), squeeze=False)
for z in range(nz):
axs[0, z].imshow(mean_img[z], cmap='gray',
vmax=np.percentile(mean_img[z], 99.5))
axs[0, z].set_title(f'mean, z={z}'); axs[0, z].axis('off')
axs[1, z].imshow(vmap[z], cmap='magma',
vmax=np.percentile(vmap[z], 99.5))
axs[1, z].set_title(f'corr map, z={z}'); axs[1, z].axis('off')
plt.tight_layout(); plt.show()
4. SegmentationΒΆ
Peaks in the correlation map seed ROIs; each seed is grown into a 3D
footprint, then subtracted so the next iteration finds the next cell.
Voxels join an ROI when they pass vox_snr_thresh.
job.segment_rois()
stats = job.load_segmentation_results(to_load=['stats'])
print(f'{len(stats)} ROIs detected')
# How far do ROIs extend in z? Multi-plane cells are the point of 3D detection.
zspan = np.array([s['coords'][0].max() - s['coords'][0].min() + 1 for s in stats])
npix = np.array([len(s['lam']) for s in stats])
fig, axs = plt.subplots(1, 2, figsize=(10, 3.2))
axs[0].hist(zspan, bins=np.arange(0.5, zspan.max() + 1.5))
axs[0].set_xlabel('z-planes spanned'); axs[0].set_ylabel('# ROIs')
axs[1].hist(npix, bins=40)
axs[1].set_xlabel('voxels per ROI'); axs[1].set_ylabel('# ROIs')
plt.tight_layout(); plt.show()
print(f'median z-span {np.median(zspan):.0f} planes, '
f'{(zspan > 1).mean() * 100:.0f}% of ROIs span more than one plane')
# ROI footprints painted onto the correlation map, plane by plane.
lbl = np.zeros(vmap.shape, dtype=int)
for i, s in enumerate(stats):
zc, yc, xc = s['coords']
lbl[zc, yc, xc] = i + 1
nz = vmap.shape[0]
fig, axs = plt.subplots(1, nz, figsize=(3 * nz, 3.4), squeeze=False)
for z in range(nz):
axs[0, z].imshow(vmap[z], cmap='gray', vmax=np.percentile(vmap[z], 99.5))
m = np.ma.masked_where(lbl[z] == 0, lbl[z] % 20 + 1)
axs[0, z].imshow(m, cmap='tab20', alpha=0.65, interpolation='nearest')
axs[0, z].set_title(f'z={z} ({(lbl[z] > 0).sum()} vox)'); axs[0, z].axis('off')
plt.tight_layout(); plt.show()
5. Neuropil masks, traces, deconvolutionΒΆ
An annular neuropil mask is built around each ROI; F is the raw trace,
Fneu the surrounding neuropil, and the corrected trace is
F - npil_coeff * Fneu. spks is the deconvolved estimate.
job.compute_npil_masks()
# Trace extraction sets this pipeline's peak memory. Suite3D's default of
# 500 volumes per batch costs ~56 GiB on a 22-plane LBM volume; snapping the
# batch to whole on-disk chunks is both cheaper and faster. See
# common/pipeline.py:extract_batch.
job.extract_and_deconvolve(batchsize_frames=extract_batch(job))
res = job.load_segmentation_results(to_load=['F', 'Fneu', 'spks'])
F, Fneu, spks = res['F'], res['Fneu'], res['spks']
print('traces:', F.shape, '(n_rois, n_volumes)')
fs = params['fs'] # volume rate -> seconds
t = np.arange(F.shape[1]) / fs
# The most active ROIs, by deconvolved activity.
order = np.argsort(spks.sum(1))[::-1][:5]
fig, axs = plt.subplots(len(order), 1, figsize=(11, 2 * len(order)), sharex=True)
for ax, i in zip(np.atleast_1d(axs), order):
ax.plot(t, F[i] - 0.7 * Fneu[i], lw=0.7, label='F - 0.7*Fneu')
ax.plot(t, spks[i], lw=0.7, alpha=0.7, label='spks')
ax.set_ylabel(f'ROI {i}'); ax.legend(loc='upper right', fontsize=7)
np.atleast_1d(axs)[-1].set_xlabel('time (s)')
plt.tight_layout(); plt.show()
6. Browse the resultΒΆ
Export, and open the portable HTML viewer (or napari).
job.export_results(OUT_DIR, make_viewer=True) \
if hasattr(job, 'make_html_viewer') else job.export_results(OUT_DIR)
# Or, in napari:
# from suite3d.ui import create_napari_ui # NB: ui.py, not curation.py
# import napari
# outputs = job.load_segmentation_results()
# outputs.update(job.load_corr_map_results())
# create_napari_ui(outputs, scale=params['voxel_size_um'])
# napari.run()
Equivalent one-liner:
python run_pipeline.py --data-root /path/to/figshare --out-dir ./results