Skip to content

qi2lab DataStore overview

Philosophy

To efficiently handle 3D MERFISH experiments, qi2labDataStore stores image arrays as independent OME-NGFF v0.5 images, read and written through yaozarrs using the TensorStore interface. Tabular outputs are stored as Parquet.

The current datastore reader/writer only supports the breaking-change Version: 0.6 layout shown below.

Important considerations

To create a qi2labDataStore, we need to know the following metadata:

  • the effective xy pixel size and z step
  • the objective numerical aperture
  • the immersion media refractive index
  • the global stage zyx position at each tile
  • the camera orientation with respect to the stage orientation
  • the direction of stage motion with respect the camera view
  • the bits that were collected in each round
  • the acquisition order in each tile (channel,z) or (z,channel)
  • the excitation and emission wavelengths for each channel

Most of these are straightforward to obtain. The camera orientation and stage direction can be the trickiest. In our experience, one way to figure this out is to load a few tiles of the data in napari and explore different orientations of the images and stage direction.

Because there are so many different microscopes and microscope acquisition software, we rely on the user to provide the images in the correct orientation such that a positive displacement in the global stage coordinates corresponds to a positive displacement in the image and vice-versa. In the Zhuang lab examples, we show how to determine the camera and stage orientations when the metadata is not available.

Codebook and Experiment Order files

For iterative multiplexing, we need to know the codebook, which connects genes and codewords, and the experiment order, which connects rounds and bits.

We expect these to be in .csv or .tsv format.

For example, a 16-bit codebook codebook.tsv should have the following structure:

codeword bit01 bit02 bit03 bit04 bit05 bit06 bit07 bit08 bit09 bit10 bit11 bit12 bit13 bit14 bit15 bit16
word 1 0 0 0 0 1 1 1 0 0 0 0 0 0 0 1 0
word 2 0 0 0 0 1 0 1 0 0 0 0 1 0 0 0 1
word 3 1 0 0 0 1 0 1 0 0 0 0 0 1 0 0 0
word 4 0 0 1 0 1 0 1 1 0 0 0 0 0 0 0 0
-------- - - - - - - - - - - - - - - - -
word N 0 1 0 0 0 0 0 0 1 1 0 0 0 1 0 0

exp_order should have N columns. The first column is the round, starting from 1. The remaining columns are the readout bits in the codebook, in order of acquisition. Important: we assume that each tile has a fiducial channel. If there is not, this software package will not work for your experiment.

For a 16-bit codebook, where we acquire the bits in sequential order within rounds and across rounds, the exp_order.tsv will look like:

round readout 1 readout 2
1 1 2
2 3 4
3 5 6
4 7 8
5 9 10
6 11 12
7 13 14
8 15 16

General use

Here, we use a hypothetical dataset that only has one round with two bits. We assume the data is already gain, offset, and hot pixel corrected.

Tiles, rounds, and bits are indexed from 0 in the Python API, but datastore IDs are stored as 1-based, zero-padded strings (round001, bit001, tile0000).

from pathlib import Path

import numpy as np
import pandas as pd
from tifffile import imread

from merfish3danalysis.qi2labDataStore import qi2labDataStore

# define the datastore directory and create the datastore
root_path = Path(r"/path/to/dataset/")
raw_path = root_path / "raw_data"
datastore = qi2labDataStore(root_path / "qi2labdatastore")

# required metadata
datastore.channels_in_data = ["alexa488", "alexa561", "alexa647"]
datastore.num_rounds = 1
datastore.codebook = pd.read_csv(raw_path / "codebook.csv")
datastore.experiment_order = pd.read_csv(raw_path / "exp_order.csv").to_numpy()
datastore.num_tiles = 1
datastore.microscope_type = "3D"
datastore.tile_overlap = 0.2
datastore.e_per_ADU = 0.51
datastore.na = 1.35
datastore.ri = 1.51
datastore.binning = 1
datastore.noise_map = np.zeros((2048, 2048), dtype=np.uint16)
datastore.channel_psfs = (
    channel_psfs  # one experimental or theoretical 3D PSF per channel
)
datastore.voxel_size_zyx_um = [0.31, 0.098, 0.098]

# Mark calibrations complete
datastore.datastore_state = {"Calibrations": True}

# initialize the tile
tile_idx = 0
datastore.initialize_tile(tile_idx)

# code to read image tile here
# Assume the images are of shape [n_channels,nz,nx,ny]
fiducial_data = imread(raw_path / "tile001" / "image.tif")[0, :]

# save image data for tile = 0, round = 0, fiducial
datastore.save_local_corrected_image(
    fiducial_data,
    tile=0,
    psf_idx=0,
    gain_correction=True,
    hotpixel_correction=True,
    shading_correction=False,
    round=0,
)

# save stage position for tile = 0, round = 0, fiducial
datastore.save_local_stage_position_zyx_um(
    [1000.0, 200.0, 500.0],
    np.eye(4, dtype=np.float32),
    tile=0,
    round=0,
)

# save excitation and emission wavelengths for tile = 0, round = 0, fiducial
# this position is used for any bits linked to this round
datastore.save_local_wavelengths_um(
    (0.488, 0.520),
    tile=0,
    round=0,
)

# code to read image tile here
# Assume the images are of shape [n_channels,nz,nx,ny]
bit001_data = imread(raw_path / "tile001" / "image.tif")[1, :]

# save first readout channel for tile = 0, bit = 0 (bit001)
datastore.save_local_corrected_image(
    bit001_data,
    tile=0,
    psf_idx=1,
    gain_correction=True,
    hotpixel_correction=True,
    shading_correction=False,
    bit=0,
)

# save excitation and emission wavelengths for tile = 0, bit = 0
datastore.save_local_wavelengths_um(
    (0.561, 0.590),
    tile=0,
    bit=0,
)

# code to read image tile here
# Assume the images are of shape [n_channels,nz,nx,ny]
bit002_data = imread(raw_path / "tile001" / "image.tif")[2, :]

# save second readout channel for tile = 0, bit = 1 (bit002)
datastore.save_local_corrected_image(
    bit002_data,
    tile=0,
    psf_idx=2,
    gain_correction=True,
    hotpixel_correction=True,
    shading_correction=False,
    bit=1,
)

# save excitation and emission wavelengths for tile = 0, bit = 1
datastore.save_local_wavelengths_um(
    (0.635, 0.670),
    tile=0,
    bit=1,
)

# Mark corrected data complete
datastore.datastore_state = {"Corrected": True}

SOFIMA deformable registration convention

When deformable local registration is enabled, preprocessing estimates a SOFIMA residual flow field after the moving fiducial round has already been affine-initialized into the first fiducial round frame. The field is stored in the moving round's fiducial group as local_sofima_flow_field.ome.zarr. SOFIMA's local patch-correlation measurements are integer-pixel vectors. The preprocessing pipeline refines accepted vectors within a small subpixel search window, composes a bounded residual pass, and saves the float32 result after the measurements are relaxed with SOFIMA's 3D elastic mesh. The SOFIMA flow estimation, residual composition, and subpixel patch scoring are run through JAX on the GPU.

The saved array and attributes use the following convention:

  • Array shape is (3, z, y, x).
  • Channel order is X, Y, Z.
  • Spatial axis order is Z, Y, X, matching image arrays.
  • Values are relative displacements in reference-image pixels. Adding the interpolated field to a round001 reference coordinate gives the coordinate in the affine-initialized moving image.
  • map_stride_zyx_px is the flow-map spacing in reference pixels, ordered Z, Y, X.
  • map_box_start_xyz_px is the reference pixel coordinate of the first flow sample, ordered X, Y, Z. SOFIMA estimates patch-centered vectors, so this value is normally half the patch size, not (0, 0, 0).
  • map_box_size_xyz_px is the sampled flow-lattice extent from map_box_start_xyz_px through the last stored map sample, ordered X, Y, Z.
  • reference_shape_zyx_px records the output grid used when the field is applied after reload.

The datastore round trip is expected to preserve the float32 flow array exactly. Applying a reloaded SOFIMA field with its saved metadata must produce the same warped image as applying the in-memory field returned by the estimator.

DataStore structure

/experiment
├── raw_data/
   └── <raw experimental data and metadata>
└── qi2labdatastore/
    ├── datastore_state.json
    ├── calibrations/
       ├── attributes.json
          ├── <experiment metadata: codebook, exp_order, channels, ...>
          ├── <voxel_size_zyx_um>
          └── <psf_manifest>
       ├── noise_map/                # OME-NGFF v0.5 image
          ├── zarr.json
          └── 0/
       ├── shading_maps/             # OME-NGFF v0.5 image
          ├── zarr.json
          └── 0/
       └── psf_data/
           ├── psf_000.ome.zarr/     # OME-NGFF v0.5 image
              ├── zarr.json
              └── 0/
           ├── psf_001.ome.zarr/
           └── ...
    ├── fiducial/
       └── tile0000/
           ├── round001/
              ├── attributes.json
              ├── corrected_data.ome.zarr/   # OME-NGFF v0.5 image
                 ├── zarr.json
                 └── 0/
              ├── decon_data.ome.zarr/       # optional, native local frame
                 ├── zarr.json
                 └── 0/
              └── local_sofima_flow_field.ome.zarr/  # rounds > 1 when enabled
                  ├── zarr.json
                  └── 0/
           ├── round002/
           └── ...
    ├── readouts/
       └── tile0000/
           ├── bit001/
              ├── attributes.json
              ├── corrected_data.ome.zarr/
                 ├── zarr.json
                 └── 0/
              ├── decon_data.ome.zarr/
                 ├── zarr.json
                 └── 0/
              └── feature_predictor_data.ome.zarr/
                  ├── zarr.json
                  └── 0/
           ├── bit002/
           └── ...
    ├── feature_predictor_localizations/
       └── tile0000/
           ├── bit001.parquet
           └── ...
    ├── fused/
       └── fused.zarr/
           ├── fused_fiducial_zyx.ome.zarr/
              ├── zarr.json
              └── 0/
           └── fused_all_channels_zyx.ome.zarr/   # optional
    ├── segmentation/
       ├── cellpose/
          ├── cellpose.zarr/
             └── masks_fiducial_iso_zyx.ome.zarr/
                 ├── zarr.json
                 └── 0/
          └── imagej_rois/global_coords_rois.zip
       └── baysor/3D/
           ├── molecules.parquet
           └── cell_boundaries_3d.parquet
    ├── proseg/
       └── 3D/
           ├── cell_polygons_3D.geojson.gz
           ├── transcript_metadata_3D.csv.gz
           └── <optional run name>/
               ├── cell_polygons_3D.geojson.gz
               └── transcript_metadata_3D.csv.gz
    ├── decoded/
       ├── tile0000_decoded_features.parquet
       └── temporary/iteration_000/tile000_temp_decoded.parquet
    ├── all_tiles_filtered_decoded_features/
       ├── decoded_features.parquet
       └── decoded_features.csv.gz
    └── mtx_output/

Metadata conventions

  • Each image directory (for example corrected_data.ome.zarr/, decon_data.ome.zarr/, feature_predictor_data.ome.zarr/, or local_sofima_flow_field.ome.zarr/) is a standalone OME-NGFF v0.5 image.
  • Local fiducial and readout images are stored in native tile coordinates. Registered fiducial/readout images are not saved as separate arrays; downstream decoding and viewer paths apply affine, chromatic, and SOFIMA transforms when aligned data are needed.
  • Readout corrected_data.ome.zarr/ is always expected after datastore creation. Readout and fiducial decon_data.ome.zarr/ are present only when deconvolution was run. Readout feature_predictor_data.ome.zarr/ is expected after preprocessing and is produced from the deconvolved image when available, otherwise from the corrected image.
  • Folder-level metadata for non-image entities (for example calibrations/, fiducial/*/round*/, readouts/*/bit*/) is stored in attributes.json.
  • In OME metadata, we only write voxel scale (scale) and original tile position (translation) when available.
  • All other datastore metadata is written into zarr.json -> extra_attributes for that image (for example bit_linker, round_linker, psf_idx, correction flags, wavelengths, transforms).
  • For opticalflow_xform_px, the dense 4D displacement field is stored only in the OME-Zarr array (0/). OME transforms are identity (scale=1, translation=0) and metadata only stores lightweight fields such as block_size and block_stride.
  • PSFs are stored as one image per channel under calibrations/psf_data/psf_XXX.ome.zarr/, which allows different PSF array sizes across channels.

DataStore API

Nearly all parameters are accessible as class properties and all data has helper functions for reading and writing. The full API reference is available at qi2labDataStore.