A high-performance, fully async C# library for reading and writing OME-Zarr datasets with comprehensive support for multiscale images, labels, and High-Content Screening (HCS) plate data.
Features
✅ Zarr v2 & v3 Support - Automatic version detection and handling
✅ OME-Zarr 0.4, 0.5 & 0.6 - Multiscale images, labels, and HCS plates; 0.6 coordinate-system metadata is supported ✅ Remote Access - Read from HTTP/HTTPS, S3
✅ Physical Coordinates - ROI reading in real-world units (micrometers, seconds, etc.)
✅ OME-Zarr 0.6 Transform Metadata - Coordinate systems plus parsed and evaluable closed-form transforms ✅ Codecs - Bytes, transpose, Blosc, Gzip, Zstandard (zstd), and CRC32C support ✅ Memory Efficient - Chunk-level reading with streaming support
✅ Type Safe - Strongly typed metadata models and coordinate transformations
✅ Cross-Platform - .NET 10.0, works on Windows, Linux, macOS
✅ Well-Architected - Clean separation of concerns, testable, extensible
Installation
# Via NuGet
dotnet add package ZarrNET
# Or clone and build locally
git clone https://github.com/BiologyTools/Zarr.NET.git
cd ZarrNET
dotnet build
Dependencies
- .NET 10.0 or higher
- ZstdSharp.Port (managed zstd implementation)
- System.Text.Json
- AWS SDK S3 (for S3 zarr files)
Documentation
/Documentation/ZarrNET
Quick Start
Read a single 2D plane from a multiscale image
using ZarrNET.Helpers;
await using var reader = await OmeZarrReader.OpenAsync("/path/to/dataset.zarr");
var image = reader.AsMultiscaleImage();
var level = await image.OpenResolutionLevelAsync(datasetIndex: 0);
var plane = await level.ReadPlaneAsync(t: 0, c: 1, z: 5);
ushort[,] pixels = plane.As2DArray<ushort>();
Console.WriteLine($"Plane: {plane.Width}x{plane.Height}, max value: {pixels.Cast<ushort>().Max()}");
byte[] bytes = plane.ToBytes<ushort>(PixelFormat.Gray8);
Definition BloscCodec.cs:6
Read using physical coordinates (ROI in microns)
using ZarrNET.Coordinates;
var roi = new PhysicalROI(
origin: [0, 0, 5.0, 50.0, 50.0],
size: [1, 1, 1.0, 100.0, 100.0]
);
var result = await level.ReadRegionAsync(roi);
Read from remote HTTP/S3 URLs
var httpUrl = "https://example.com/datasets/image.zarr";
await using var reader = await OmeZarrReader.OpenAsync(httpUrl);
var s3Url = "https://s3.amazonaws.com/bucket/image.zarr";
await using var s3Reader = await OmeZarrReader.OpenAsync(s3Url);
var image = reader.AsMultiscaleImage();
var plane = await level.ReadPlaneAsync(t: 0, c: 0, z: 0);
Custom HTTP configuration (auth, timeouts)
using OmeZarr.Core.Zarr.Store;
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_TOKEN");
httpClient.Timeout = TimeSpan.FromMinutes(10);
var store = new HttpZarrStore("https://example.com/data.zarr", httpClient);
await using var reader = await OmeZarrReader.OpenAsync(store);
Navigate HCS Plate structure
var plate = reader.AsPlate();
Console.WriteLine($"Plate: {plate.PlateMetadata.Name}");
Console.WriteLine($"Wells: {plate.Wells.Count}");
var well = await plate.OpenWellAsync("B", "3");
var field = await well.OpenFieldAsync(0);
var level = await field.OpenResolutionLevelAsync(0);
var plane = await level.ReadPlaneAsync(c: 0, z: 0);
Work with labels (segmentation masks)
var image = reader.AsMultiscaleImage();
if (await image.HasLabelsAsync())
{
var labelGroup = await image.OpenLabelsAsync();
Console.WriteLine($"Available labels: {string.Join(", ", labelGroup.LabelNames)}");
var cellLabel = await labelGroup.OpenLabelAsync("cells");
var labelLevel = await cellLabel.OpenResolutionLevelAsync(0);
var labelPlane = await labelLevel.ReadPlaneAsync(t: 0, c: 0, z: 0);
var labelIds = labelPlane.As1DArray<uint>();
var uniqueCells = labelIds.Distinct().Count(id => id != 0);
Console.WriteLine($"Detected {uniqueCells} cells");
}
Architecture
The library is structured in clean, composable layers:
┌─────────────────────────────────────────────────────┐
│ OmeZarrReader (Public API) │
├─────────────────────────────────────────────────────┤
│ Node Tree Layer │
│ - MultiscaleNode, PlateNode, WellNode, FieldNode │
│ - ResolutionLevelNode, LabelNode │
├─────────────────────────────────────────────────────┤
│ Coordinate Transform Layer │
│ - PhysicalROI ↔ PixelRegion │
│ - CoordinateTransformService │
├─────────────────────────────────────────────────────┤
│ OME-Zarr Metadata Layer │
│ - MultiscaleMetadata, PlateMetadata, etc. │
│ - OmeAttributesParser │
├─────────────────────────────────────────────────────┤
│ Zarr Array/Group Layer │
│ - ZarrArray (chunk reading, region extraction) │
│ - ZarrGroup (tree navigation) │
├─────────────────────────────────────────────────────┤
│ Codec Pipeline │
│ - BytesCodec, GzipCodec, ZstdCodec │
│ - CodecPipeline (ordered application) │
├─────────────────────────────────────────────────────┤
│ Store Layer (I/O) │
│ - IZarrStore, LocalFileSystemStore │
└─────────────────────────────────────────────────────┘
Design Principles
- Separation of Concerns - Each layer has a single, well-defined responsibility
- No Leaky Abstractions - Zarr layer knows nothing about OME, OME layer knows nothing about I/O
- Async Throughout - All I/O operations are async from the start
- Variable Staging Pattern - Readable code with clear intermediate values
- Testable - Interfaces at key boundaries enable unit testing
Supported Features
Data Types
uint8, uint16, float32, float64, complex64, complex128
The lower-level dtype parser also understands the core fixed-size integer and floating-point Zarr dtypes, including v2 NumPy dtype strings such as "<f4" / "<f8" / "<c8" / "<c16" and v3 data type names such as "float32" / "float64" / "complex64" / "complex128".
Compression
- Gzip - Standard compression (via System.IO.Compression)
- Zstandard - High-performance compression (via ZstdSharp.Port)
- Uncompressed - Raw data
- Blosc
OME-Zarr Node Types
- Multiscale Images - Pyramidal resolution levels with coordinate transforms
- Labels - Segmentation masks with color/property metadata
- HCS Plates - Wells, fields, acquisitions
- Label Groups - Multiple label arrays per image
Zarr Versions
- Zarr v2 -
.zarray, .zattrs, .zgroup files (widely used by Fiji, napari, OMERO)
- Zarr v3 - Single
zarr.json per node (newer spec)
- Automatic version detection and handling
OME-Zarr 0.6 transformations
OME-Zarr 0.6 coordinate systems, transform endpoints, and nested transformation metadata are retained in the typed API. The point evaluator supports the closed-form transform types identity, scale, translation, mapAxis, projectAxis, affine, rotation, sequence, byDimension, and bijection.
var level = await image.OpenResolutionLevelAsync(datasetIndex: 0);
var transform = level.Dataset.CoordinateTransformations[0];
double[] output = CoordinateTransformationEvaluator.TransformForward(
transform, [12.5, 48.0]);
Definition CoordinateTransformationEvaluator.cs:3
Affine and rotation matrices may be stored inline or in a separate Zarr array. Resolve array-backed parameters before evaluating the transform:
var resolved = await TransformParameterResolver.ResolveMatrixAsync(
reader.RootGroup,
transform,
inputRank: 2,
outputRank: 2);
double[] output = CoordinateTransformationEvaluator.TransformForward(
resolved, [12.5, 48.0]);
ReadRegionAsync(PhysicalROI) returns the smallest axis-aligned source box. To produce an output grid through a rotated, sheared, or axis-mapped transform, resample into the physical region explicitly:
var output = await level.ResamplePhysicalRegionAsync(
reader.RootGroup,
new PhysicalROI(origin: [0, 0, 0, -50, -50], size: [1, 1, 1, 100, 100]),
outputShape: [1, 1, 1, 512, 512],
A region of interest expressed in physical units (e.g. micrometers). Axis order matches the multiscal...
Definition PhysicalROI.cs:9
ImageResamplingInterpolation
Interpolation used while resampling an OME-Zarr image into a physical output grid.
Definition TransformResampler.cs:10
This supports rank-preserving inline closed-form transforms and nearest, linear, or cubic B-spline image interpolation. The overload accepting reader.RootGroup resolves array-backed affine/rotation matrices and direct coordinates or displacements vector fields. Vector fields use a numerical inverse solve for pull-based resampling; nested vector-field compositions and rank-changing projections are not yet supported.
Scene metadata can be parsed and used to map points across image groups:
var scene = OmeAttributesParser.ParseScene(reader.RawAttributes!.Value);
double[] inInstrument1 = graph.
TransformPoint(pointInInstrument2, source, destination);
API Overview
Core Classes
OmeZarrReader
Entry point for opening datasets. Auto-detects node type.
var root = reader.OpenRoot();
var image = reader.AsMultiscaleImage();
var plate = reader.AsPlate();
var well = reader.AsWell();
Entry point for reading OME-Zarr datasets.
Definition OmeZarrReader.cs:31
static async Task< OmeZarrReader > OpenAsync(string pathOrUrl, CancellationToken ct=default)
Opens a Zarr store at the given path or URL and detects the OME-Zarr node type at the root.
Definition OmeZarrReader.cs:99
ResolutionLevelNode
Represents a single resolution level in a multiscale pyramid.
var level = await image.OpenResolutionLevelAsync(datasetIndex: 0);
long[] shape = level.Shape;
string dtype = level.DataType;
double[] pixelSize = level.GetPixelSize();
var plane = await level.ReadPlaneAsync(t: 0, c: 0, z: 5);
var roi = await level.ReadRegionAsync(physicalROI);
var region = await level.ReadPixelRegionAsync(pixelRegion);
await level.WriteRegionAsync(pixelRegion, data);
Low-level chunk API
For larger-than-memory and chunk-native workflows, open the underlying Zarr array and work directly with full chunks.
await using var store = new LocalFileSystemStore("/path/to/data.zarr");
var root = await ZarrGroup.OpenRootAsync(store);
var array = await root.OpenArrayAsync("0");
await foreach (var chunk in array.EnumerateChunksAsync())
{
byte[] decoded = await array.ReadChunkDecodedAsync(chunk);
await array.WriteChunkDecodedAsync(chunk, decoded);
byte[] rented = ArrayPool<byte>.Shared.Rent(decoded.Length);
try
{
await array.ReadChunkDecodedAsync(
chunk,
rented.AsMemory(0, decoded.Length),
allowBorrowedBuffer: true);
await array.WriteChunkDecodedAsync(
chunk,
rented.AsMemory(0, decoded.Length),
allowBorrowedBuffer: true);
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
byte[]? encoded = await array.ReadChunkEncodedAsync(chunk);
if (encoded is not null)
await array.WriteChunkEncodedAsync(chunk, encoded);
}
IReadOnlyList<ZarrDecodedChunkWrite> decodedWrites = GetPreparedFullChunkWrites();
await array.WriteChunksDecodedAsync(decodedWrites, maxDegreeOfParallelism: 8, allowBorrowedBuffers: true);
ZarrChunkRef.Shape gives the valid in-array extent for edge chunks. Decoded chunk buffers are padded to the full effective chunk shape, matching the region reader's fill-value behaviour. Encoded chunk access is available for non-sharded arrays; sharded arrays store logical chunks inside shard objects. For callers that need direct scheduling, ReadChunksEncodedAsync and WriteChunksEncodedAsync expose the same encoded path with caller-selected parallelism. WriteChunksDecodedAsync provides the analogous full-chunk decoded write path and can bypass encoding for native-endian bytes-only pipelines.
PlaneResult
Result of reading a 2D plane with convenience extraction methods.
var plane = await level.ReadPlaneAsync(t: 0, c: 0, z: 0);
int width = plane.Width;
int height = plane.Height;
ushort[,] pixels2D = plane.As2DArray<ushort>();
ushort[] pixels1D = plane.As1DArray<ushort>();
byte[] gray8 = plane.ToBytes<ushort>(PixelFormat.Gray8);
byte[] bgra32 = plane.ToBytes<ushort>(PixelFormat.Bgra32);
PhysicalROI and PixelRegion
Coordinate representations for ROI specification.
origin: [0, 0, 5.0, 100.0, 200.0],
size: [1, 1, 2.0, 50.0, 50.0]
);
start: [0, 0, 10, 100, 200],
end: [1, 1, 12, 150, 250]
);
A region expressed in pixel (array index) coordinates. Maps directly to ZarrArray....
Definition PhysicalROI.cs:52
Examples
Iterate through a Z-stack
var axes = level.Multiscale.Axes;
var zIndex = Array.FindIndex(axes, a => a.Name.Equals("z", StringComparison.OrdinalIgnoreCase));
var numSlices = (int)level.Shape[zIndex];
for (int z = 0; z < numSlices; z++)
{
var plane = await level.ReadPlaneAsync(t: 0, c: 0, z: z);
var pixels = plane.As1DArray<ushort>();
double meanIntensity = pixels.Average(p => (double)p);
Console.WriteLine($"Z={z}: mean intensity = {meanIntensity:F1}");
}
Read all channels as separate planes
var cIndex = Array.FindIndex(axes, a => a.Name.Equals("c", StringComparison.OrdinalIgnoreCase));
var numChannels = (int)level.Shape[cIndex];
var channels = new List<ushort[,]>();
for (int c = 0; c < numChannels; c++)
{
var plane = await level.ReadPlaneAsync(t: 0, c: c, z: 0);
channels.Add(plane.As2DArray<ushort>());
}
Choose optimal resolution level for display
var levels = await image.OpenAllResolutionLevelsAsync();
double targetMicronsPerPixel = 0.5;
var spatialAxisIndex = 3;
var bestLevel = levels
.Select((l, i) => (level: l, index: i, pixelSize: l.GetPixelSize()[spatialAxisIndex]))
.OrderBy(l => Math.Abs(l.pixelSize - targetMicronsPerPixel))
.First();
Console.WriteLine($"Using level {bestLevel.index} ({bestLevel.pixelSize:G4} µm/px)");
Process an entire HCS plate
var plate = reader.AsPlate();
foreach (var wellRef in plate.Wells)
{
var well = await plate.OpenWellAsync(wellRef.Path);
foreach (var fieldRef in well.Fields)
{
var field = await well.OpenFieldAsync(fieldRef.Path);
var level = await field.OpenResolutionLevelAsync(0);
var plane = await level.ReadPlaneAsync(c: 0, z: 0);
}
}
Save plane as image file (using ImageSharp)
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
var plane = await level.ReadPlaneAsync(t: 0, c: 0, z: 5);
byte[] pixels = plane.ToBytes<ushort>(PixelFormat.Gray8);
var img = Image.LoadPixelData<L8>(pixels, plane.Width, plane.Height);
img.SaveAsPng("output.png");
Performance Considerations
Chunk-Aligned Reads
For best performance, align ROI boundaries to chunk boundaries when possible:
var chunkShape = level.ZarrArray.Metadata.ChunkShape;
start: [0, 0, 0, 0, 0],
end: [1, 1, 1, 96, 192]
);
start: [0, 0, 0, 50, 50],
end: [1, 1, 1, 150, 150]
);
Resolution Level Selection
Use lower resolution levels for overview/navigation, full resolution for analysis:
var overview = await image.OpenResolutionLevelAsync(datasetIndex: 3);
var fullRes = await image.OpenResolutionLevelAsync(datasetIndex: 0);
Memory Management
For large datasets, process in tiles rather than loading entire planes:
int tileSize = 512;
for (int y = 0; y < height; y += tileSize)
{
for (int x = 0; x < width; x += tileSize)
{
start: [0, 0, 0, y, x],
end: [1, 1, 1, Math.Min(y + tileSize, height), Math.Min(x + tileSize, width)]
);
var tile = await level.ReadPixelRegionAsync(tileRegion);
}
}
Limitations
- HTTP store listing -
ListChildNamesAsync() works for HTTP stores with v2 .zmetadata or v3 inline consolidated metadata. Stores without either still require explicit path navigation.
- Write support to HTTP - Read-only for remote stores. Use LocalFileSystemStore for writing.
- Transform-aware image resampling - Closed-form, rank-preserving transforms (including array-backed affine/rotation matrices) support nearest, linear, and cubic B-spline output grids. Direct vector fields use a numerical inverse solve; nested vector fields and rank-changing projections remain unsupported.
Roadmap
- HTTP/S3 remote store support
- Zarr v3 transpose codec support
- AWS S3 native SDK integration (ListObjects, credentials)
- OME-Zarr writer API for creating new datasets
- Parallel chunk reading for improved performance
- Automated baseline tests for writer/reader round-trips and coordinate transforms
- Zarr v2 → v3 array conversion utility
- Recursive v2 → v3 hierarchy conversion with attributes and group metadata
- OME-Zarr 0.6 coordinate systems and standard multiscale transforms
- Closed-form 0.6 point transforms: affine, rotation, axis mapping, projection, sequences, by-dimension, and bijections
- Array-backed affine and rotation parameter matrices
- Vector-field
coordinates and displacements transforms with nearest/linear interpolation
- Cubic (
bspline-cubic) vector-field interpolation
- Transform-aware nearest/linear image resampling for rank-preserving closed-form transforms
- Array-backed affine/rotation matrix resampling
- Cubic B-spline image resampling
- Validate and extend vector-field image resampling (nested compositions)
- Rank-changing projection image resampling
- Scene metadata parsing and cross-image point transformation-graph traversal
- Consolidated metadata (
.zmetadata and v3 inline) support for HTTP stores
Contributing
Contributions welcome! Please:
- Follow the existing code style (variable staging pattern, clear separation of concerns)
- Add tests for new features
Run the suite before submitting a change:
dotnet test ZarrNET.Tests/ZarrNET.Tests.csproj
Converting a Zarr v2 array to v3
ZarrV2ToV3Converter copies decoded chunks into a portable v3 bytes pipeline. It currently converts one non-root array; converting an entire hierarchy, including group metadata and attributes, remains future work.
await ZarrV2ToV3Converter.ConvertArrayAsync(
sourceStore, "0",
destinationStore, "converted/0");
- Update documentation
- Open an issue first for major changes
License
Acknowledgments
Support
Please use image.sc forum for discussion.
Built with ❤️ for the scientific imaging community