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
Physical pixel sizes and voxel spacing are rounded to three decimal places in microns (0.001 µm). This applies when loading and saving calibration metadata and throughout registration, fusion, decoding, segmentation, and image export. Legacy spacing values are rounded on read; existing files are updated when written again. Global fusion uses the rounded native voxel spacing, and fused OME metadata records the spacing of the fused image. The shared rounding helpers are in merfish3danalysis.utils.spacing.
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 in the NDV datastore viewer and compare the image orientation with the recorded 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
- A tile's
affine_zyx_umis its final global registration correction. Camera/stage calibration remains separate. The world mapping isglobal_correction @ camera_to_stage @ [pixel_zyx * spacing_zyx_um + origin_zyx_um, 1]. Zhuang uses the same registration and coordinate-mapping methods as the qi2lab workflow. - 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 -> attributes, alongsideome, for 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.
Image access and coordinates
Datastore images use Zarr v3 with OME-Zarr v0.5 metadata. Reads, image preparation, and synchronous writes go through yaozarrs. TensorStore is used directly only for the configured compression writer and the existing lazy/read/write-future interfaces. Metadata-only shape queries use yaozarrs metadata without opening a pixel backend. Extra attributes preserve OME metadata; local metadata updates write the local JSON because yaozarrs currently exposes read-only attributes.
Fused fiducial images and their maximum-projection TIFFs retain the downsampled segmentation grid. Cellpose masks store that grid's physical spacing, and their downsampling metadata records fused spacing divided by native spacing in ZYX order. A two-dimensional mask carries YX OME axes and YX physical scale. ROI conversion uses the saved scale difference, so downsampled mask pixels map to the same physical coordinates as decoded transcripts.
Processing entry points accept either an experiment directory containing qi2labdatastore or the datastore directory itself. Existing stores are identified by datastore_state.json; a missing input fails before creating output directories. Only conversion commands explicitly resolve a new datastore destination.
Public image readers support three modes: True returns a read future, False returns a NumPy array, and None returns a lazy backend handle opened through yaozarrs. Public image writers finish pixel writes before updating associated metadata; True returns that completed write handle, while False and None return None. The private asynchronous writer can return a pending write handle.
Voxel calibration requires three positive finite values in ZYX microns, including after rounding to stored precision. Missing optional JSON metadata returns an empty dictionary; malformed or non-object JSON raises an error. Invalid public image selections and absent images retain their None return convention. Write failures propagate and do not update completion-state caches.