Skip to content

Output metadata (.lattice.json)

Every image napari-lattice writes gets a sibling JSON sidecar recording how the data was produced and where it sits. For results/6h_deskewed_ROI_3.ome.tif the sidecar would be results/6h_deskewed_ROI_3.lattice.json.

The image extension is dropped rather than appended to, so the sidecar never matches a *.tif* glob and tools scanning a results folder for images will not trip over it.

Why

Pixel data carries voxel size and little else. Two things are otherwise lost at write time:

  • Provenance — the deskew angle, skew direction, coverslip rotation, scan direction and the raw→deskewed affine. The transform is computed for every run but was never saved.
  • Placement — where a cropped ROI sits in the parent volume. Every ROI file starts at voxel (0,0,0), so a set of ROIs from one acquisition could not be related to each other or to the full field of view.

Layout

{
  "schema_version": "1.0",
  "generator": { "name": "napari-lattice", "version": "3.1.1" },
  "output": {
    "path": "6h_deskewed_ROI_3.ome.tif",
    "roi_index": 3,
    "projection": null,
    "origin_zyx_px": [64.0, 1180.0, 512.0],
    "origin_zyx_um": [9.6, 176.91, 76.76],
    "origin_reference": "Voxel (0, 0, 0) of the full deskewed volume ..."
  },
  "roi": {
    "index": 3,
    "units": "Pixels",
    "bbox_yx_px": { "top": 1180.0, "left": 512.0, "bottom": 1660.0, "right": 832.0 },
    "z_range": [64, 274]
  },
  "derived": {
    "output_voxel_size_um": { "z": 0.15, "y": 0.1499, "x": 0.1499 },
    "full_output_shape_zyx": [210, 3100, 2048],
    "raw_to_deskewed_affine_zyx": [[...], [...], [...], [0, 0, 0, 1]],
    "affine_convention": "4x4 row-major homogeneous matrix in ZYX order ..."
  },
  "config": { "angle": 30.0, "skew": "Y", "save_type": "tiff", "...": "..." }
}

config — the run, in the config-file schema

The config block uses the same schema as --json-config / --yaml-config, so it is directly reusable:

# Pull the config back out of a result and re-run it
python -c "import json,sys; json.dump(json.load(open(sys.argv[1]))['config'], sys.stdout)" \
    results/6h_deskewed_ROI_3.lattice.json > rerun.json
lls-pipeline process --json-config rerun.json

Run lls-pipeline process --show-schema to see the same field set documented.

The config records absolute paths

input_image, save_dir, workflow and PSF entries are written as absolute paths from the machine that produced the data. Check before sharing sidecars outside your group if those paths are sensitive.

Three fields record a path, not the value

input_image, workflow and deconvolution.psf hold loaded objects in memory, which cannot be serialised back to the config that produced them. The sidecar records the source path instead, and each is empty when that input was supplied in memory (e.g. from the napari plugin, or a script passing an array). The rest of the config is still accurate, but that field cannot reproduce the run on its own.

One case is not merely incomplete but un-runnable: a deconvolution run whose PSFs were passed as arrays records no PSF paths, and re-parsing then fails because a validator requires one PSF per channel. Supply PSFs as paths if you want the config to be re-runnable.

origin_zyx_px — placing an output back in the parent volume

origin_zyx_px is the position of the output's voxel (0,0,0) within the full deskewed volume, in deskewed voxels; origin_zyx_um is the same in microns. Loading several ROIs into a shared coordinate system is then just a translation:

import json
from pathlib import Path
import napari, tifffile

viewer = napari.Viewer()
for sidecar in sorted(Path("results").glob("*.lattice.json")):
    meta = json.loads(sidecar.read_text())
    image = tifffile.imread(Path("results") / meta["output"]["path"])
    voxel = meta["derived"]["output_voxel_size_um"]
    viewer.add_image(
        image,
        name=f"ROI {meta['output']['roi_index']}",
        scale=(voxel["z"], voxel["y"], voxel["x"]),
        translate=meta["output"]["origin_zyx_um"],
    )

The origin is not the ROI you drew

Cropping only trims the skew axis to the ROI; the other two axes keep the bounds the clipped raw sub-block deskewed into. So the Z origin is the sub-block's minimum deskewed Z, not z_range[0], and if an ROI extends past what the raw data can produce the output starts where the data actually begins. origin_zyx_px always describes where the pixels are, which is what you want for placement — compare it against roi.bbox_yx_px if you need to know whether the request was clipped.

An uncropped run has origin [0, 0, 0]. A MIP has a null Z origin, since Z is projected away.

raw_to_deskewed_affine_zyx

A 4×4 row-major homogeneous matrix in ZYX order, mapping a raw voxel index [z, y, x, 1] to a voxel index in the full deskewed volume.

This records the transform that was applied. The saved pixels are already deskewed, so the matrix is for relating them back to the raw acquisition — not something to apply again. Multiply componentwise by output_voxel_size_um for microns.

The matrix means different things in the two geometries, so read it together with config.coverslip_rotation: true (Zeiss LLS7 and similar) is the standard deskew with the coverslip rotation folded in; false is the shear-only OPM/SOPi frame.