mbo_utilities: User Guide#

Converting raw scanimage-tiff files into fused z-planes.

There are two computational efforts made during extraction:

  • Scan-phase correction (fix_phase=True)

  • Axial alignment / registration (z_register=True)

  • (Optional) Image fusion/stitching

You can install packages on the fly, within this notebook:

!uv pip install napari # from pypi
!uv pip install git+https://github.com/MillerBrainObservatory/mbo_utilities.git # from github
!uv pip install git+https://github.com/MillerBrainObservatory/mbo_utilities.git@dev # github branch
# imports
from pathlib import Path
import numpy as np

import fastplotlib as fpl
import mbo_utilities as mbo
import matplotlib.pyplot as plt

Quickstart#


import mbo_utilities as mbo

scan = mbo.imread(r"path/to/tiffs*")  # glob or list of filepaths

# Save options:
scan.roi = 1            # save just mROI 1
scan.roi = [1, 2]       # save mROI 1 and 2
scan.roi = 0            # save all mROIs separately
scan.roi = None         # stitch/fuse all mROIs
scan.fix_phase = True   # correct bi-dir scan-phase
scan.use_fft = True     # subpixel scan-phase correction

mbo.imwrite(
    scan,
    outpath="/path/to/save",
    planes=[1, 7, 14],
    num_frames=1500,
    ext=".zarr",
    register_z=True,
    roi=None,            # interchangable with scan.roi = None
    target_chunk_mb=200,  # larger chunks can be faster
    # zarr only
    ome=True,           # WIP
    sharded=True,       # Longer processing time, faster file transfer
)

Read data#

mbo_utilities.imread() is a smart file reader that automatically detects the file type and returns the appropriate lazy array class.

Each array has functionality, depending on the use of the filetype.

See array types for details on each array.

Lazy Array Types: Quick Reference#

Input

Returns

Use Case

.tif (raw ScanImage)

MboRawArray

Multi-ROI volumetric data with phase correction

.tif (processed)

TiffArray or MBOTiffArray

Standard TIFF files

.bin (direct path)

BinArray

Direct binary file manipulation

Directory with ops.npy

Suite2pArray

Suite2p workflow integration

.h5 / .hdf5

H5Array

HDF5 datasets

.zarr

ZarrArray

Zarr v3 stores

.npy

NpyArray

NumPy memory-mapped files

Important

Make sure your data_path contains only supported filetype.

Pass a list of files, or a wildcard string (e.g. “/path/to/files/*” matches all files in that directory) to mbo.imread().

# Show docstring

help(mbo.imread)
Help on function imread in module mbo_utilities.lazy_array:

imread(inputs: 'str | Path | Sequence[str | Path]', **kwargs)
    Lazy load imaging data from supported file types.

    Currently supported file types:
    - .bin: Suite2p binary files (.bin + ops.npy)
    - .tif/.tiff: TIFF files (BigTIFF, OME-TIFF and raw ScanImage TIFFs)
    - .h5: HDF5 files
    - .zarr: Zarr v3

    Parameters
    ----------
    inputs : str, Path, ndarray, MboRawArray, or sequence of str/Path
        Input source. Can be:
        - Path to a file or directory
        - List/tuple of file paths
        - An existing lazy array
    **kwargs
        Extra keyword arguments passed to specific array readers.

    Returns
    -------
    array_like
        One of Suite2pArray, TiffArray, MboRawArray, MBOTiffArray, H5Array,
        or the input ndarray.

    Examples
    -------
    >>> from mbo_utilities import imread
    >>> arr = imread("/data/raw")  # directory with supported files, for full filename
# mbo.get_files for getting a list of tiffs
# also can use: list(raw_scanimage_tiffs.glob("*.tif*"))

raw_scanimage_tiffs = mbo.get_files(Path(r"\\rbo-s1\S1_DATA\lbm\demo_user\raw_scanimage_tiffs"))
len(raw_scanimage_tiffs)
50
scan = mbo.imread(raw_scanimage_tiffs)
print(f'Planes: {scan.num_channels}')
print(f'Frames: {scan.num_frames}')
print(f'ROIs: {scan.num_rois}')
print(f'Shape (T, Z, Y, X): {scan.shape}')
Planes: 14
Frames: 5632
ROIs: 2
Shape (T, Z, Y, X): (5632, 14, 448, 448)

Accessing metadata#

mbo.imread automatically collects all available metadata in the file.

This metadata reflects information on all the options selected and otherwise available in ScanImage, for an image acquisition, as well as specific parameters that pertain to the current parameter set e.g. number of rois, number of frames, scan-phase correction properties, etc.

There is a lot of ScanImage metadata, even after cleaning empty values.

To view metadata, it’s helpful to use pprint so that the scanimage metadata doesn’t flood the terminal.

from pprint import pprint

# "si" contains nested scanimage metadata
pprint(scan.metadata, depth=1)
{'border': 3,
 'dtype': 'int16',
 'file_paths': [...],
 'fix_phase': True,
 'fov': [...],
 'fov_px': [...],
 'frame_rate': 17.06701142272251,
 'frames_per_file': [...],
 'max_offset': 4,
 'ndim': 2,
 'num_files': 50,
 'num_fly_to_lines': 16,
 'num_frames': 5632,
 'num_planes': 14,
 'num_rois': 2,
 'objective_resolution': 61,
 'offset': 0.0,
 'page_height': 912,
 'page_width': 224,
 'phasecorr_method': 'mean',
 'pixel_resolution': [...],
 'roi_groups': [...],
 'roi_heights': [...],
 'si': {...},
 'size': 204288,
 'uniform_sampling': 1,
 'upsample': 5,
 'use_fft': False,
 'zoom_factor': 2}

Accessing image data#

Numpy-like indexing:

frame = scan[0, 0, :, :]   # first frame, plane 1
zplane7 = scan[:, 6, :, :] # all frames from z-plane 7
# !! Jupyter Only
iw = scan.imshow()
iw.show()
iw.close()
# max-projection: every other frame (2x temporal downsampling)

scan.roi = None
plt.imshow(scan[::2, 7, :, :].max(axis=0), cmap='gray')
<matplotlib.image.AxesImage at 0x1a2d9b58dd0>
_images/7f491ce39bf53b9b84628c6e574966f4bc67a82538d325ce1bd68b9a3e34c73a.png

Scan-Phase Correction#

scan.roi = None
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

scan.fix_phase = False
img_no_corr = scan[:,7,:,:].max(axis=0)[130:190, 250:280]
axes[0].imshow(img_no_corr, cmap='gray')
axes[0].set_title('No Scan-Phase Correction', fontsize=12, fontweight='bold')
axes[0].axis('off')

scan.fix_phase = True
scan.use_fft = False
img_corr = scan[:,7,:,:].max(axis=0)[130:190, 250:280]
axes[1].imshow(img_corr, cmap='gray')
axes[1].set_title('With Scan-Phase Correction', fontsize=12, fontweight='bold')
axes[1].axis('off')

scan.fix_phase = True
scan.use_fft = True
img_fft = scan[:,7,:,:].max(axis=0)[130:190, 250:280]
axes[2].imshow(img_fft, cmap='gray')
axes[2].set_title('FFT Scan-Phase Correction\n(Best, Slower)', fontsize=12, fontweight='bold')
axes[2].axis('off')

plt.tight_layout()
plt.show()

# clear out these large arrays in memory
del img_no_corr, img_corr, img_fft
_images/aa0690a26b245c50de028866bce41974b62b99a7f07e10d1a838fd74c497f56b.png

Multi-ROI reading#

# returns a tuple of all rois
scan.roi = 0
mroi1, mroi2 = scan[:,7,:,:]

fig, ax = plt.subplots(1,2, figsize=(10,5))
ax[0].imshow(mroi1.max(axis=0), cmap='gray')
ax[1].imshow(mroi2.max(axis=0), cmap='gray')
<matplotlib.image.AxesImage at 0x11194201f40>
_images/f5c7817b4aa0a8f4fe512d939133f89ed1e2947797e26ac32dedd6abfbd778b0.png

Supported Filetypes#

from mbo_utilities import SUPPORTED_FTYPES
print(SUPPORTED_FTYPES) # json is for .zarr file loading
('.npy', '.tif', '.tiff', '.bin', '.h5', '.zarr', '.json')

Write data#

mbo.imwrite takes any of the lazy array types as inputs, and a filepath as outputs. If not given, the ouptut path will be the parent of the lazy arrays filename.

File Naming Convention#

For saving raw scanimage tiffs, each z-plane is saved/processed sequentially.

Stitched/Fused multiROI’s#

mbo.imwrite(..., ext=".tiff", roi=None)
outpath/plane01_stitched.tiff
outpath/plane02_stitched.tiff

mbo.imwrite(..., ext=".zarr", roi=None)
outpath/plane01_stitched.zarr
outpath/plane02_stitched.zarr
outpath/plane0N_stitched.zarr

mbo.imwrite(..., ext=".bin", roi=None)
outpath/plane01/
  ├── data_raw.bin
  └── ops.npy
outpath/plane02/
  ├── data_raw.bin
  └── ops.npy
outpath/plane0N/
  ├── data_raw.bin
  └── ops.npy

Individual ROIs#

Saving each ROI into a separate directory will to treat as entirely different datasets. LBM-Suite2p-Python contains utilities to merge after processing.


outpath/plane01_roi1/
  ├── data_raw.bin
  └── ops.npy
outpath/plane01_roi2/
  ├── data_raw.bin
  └── ops.npy

plane01_roi1.tiff
plane01_roi2.tiff
plane02_roi1.tiff
plane02_roi2.tiff

etc.

---

## See Also

- {func}`mbo_utilities.imread` - Load imaging data from various formats
- {func}`mbo_utilities.run_gui` - Visualize data interactively
- {func}`mbo_utilities.save_mp4` - Export data as video
- {func}`lbm_suite2p_python.merging.merge_rois()` - Merge Suite2p results

Suite3D: Axial (Z) Registration#

Light-beads-microscopy data aquisition typically results in each z-plane having a shift relative to the previous z-plane in space.

This can be corrected for automatically by setting register_z=True.

Note: No matter how many z-planes you are extracting, axial registration computes shifts based on all z-planes in the image data.

Suite3D: Outputs#

Suite3d takes a subset of input tiffs and creates a s3d_preprocessed directory that stores the planar shifts and several intermediates.

[img]: Mean image
  Type: ndarray
  Shape: (14, 449, 449)
  Dtype: float64
  Size (elements): 2822414
  Memory: 22050.11 KB
  Min/Max: -0.904 / 17742.870

[init_mov_path]: Path to saved initial movie (init_mov.npy)
  Type: str
  Value: \\rbo-s1\S1_DATA\lbm\demo_user\extracted\s3d-preprocessed\summary\init_mov.npy

[init_tifs]: List of initial TIFF files used
  Type: ndarray
  Shape: (1,)
  Dtype: object
  Size (elements): 1
  Memory: 0.01 KB
  First item: \\rbo-s1\S1_DATA\lbm\demo_user\raw_scanimage_tiffs\mk301_03_01_2025_2roi_17p07hz_224x448px_2umpx_180mw_green_00025.tif

[min_pix_vals]: Minimum pixel values per plane (for enforcing positivity)
  Type: ndarray
  Shape: (14,)
  Dtype: int64
  Size (elements): 14
  Memory: 0.11 KB
  Min/Max: -533.000 / -21.000

[plane_shifts]: Translation vectors for plane alignment (Z, Y, X)
  Type: ndarray
  Shape: (14, 2)
  Dtype: int64
  Size (elements): 28
  Memory: 0.22 KB
  Min/Max: 0.000 / 112.000

[raw_img]: Raw mean image directly from TIFF files (shape: Z, Y, X)
  Type: ndarray
  Shape: (14, 449, 449)
  Dtype: float64
  Size (elements): 2822414
  Memory: 22050.11 KB
  Min/Max: -533.487 / 17628.870

[ref_img_3d]: Reference image (crosstalk-subtracted, padded, plane-shifted)
  Type: ndarray
  Shape: (14, 561, 453)
  Dtype: float32
  Size (elements): 3557862
  Memory: 13897.90 KB
  Min/Max: -38.961 / 17758.334

[reference_info]: Additional registration info - 3D reg only
  Type: dict
  Dict keys: ['plane_mins', 'plane_maxs', 'zmax', 'ymax', 'xmax', 'cmax', 'subpix_shifts', 'used_frames', 'frame_use_order']
  Num items: 9

[reference_params]: Dictionary of registration parameters used
  Type: dict
  Dict keys: ['percent_contribute', 'block_size', 'sigma', 'smooth_sigma', 'niter', 'max_reg_xy_reference', 'pc_size', 'batch_size', '3d_reg', 'plane_to_plane_alignment', 'NRsm', 'nblocks', 'xblock', 'yblock']
  Num items: 14

[refs_and_masks]: Reference images and masks for registration
  Type: list
  List length: 14
  First item type: <class 'list'>
# Suite3D logs during z-plane registration
Loaded file into shared memory in 2.38 sec
    Workers completed in 1.50 sec
    Total time: 3.88 sec

Suite3D registration operates independantly from mbo.imwrite.

During writing, these planar shifts are loaded and applied to the seleted z-planes.

save_path = Path(r"D:\demo\local_nvme")
scan.roi = None       # Stitch all ROIs together (default behavior)
scan.fix_phase = True
scan.use_fft = True

mbo.imwrite(
    scan,
    save_path,
    ext='.bin',
    num_frames=1550,
    planes=[6, 7, 8],
    overwrite=True,
    register_z=True,  # suite3d axial registration
    metadata={"notes": "No behavior"},
)
# Creates: plane01_stitched/data_raw.bin, plane02_stitched/data_raw.bin
# Compatible with LBM-Suite2p-Python
    Loaded file into shared memory in 2.53 sec
    Workers completed in 1.49 sec
    Total time: 4.03 sec
out_dirs = list(save_path.iterdir())
out_dirs
[WindowsPath('D:/demo/local_nvme/plane06_stitched'),
 WindowsPath('D:/demo/local_nvme/plane07_stitched'),
 WindowsPath('D:/demo/local_nvme/plane08_stitched'),
 WindowsPath('D:/demo/local_nvme/s3d-preprocessed')]

Suite2p binaries are readable with mbo.imread:

# this file may not exist if you ran LBM-Suite2p-Python without keep_reg=True
raw_data = mbo.imread(out_dirs[0] / "data_raw.bin")
raw_data.shape
(1550, 448, 448)
from pprint import pprint # just encase we didnt run the above cell import
z_reg_dir = save_path / "s3d-preprocessed" / "summary"
reg_files = list(z_reg_dir.iterdir())
pprint(reg_files)
[WindowsPath('D:/demo/local_nvme/s3d-preprocessed/summary/init_mov.npy'),
 WindowsPath('D:/demo/local_nvme/s3d-preprocessed/summary/params.npy'),
 WindowsPath('D:/demo/local_nvme/s3d-preprocessed/summary/summary.npy')]
init_mov = mbo.imread(reg_files[0])
init_mov.shape
(14, 115, 561, 453)
# z-registration summary files are not read by imread (coming soon)
summary_npy_file = save_path / "s3d-preprocessed" / "summary" / "summary.npy"
summary_data = np.load(summary_npy_file, allow_pickle=True).item()
Verify Suite3D Registration#

We can use the intermediates saved by suite3D to preview a subsampled version of the data.

import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from IPython.display import HTML

ref_img = summary_data['ref_img_3d']
plane_shifts = summary_data['plane_shifts']
raw_img = summary_data['raw_img']

Z, H, W = ref_img.shape

fig = plt.figure(figsize=(16, 10))
gs = fig.add_gridspec(3, 3, hspace=0.3, wspace=0.3)

# 1. Raw
ax_raw = fig.add_subplot(gs[0:2, 0])
im_raw = ax_raw.imshow(raw_img[0], cmap="gray", animated=True)
ax_raw.set_title(f"BEFORE Alignment - Plane 0/{Z-1}", fontsize=12, fontweight='bold', color='red')
ax_raw.axis('off')
cbar_raw = plt.colorbar(im_raw, ax=ax_raw, fraction=0.046, pad=0.04)

# 2. Registered
ax_aligned = fig.add_subplot(gs[0:2, 1])
im_aligned = ax_aligned.imshow(ref_img[0], cmap="gray", animated=True)
ax_aligned.set_title(f"AFTER Alignment - Plane 0/{Z-1}", fontsize=12, fontweight='bold', color='green')
ax_aligned.axis('off')
cbar_aligned = plt.colorbar(im_aligned, ax=ax_aligned, fraction=0.046, pad=0.04)

# 3. Plane shifts
ax_shifts = fig.add_subplot(gs[0, 2])
y_shifts = plane_shifts[:, 0]  # Y shifts
x_shifts = plane_shifts[:, 1]  # X shifts
planes = np.arange(Z)

ax_shifts.plot(planes, y_shifts, 'o-', label='Y shift', linewidth=2, markersize=6)
ax_shifts.plot(planes, x_shifts, 's-', label='X shift', linewidth=2, markersize=6)
ax_shifts.axhline(0, color='gray', linestyle='--', alpha=0.5)
ax_shifts.set_xlabel('Z-plane', fontweight='bold')
ax_shifts.set_ylabel('Shift (pixels)', fontweight='bold')
ax_shifts.set_title('Plane Shifts Applied', fontsize=12, fontweight='bold')
ax_shifts.legend(loc='best')
ax_shifts.grid(True, alpha=0.3)

# 4. Shift magnitude
ax_magnitude = fig.add_subplot(gs[1, 2])
shift_magnitude = np.sqrt(y_shifts**2 + x_shifts**2)
ax_magnitude.bar(planes, shift_magnitude, color='steelblue', alpha=0.7, edgecolor='black')
ax_magnitude.set_xlabel('Z-plane', fontweight='bold')
ax_magnitude.set_ylabel('Total shift (pixels)', fontweight='bold')
ax_magnitude.set_title('Shift Magnitude', fontsize=12, fontweight='bold')
ax_magnitude.grid(True, alpha=0.3, axis='y')

# 5. Max projections
ax_raw_max = fig.add_subplot(gs[2, 0])
raw_max = raw_img.max(axis=0)
im_raw_max = ax_raw_max.imshow(raw_max, cmap='gray')
ax_raw_max.set_title('Before (Max Proj)', fontsize=11, fontweight='bold')
ax_raw_max.axis('off')
plt.colorbar(im_raw_max, ax=ax_raw_max, fraction=0.046, pad=0.04)

ax_ref_max = fig.add_subplot(gs[2, 1])
ref_max = ref_img.max(axis=0)
im_ref_max = ax_ref_max.imshow(ref_max, cmap='gray')
ax_ref_max.set_title('After (Max Proj)', fontsize=11, fontweight='bold')
ax_ref_max.axis('off')
plt.colorbar(im_ref_max, ax=ax_ref_max, fraction=0.046, pad=0.04)

# 7. Statistics box
ax_stats = fig.add_subplot(gs[2, 2])
ax_stats.axis('off')
stats_text = f"""
Registration Statistics

Z-planes: {Z}
Image size: {H} × {W} px

Y shifts:
  Range: [{y_shifts.min():.1f}, {y_shifts.max():.1f}]
  Mean: {y_shifts.mean():.2f} px
  Std: {y_shifts.std():.2f} px

X shifts:
  Range: [{x_shifts.min():.1f}, {x_shifts.max():.1f}]
  Mean: {x_shifts.mean():.2f} px
  Std: {x_shifts.std():.2f} px

Max total shift: {shift_magnitude.max():.2f} px
"""
ax_stats.text(0.05, 0.95, stats_text, transform=ax_stats.transAxes,
             fontsize=10, verticalalignment='top', family='monospace',
             bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.3))

def update(frame):
    im_raw.set_array(raw_img[frame])
    im_aligned.set_array(ref_img[frame])
    ax_raw.set_title(f"BEFORE Alignment - Plane {frame}/{Z-1}",
                    fontsize=12, fontweight='bold', color='red')
    ax_aligned.set_title(f"AFTER Alignment - Plane {frame}/{Z-1}",
                        fontsize=12, fontweight='bold', color='green')
    return [im_raw, im_aligned]

ani = FuncAnimation(fig, update, frames=Z, interval=200, blit=True)
plt.tight_layout()
plt.close(fig)
HTML(ani.to_jshtml())
# Option 1: Stitch all ROIs together (default)
scan.roi = None
mbo.imwrite(scan, save_path / "stitched")
# Creates: plane01_stitched.tiff, plane02_stitched.tiff, ...

# Option 2: Save all ROIs as separate files
scan.roi = 0
mbo.imwrite(scan, save_path / "split_rois")
# Creates: plane01_roi1.tiff, plane01_roi2.tiff, ..., plane14_roi1.tiff, plane14_roi2.tiff

# Option 3: Save specific ROI only
scan.roi = 1
mbo.imwrite(scan, save_path / "roi1_only")
# Creates: plane01_roi1.tiff, plane02_roi1.tiff, ..., plane14_roi1.tiff

# Option 4: Save multiple specific ROIs
scan.roi = [1, 3]
mbo.imwrite(scan, save_path / "roi1_and_roi3")
# Creates: plane01_roi1.tiff, plane01_roi3.tiff, ..., plane14_roi1.tiff, plane14_roi3.tiff

Visualize data with fastplotlib (jupyter only)#

To get a rough idea of the quality of your extracted timeseries, we can create a fastplotlib visualization to preview traces of individual pixels.

Here, we simply click on any pixel in the movie, and we get a 2D trace (or “temporal component” as used in this field) of the pixel through the course of the movie:

More advanced visualizations can be easily created, i.e. adding a baseline subtracted element to the trace, or passing the trace through a frequency filter.

from ipywidgets import VBox

iw_movie = fpl.ImageWidget(raw_data, cmap="viridis")

tfig = fpl.Figure()

raw_trace = tfig[0, 0].add_line(np.zeros(raw_data.shape[0]))

@iw_movie.managed_graphics[0].add_event_handler("click")
def pixel_clicked(ev):
    col, row = ev.pick_info["index"]
    raw_trace.data[:, 1] = iw_movie.data[0][:, row, col]
    tfig[0, 0].auto_scale(maintain_aspect=False)

VBox([iw_movie.show(), tfig.show()])

Whats Next#

See LBM-Suite2p-Python Quickstart for suite2p registration and segmentation.