🐍 Python Interface

NGFF-Zarr is a Python library that provides a simple, natural interface for working with OME-Zarr data structures, creating chunked, multiscale OME-Zarr image pyramids, and reading and writing OME-Zarr multiscale image files.

NGFF-Zarr’s interface, which reflects the OME-Zarr data model, is built on Python’s built-in dataclasses and Dask arrays. It is designed to be simple, flexible, and easy to use.

Array to NGFF Image

NGFF-Zarr supports conversion of any NumPy array-like object that follows the Python Array API Standard into the OME-Zarr data model. This includes such objects as NumPy ndarrays, Dask Arrays, PyTorch Tensors, CuPy arrays, Zarr arrays, etc.

NumPy arrays with explicit byte order, including big-endian arrays, are accepted and normalized during writes so values round-trip correctly.

Convert the array to an NgffImage, which is a standard Python dataclass that represents an OME-Zarr image for a single scale.

When creating the image from the array, you can specify

  • names of the dims from {‘t’, ‘z’, ‘y’, ‘x’, ‘c’}

  • the scale, the pixel spacing for the spatial dims

  • the translation, the origin or offset of the center of the first pixel

  • a name for the image

  • and axes_units with UDUNITS-2 identifiers

>>> # Load an image as a NumPy array
>>> from imageio.v3 import imread
>>> data = imread('cthead1.png')
>>> print(type(data))
<class 'numpy.ndarray'>

Specify optional additional metadata with to_ngff_image.

>>> import ngff_zarr as nz
>>> image = nz.to_ngff_image(data,
                             dims=['y', 'x'],
                             scale={'y': 1.0, 'x': 1.0},
                             translation={'y': 0.0, 'x': 0.0})
>>> print(image)
NgffImage(
    data=dask.array<array, shape=(256, 256),
    dtype=uint8,
chunksize=(256, 256), chunktype=numpy.ndarray>,
    dims=['y', 'x'],
    scale={'y': 1.0, 'x': 1.0},
    translation={'y': 0.0, 'x': 0.0},
    name='image',
    axes_units=None,
    computed_callbacks=[]
)

The image data is nested in a lazy dask.Array and chucked.

If dims, scale, or translation are not specified, NumPy-compatible defaults are used.

Generate Multiscales

OME-Zarr represents images in a chunked, multiscale data structure. Use to_multiscales to build a task graph that will produce a chunked, multiscale image pyramid. to_multiscales has optional scale_factors and chunks parameters. An antialiasing method can also be prescribed.

>>> multiscales = nz.to_multiscales(image,
                                    scale_factors=[2,4],
                                    chunks=64)
>>> print(multiscales)
NgffMultiscales(
    images=[
        NgffImage(
            data=dask.array<rechunk-merge, shape=(256, 256), dtype=uint8,chunksize=(64, 64), chunktype=numpy.ndarray>,
            dims=['y', 'x'],
            scale={'y': 1.0, 'x': 1.0},
            translation={'y': 0.0, 'x': 0.0},
            name='image',
            axes_units=None,
            computed_callbacks=[]
        ),
        NgffImage(
            data=dask.array<rechunk-merge, shape=(128, 128), dtype=uint8,
chunksize=(64, 64), chunktype=numpy.ndarray>,
            dims=['y', 'x'],
            scale={'x': 2.0, 'y': 2.0},
            translation={'x': 0.5, 'y': 0.5},
            name='image',
            axes_units=None,
            computed_callbacks=[]
        ),
        NgffImage(
            data=dask.array<rechunk-merge, shape=(64, 64), dtype=uint8,
chunksize=(64, 64), chunktype=numpy.ndarray>,
            dims=['y', 'x'],
            scale={'x': 4.0, 'y': 4.0},
            translation={'x': 1.5, 'y': 1.5},
            name='image',
            axes_units=None,
            computed_callbacks=[]
        )
    ],
    metadata=Metadata(
        axes=[
            Axis(name='y', type='space', unit=None),
            Axis(name='x', type='space', unit=None)
        ],
        datasets=[
            Dataset(
                path='scale0/image',
                coordinateTransformations=[
                    Scale(scale=[1.0, 1.0], type='scale'),
                    Translation(
                        translation=[0.0, 0.0],
                        type='translation'
                    )
                ]
            ),
            Dataset(
                path='scale1/image',
                coordinateTransformations=[
                    Scale(scale=[2.0, 2.0], type='scale'),
                    Translation(
                        translation=[0.5, 0.5],
                        type='translation'
                    )
                ]
            ),
            Dataset(
                path='scale2/image',
                coordinateTransformations=[
                    Scale(scale=[4.0, 4.0], type='scale'),
                    Translation(
                        translation=[1.5, 1.5],
                        type='translation'
                    )
                ]
            )
        ],
        coordinateTransformations=None,
        name='image',
        version='0.4'
    ),
    scale_factors=[2, 4],
    method=<Methods.ITKWASM_GAUSSIAN: 'itkwasm_gaussian'>,
    chunks={'y': 64, 'x': 64}
)

The NgffMultiscales dataclass stores all the images and their metadata for each scale according the OME-Zarr data model. Note that the correct scale and translation for each scale are automatically computed.

Displacement and coordinate fields (v0.6)

OME-Zarr v0.6 (RFC-5) stores displacement and coordinate fields as ordinary multiscale images. The only difference from a regular image is that the vector-component axis, in the channel position, carries type="displacement" (or type="coordinate") instead of type="channel". Like a channel/component axis it is discrete (it indexes vector components rather than a continuous coordinate). Set the type with the axes_types argument of NgffImage, which maps a dimension name to its axis type:

import dask.array as da
import numpy as np
import ngff_zarr as nz

# A 2-component displacement field over a yx image: the "c" dimension holds the
# (y, x) displacement vector, one component per output spatial axis.
data = da.from_array(np.zeros((2, 256, 256), dtype=np.float32))
field = nz.NgffImage(
    data=data,
    dims=("c", "y", "x"),
    scale={"c": 1.0, "y": 1.0, "x": 1.0},
    translation={"c": 0.0, "y": 0.0, "x": 0.0},
    axes_types={"c": "displacement"},
)

multiscales = nz.to_multiscales(field)
nz.to_ome_zarr("displacement.ome.zarr", multiscales, version="0.6")

The axis type round-trips through reading and writing. It can also be set after the fact by editing the metadata directly, for example multiscales.metadata.intrinsic_coordinate_system.axes[0].type = "displacement".

OME-Zarr v0.6 also models the displacements and coordinates coordinate transformations (ngff_zarr.v06.zarr_metadata.Displacements and Coordinates) that reference such a field from a registered image. Like the other v0.6 transforms, they round-trip through reading and writing.

See RFC-5: Coordinate Systems and Transformations for the full transformation model, including how to write an image and its transformation (an affine, or a displacement/coordinate field) into a single store.

Read an OME-Zarr

To read an OME-Zarr file, use from_ngff_zarr, which returns the NgffMultiscales dataclass.

>>> multiscales = nz.from_ngff_zarr('cthead1.ome.zarr')

OME-Zarr version 0.1 to 0.6 is supported. Version 0.6 adds RFC-5 coordinate systems and transformations. The opt-in development version 0.9.dev1 additionally adopts RFC-3, that expands supported axis counts, names, types and order.

The store argument accepts:

  • A local directory path (str, pathlib.Path, or os.PathLike)

  • A remote URL string (s3://, gs://, azure://, http://, https://) with the remote extra installed – see Network Storage and Authentication

  • A .ozx / .zip archive path

  • An in-memory key-to-bytes MutableMapping (e.g. a plain dict of a store’s contents), read through a pure-Python reader (Zarr v2, or unsharded Zarr v3)

zarr-python store objects (LocalStore, MemoryStore, …) are no longer accepted – pass the path they wrap instead.

OME-Zarr Zip (.ozx) files

RFC-9 introduces support for OME-Zarr Zip (.ozx) files, which package an entire OME-Zarr hierarchy into a single ZIP archive. This format provides several benefits:

  • Single-file distribution: Share complete multiscale datasets as one portable file

  • Version metadata: OME-Zarr version embedded in ZIP comment for automatic detection

Reading local .ozx files

>>> multiscales = nz.from_ngff_zarr('cthead1.ozx')

The .ozx extension is automatically detected and handled appropriately.

Writing .ozx files

To write an OME-Zarr dataset as a .ozx file, simply use the .ozx extension:

>>> nz.to_ngff_zarr('cthead1.ozx', multiscales, version='0.5')

All RFC-9 recommendations are followed. By default, .ozx files are written using OME-Zarr version 0.5 (Zarr v3 format), which is recommended for the ZIP-based format. The OME-Zarr version is automatically embedded in the ZIP file comment for proper detection when reading.

Converting existing OME-Zarr stores to .ozx

You can easily convert an existing OME-Zarr directory store to a portable .ozx file:

>>> # Read from directory store
>>> multiscales = nz.from_ngff_zarr('cthead1.ome.zarr')
>>>
>>> # Write as .ozx file
>>> nz.to_ngff_zarr('cthead1.ozx', multiscales)

This creates a single-file archive containing the entire multiscale pyramid, making it easy to share or distribute datasets.

For direct store-to-ZIP conversion without reprocessing the data, use write_store_to_zip:

>>> from ngff_zarr.rfc9_zip import write_store_to_zip
>>>
>>> # Direct conversion of an existing store directory to .ozx
>>> write_store_to_zip('cthead1.ome.zarr', 'cthead1.ozx', version='0.5')

This is more efficient for large datasets as it copies the store contents directly without recomputing arrays.

Validate OME-Zarr metadata

To validate that an OME-Zarr’s metadata following the specification’s data model, which is used by all the programming languages in the community, use the validate optional dependency and kwarg to from_ngff_zarr.

pip install "ngff-zarr[validate]"
>>> multiscales = nz.from_ngff_zarr('cthead1.ome.zarr', validate=True)

If the metadata does not follow the data model, an error will be raised.

Metadata validation is supported for OME-Zarr version 0.1 to 0.5.

Write an OME-Zarr

To write the multiscales to OME-Zarr, use to_ngff_zarr.

nz.to_ngff_zarr("cthead1.ome.zarr", multiscales)

Use the .ome.zarr extension for local directory stores by convention.

Writing targets a local directory path (str, pathlib.Path, or os.PathLike), or a .ozx path for a zipped OME-Zarr archive. Remote URLs, in-memory mappings, and zarr-python store objects are not write targets – write to a local directory and upload or convert afterwards.

The multiscales will be computed and written out-of-core, limiting memory usage.

The zarrista backend

Writing (and local reading) is backed by zarrista, a Python Zarr implementation built on the Rust zarrs library, which writes arrays through a fast direct-write path. Outputs remain fully spec-compliant and readable by other OME-Zarr implementations: OME-Zarr 0.4 (Zarr v2) stores stay readable by zarr-python 2 and 3, and 0.5+ (Zarr v3) stores by zarr-python 3, which is the major that reads the Zarr v3 format.

The use_tensorstore keyword is a deprecated no-op kept for backwards compatibility: the fast direct-write path it used to enable (first via tensorstore, then via zarrista) is now the only write path. Passing use_tensorstore=True raises a DeprecationWarning; remove it from your calls.

The zarrista backend applies dtype canonicalization, including for big-endian NumPy arrays written to Zarr v3 stores.

Write a sharded OME-Zarr store

Sharded Zarr stores save multiple compressed chunks in a single file or blob. This can be useful for large datasets, as it can reduce the number of files in a directory.

To generate a sharded OME-Zarr store, pass the chunks_per_shard kwarg to to_ngff_zarr. Sharding requires OME-Zarr version 0.5, which uses the Zarr Format Specification 3.

This can be a single integer,

version = "0.5"
nz.to_ngff_zarr("lightsheet.ome.zarr", multiscales, chunks_per_shard=2, version=version)

This will use 2 chunks per shard for all dimensions.

Or, specify a tuple of integers for each dimension.

nz.to_ngff_zarr(
    "lightsheet.ome.zarr", multiscales, chunks_per_shard=(2, 2, 4), version=version
)

Or, specify a dictionary of integers for each dimension.

nz.to_ngff_zarr(
    "lightsheet.ome.zarr",
    multiscales,
    chunks_per_shard={"z": 4, "y": 2, "x": 2},
    version=version,
)

The resulting shard shape will be the product of the chunk shape and the chunks_per_shard shape. In this case the shard shape will be (256, 128, 128) for a chunk shape of (64, 64, 64).

Sharded stores are written through the same zarrista direct-write path as regular stores; no extra options are required.

Root attributes

Keys beside the OME metadata at the root of a store hold application metadata: a direction matrix, provenance, a processing log. from_ome_zarr reads them into multiscales.root_attributes, to_ome_zarr writes them back beside the OME keys, and an overwrite of an existing store keeps the ones already there. The OME keys themselves (multiscales, omero, ome) are derived from the multiscales and cannot be set this way.

multiscales.root_attributes = {
    "acquisition": {"direction": [0, -1, 0, 1, 0, 0, 0, 0, 1]}
}
nz.to_ome_zarr("image.ome.zarr", multiscales, version="0.5")

nz.from_ome_zarr("image.ome.zarr").root_attributes
# {'acquisition': {'direction': [0, -1, 0, 1, 0, 0, 0, 0, 1]}}

A fact known only once the data is on disk, such as the bound of a field filled region by region, goes in afterwards; existing keys are replaced and the others kept:

nz.update_root_attributes("image.ome.zarr", {"acquisition": {"max_displacement": 3.2}})

Create the store before its data

A producer that drives its own computation, such as an acquisition writer filling a volume over hours, a stitcher placing tiles as they arrive, or a GPU pipeline running one block at a time, can create the store first and fill it afterwards. With metadata_only=True, to_ome_zarr writes the OME-Zarr metadata and creates every scale array with its shape, dtype, chunks, shards and codecs, but evaluates no dask graph and writes no chunk. The call returns at once whatever the image size.

import dask.array as da

shape = (1, 4096, 8192, 8192)
chunks = (1, 64, 512, 512)
placeholder = da.zeros(shape, dtype="float32", chunks=chunks)
image = nz.to_ngff_image(placeholder, dims=["c", "z", "y", "x"])
multiscales = nz.to_multiscales(image, scale_factors=[], chunks=chunks, cache=False)
nz.to_ome_zarr("volume.ome.zarr", multiscales, version="0.5", metadata_only=True)

level0 = nz.open_array("volume.ome.zarr", multiscales.metadata.datasets[0].path)
for z in range(0, shape[1], 64):
    level0[:, z : z + 64] = compute_slab(z)

Pass cache=False to to_multiscales: the placeholder has nothing worth caching, and the default would serialize it to disk. With scale_factors=[] no pyramid graph is built; coarser levels can be appended later with start_level (below).

Choose the chunk grid from the regions the producer writes: when every region covers whole chunks (whole shards when chunks_per_shard is set), each chunk has exactly one writer, so no locking and no read-modify-write is needed, and the fill stays safe across processes and cluster ranks. The handle from open_array reads and writes regions through zarrista, and any other Zarr writer can fill the arrays as well; to_ome_zarr is not involved. Chunks that are never written read back as the fill value.

open_array also opens an array in a remote store (http(s), S3, GCS, Azure), through the same engine from_ome_zarr reads one with, and takes the same storage_options. A remote handle is read-only – filling a store region by region needs a local directory – but it reports the same shape, dtype and chunks, and serves the same windowed reads:

level0 = nz.open_array("s3://bucket/volume.ome.zarr", "scale0/image")
window = level0[0:1, 0:64, :, :]

Append coarser levels to an existing store

Once the finest level is on disk, start_level derives the next levels from it and writes them beside it. The levels below start_level are read, never rewritten; root attributes the writer does not own are kept; the multiscales entry is rewritten in full.

base = nz.from_ome_zarr("volume.ome.zarr").images[0]
multiscales = nz.to_multiscales(base, scale_factors=[2, 4], chunks=chunks)
nz.to_ome_zarr(
    "volume.ome.zarr", multiscales, version="0.5", overwrite=False, start_level=1
)

start_level requires overwrite=False, and the store must hold every level below it with the shape and dtype the multiscales describes. Running the same call again replaces the appended levels. metadata_only=True composes with it to allocate the new levels empty. The appended levels take the chunks given to to_multiscales; pass the chunks of the stored level to keep one grid. A level whose data the caller replaced in the multiscales is written as given, so a pipeline that already holds a coarse level can bring it instead of deriving it.

Read a store region by region

A chunk is the unit a reader decompresses. A window that covers part of a chunk costs the whole chunk, and a sweep that advances in steps smaller than the chunk pays that for every step. On a 256 MB volume with chunks of 64 along z, reading the whole of it in slabs 8 deep takes about four times the chunk-aligned read, and in slabs 2 deep about fifteen times.

The chunk grid is therefore chosen for the way the data will be read, and a producer that creates its store with metadata_only=True chooses both grids at once: pass to_multiscales(chunks=...) the shape of the regions the readers will ask for.

For a store whose grid is already fixed, read the aligned block that contains the windows and slice it in memory. open_array reports the grid:

level0 = nz.open_array("volume.ome.zarr", "scale0/image")
depth = level0.chunks[1]

for start in range(0, level0.shape[1], depth):
    block = level0[:, start : start + depth]  # one aligned read
    for z in range(block.shape[1]):
        plane = block[:, z]  # a view, no read

This reaches the same speed as reading the volume in aligned blocks, whatever the size of the windows the loop serves.

TIFF and OME-TIFF Files

NGFF-Zarr provides support for converting TIFF files, including multi-series OME-TIFF files, to OME-Zarr. When reading OME-TIFF files, physical size metadata (PhysicalSizeX/Y/Z and units) is automatically extracted and applied.

Convert all series from a TIFF file

from ngff_zarr import tiff_file_to_ngff_images, to_multiscales, to_ngff_zarr

# Convert all series from a multi-series TIFF
images = tiff_file_to_ngff_images("multi_series.ome.tiff")

for name, ngff_image in images:
    print(f"Series: {name}")
    print(f"  Shape: {ngff_image.data.shape}")
    print(f"  Scale: {ngff_image.scale}")
    print(f"  Units: {ngff_image.axes_units}")

    # Generate multiscales and write to OME-Zarr
    # Note: series names are automatically sanitized for filesystem safety
    multiscales = to_multiscales(ngff_image, scale_factors=[2, 4])
    to_ngff_zarr(f"{name}.ome.zarr", multiscales)

Select specific series

from ngff_zarr import tiff_file_to_ngff_images

# By index
images = tiff_file_to_ngff_images("multi_series.ome.tiff", series=0)

# By glob pattern
images = tiff_file_to_ngff_images("multi_series.ome.tiff", series="*GFP*")

# Multiple selections
images = tiff_file_to_ngff_images("multi_series.ome.tiff", series=[0, 2, "*Red*"])

OME metadata extraction

When reading OME-TIFF files, physical sizes and units are automatically extracted:

from ngff_zarr import tiff_file_to_ngff_images

images = tiff_file_to_ngff_images("sample.ome.tiff")
name, ngff_image = images[0]

# Physical sizes from OME-XML (e.g., {'x': 0.5, 'y': 0.5, 'z': 2.0})
print(f"Scale: {ngff_image.scale}")

# Units normalized to NGFF format (e.g., {'x': 'micrometer', ...})
print(f"Units: {ngff_image.axes_units}")

For more details on TIFF conversion including unit mapping and pyramidal TIFF handling, see the TIFF documentation.

High Content Screening (HCS)

NGFF-Zarr provides full support for High Content Screening data, implementing the plate and well metadata structures defined in the OME-Zarr specification. This enables working with multi-well plate data commonly used in drug discovery and high-throughput imaging.

Reading HCS Data

Use from_hcs_zarr to load HCS plate data:

# Load an HCS plate
plate = nz.from_hcs_zarr("screening_plate.ome.zarr")

print(f"Plate: {plate.metadata.name}")
print(f"Wells: {len(plate.metadata.wells)}")

# Access a specific well
well = plate.get_well("A", "1")  # Row A, Column 1
if well:
    print(f"Well A/1 has {len(well.images)} field(s)")

    # Get the first field image
    image = well.get_image(0)
    if image:
        print(f"Image shape: {image.images[0].data.shape}")

Working with Multi-field Wells

Each well can contain multiple fields of view:

well = plate.get_well("B", "2")
for field_idx in range(len(well.images)):
    image = well.get_image(field_idx)
    if image:
        # Each field is a standard multiscale image
        ngff_image = image.images[0]  # First scale level
        print(f"Field {field_idx}: {ngff_image.data.shape}")

Time Series and Acquisitions

For plates with multiple acquisitions (time points or conditions):

if plate.metadata.acquisitions:
    for acq in plate.metadata.acquisitions:
        print(f"Acquisition {acq.id}: {acq.name}")

    # Get image from specific acquisition
    well = plate.get_well("A", "1")
    image = well.get_image_by_acquisition(acquisition_id=0, field_index=0)

HCS Validation

Validate HCS metadata during loading:

# Validate against HCS schema
plate = nz.from_hcs_zarr("plate.ome.zarr", validate=True)

For more detailed examples and advanced usage, see the HCS documentation.

Convert OME-Zarr versions

To convert from OME-Zarr version 0.4, which uses the Zarr Format Specification 2, to 0.5, which uses the Zarr Format Specification 3, or vice version, specify the desired version when writing.

# Convert from 0.4 to 0.5
multiscales = from_ngff_zarr("cthead1.ome.zarr")
to_ngff_zarr("cthead1_zarr3.ome.zarr", multiscales, version="0.5")
# Convert from 0.5 to 0.4
multiscales = from_ngff_zarr("cthead1.ome.zarr")
to_ngff_zarr("cthead1_zarr2.ome.zarr", multiscales, version="0.4")

Upgrade OME-Zarr versions

The conversion above re-reads and re-writes the entire pyramid. When you only need to change the recorded specification version of an existing store, upgrade_ome_zarr does it directly in one of two modes.

In-place, metadata-only. When no output is given (or output resolves to the same store as input) and the source and target share the same underlying Zarr format – for example 0.5 to 0.6, both Zarr v3 – only the root group’s zarr.json metadata is rewritten. Every array chunk on disk is left byte-for-byte untouched, avoiding the data loss of a naive “read, erase, re-write” upgrade. In-place upgrades that cross the Zarr v2/v3 boundary in the upgrade direction (0.4 to 0.5 or 0.4 to 0.6) are also metadata-only: each array’s Zarr v3 zarr.json is given a v2 chunk-key encoding so the existing chunk binaries resolve unchanged. The obsolete Zarr v2 sidecars (.zarray, .zgroup, .zattrs) are then removed, so the upgraded store is a valid Zarr v3 / OME-Zarr 0.5 (or 0.6) store only – it is not simultaneously a valid Zarr v2 / OME-Zarr 0.4 store; only the chunk data binaries are reused, resolved through the v2 chunk-key encoding. The reverse – an in-place downgrade across that boundary (0.5/0.6 to 0.4) – cannot preserve chunk keys and raises a ValueError; pass an output store instead.

Write-to-new-store. When an output store distinct from input is given, the source is read lazily and re-written to output at the requested version through the standard write pipeline. Every transition among 0.4, 0.5, 0.6 and 0.9.dev1 works in this mode whenever the target version can express the axis model, and the source store is never erased. An RFC-3 axis model is refused below 0.9.dev1, so a store using one only converts upward.

Upgrade a 0.5 store to 0.6 in place, keeping every array chunk:

import numpy as np
import ngff_zarr as nz

# Synthesize a tiny store written at OME-Zarr 0.5 (Zarr v3).
image = nz.to_ngff_image(np.zeros((4, 32, 32), dtype=np.uint8), dims=["z", "y", "x"])
multiscales = nz.to_multiscales(image, scale_factors=[2])
nz.to_ome_zarr("image.ome.zarr", multiscales, version="0.5")

# Rewrite only the root metadata to 0.6; array chunks are left untouched.
nz.upgrade_ome_zarr("image.ome.zarr", version="0.6")

Write an upgraded 0.6 copy from a 0.4 source, leaving the source intact:

import numpy as np
import ngff_zarr as nz

# Synthesize a tiny store written at OME-Zarr 0.4 (Zarr v2).
image = nz.to_ngff_image(np.zeros((4, 32, 32), dtype=np.uint8), dims=["z", "y", "x"])
multiscales = nz.to_multiscales(image, scale_factors=[2])
nz.to_ome_zarr("image_v04.ome.zarr", multiscales, version="0.4")

# Write a new 0.6 store; 'image_v04.ome.zarr' is read lazily and never erased.
nz.upgrade_ome_zarr("image_v04.ome.zarr", "image_v06.ome.zarr", version="0.6")

Pass validate=True to validate the source metadata against the NGFF schema while reading, and overwrite=False (write-to-new-store only) to refuse to overwrite pre-existing data at output. A runnable version of both examples, which also proves the in-place upgrade leaves the array chunks byte-for-byte identical, is in py/examples/upgrade_ome_zarr_example.py.

Note that upgrade_ome_zarr upgrades a single image. To upgrade an HCS plate or a bioformats2raw container, point at each contained image by its own path (for example plate.ome.zarr/A/1/0).