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_pxis the flow-map spacing in reference pixels, orderedZ, Y, X.map_box_start_xyz_pxis the reference pixel coordinate of the first flow sample, orderedX, Y, Z. SOFIMA estimates patch-centered vectors, so this value is normally half the patch size, not(0, 0, 0).map_box_size_xyz_pxis the sampled flow-lattice extent frommap_box_start_xyz_pxthrough the last stored map sample, orderedX, Y, Z.reference_shape_zyx_pxrecords 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/, orlocal_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 fiducialdecon_data.ome.zarr/are present only when deconvolution was run. Readoutfeature_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 inattributes.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_attributesfor that image (for examplebit_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 asblock_sizeandblock_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.