Repository: seung-lab/cloud-volume (language: python)

Issue:
bug in saving precomputed meshes: unexpected keyword argument 'fuse'
Hi @william-silversmith. Hope all is good! I'm seeing a new (to me) error with cloudvolume 8.25.0 although `8.24.2` also gives this for @schlegelp.

This is from R but the python code should be obvious:
```
vol = cv$CloudVolume("precomputed://gs://manc-seg-2022-05/manc-seg-2022-05", use_https=TRUE)
vol$mesh$save(10118L, file_format='obj')
```

gives

```
── Python Exception Message ──────────────────────────────────────────────────────────────
Traceback (most recent call last):
  File "/Users/jefferis/Library/r-miniconda/envs/r-reticulate/lib/python3.8/site-packages/cloudvolume/datasource/precomputed/mesh/unsharded.py", line 295, in save
    mesh = self.get(segids, fuse=True, remove_duplicate_vertices=True)
TypeError: get() got an unexpected keyword argument 'fuse'

── R Traceback ───────────────────────────────────────────────────────────────────────────
    ▆
 1. └─fafbseg::read_cloudvolume_meshes("10118", cloudvolume.url = "precomputed://gs://manc-seg-2022-05/manc-seg-2022-05")
 2.   └─fafbseg::save_cloudvolume_meshes(...) at fafbseg/R/cloudvolume-reticulate.R:207:2
 3.     └─vol$mesh$save(10118L, file_format = "obj") at fafbseg/R/cloudvolume-reticulate.R:93:4
 4.       └─reticulate:::py_call_impl(callable, call_args$unnamed, call_args$named)
```




Discussion / hints:

Fix patch:
diff --git a/cloudvolume/__init__.py b/cloudvolume/__init__.py
index af126be6..50987992 100644
--- a/cloudvolume/__init__.py
+++ b/cloudvolume/__init__.py
@@ -70,9 +70,13 @@
 # Register plugins
 from .datasource.precomputed import register as register_precomputed
 from .datasource.graphene import register as register_graphene
+from .datasource.n5 import register as register_n5
+from .datasource.zarr import register as register_zarr
 
 register_precomputed()
 register_graphene()
+register_n5()
+register_zarr()
 
 try:
   from .datasource.boss import register as register_boss
@@ -80,13 +84,3 @@
 except ImportError:
   pass
 
-try:
-  from .datasource.n5 import register as register_n5
-  register_n5()
-except ImportError:
-  pass
-
-
-
-
-
diff --git a/cloudvolume/cloudvolume.py b/cloudvolume/cloudvolume.py
index ebbe2dd4..4ea55781 100644
--- a/cloudvolume/cloudvolume.py
+++ b/cloudvolume/cloudvolume.py
@@ -5,7 +5,7 @@
 import multiprocessing as mp
 import numpy as np
 
-from .exceptions import UnsupportedFormatError, DimensionError
+from .exceptions import UnsupportedFormatError, DimensionError, InfoUnavailableError
 from .lib import generate_random_string
 from .paths import strict_extract, to_https_protocol
 from .types import CompressType, ParallelType, CacheType, SecretsType
@@ -228,13 +228,26 @@ def __new__(cls,
     kwargs = dict(locals())
     del kwargs['cls']
 
-    path = strict_extract(cloudpath)
-    if path.format in REGISTERED_PLUGINS:
-      return REGISTERED_PLUGINS[path.format](**kwargs)
-    else:
-      raise UnsupportedFormatError(
-        "Unknown format {}".format(path.format)
-      )
+    def init(cloudpath):
+      path = strict_extract(cloudpath)
+      if path.format in REGISTERED_PLUGINS:
+        return REGISTERED_PLUGINS[path.format](**kwargs)
+      else:
+        raise UnsupportedFormatError(
+          "Unknown format {}".format(path.format)
+        )
+
+    try:
+      return init(cloudpath)
+    except InfoUnavailableError as err:
+      if 'precomputed://' not in cloudpath:
+        try:
+          return init('zarr2://' + cloudpath)
+        except:
+          raise err
+      else:
+        raise err
+
 
   @classmethod
   def create_new_info(cls, *args, **kwargs):
diff --git a/cloudvolume/datasource/__init__.py b/cloudvolume/datasource/__init__.py
index 123ffb5f..7d227405 100644
--- a/cloudvolume/datasource/__init__.py
+++ b/cloudvolume/datasource/__init__.py
@@ -1,7 +1,7 @@
 import os.path
 
 from .. import exceptions, paths
-from ..lib import yellow, Bbox, Vec, toabs
+from ..lib import yellow, Bbox, Vec, toabs, xyzrange, min2
 from ..secrets import CLOUD_VOLUME_CACHE_DIR, CLOUD_VOLUME_DIR
 
 import numpy as np
@@ -97,5 +97,50 @@ def get_cache_path_helper(base, cloudpath):
     base, path.protocol, basepath, path.layer
   ))
 
+def generate_chunks(meta, img, offset, mip):
+  """
+  Converts an image into a series of chunks
+  described by:
 
+    
+  """
+  shape = Vec(*img.shape)[:3]
+  offset = Vec(*offset)[:3]
+
+  bounds = Bbox( offset, shape + offset)
+
+  alignment_check = bounds.round_to_chunk_size(meta.chunk_size(mip), meta.voxel_offset(mip))
+
+  if not np.all(alignment_check.minpt == bounds.minpt):
+    raise AlignmentError(f"""
+      Only chunk aligned writes are supported by this function. 
+
+      Got:             {bounds}
+      Volume Offset:   {meta.voxel_offset(mip)} 
+      Nearest Aligned: {alignment_check}
+    """)
+
+  bounds = Bbox.clamp(bounds, meta.bounds(mip))
+
+  img_offset = bounds.minpt - offset
+  img_end = Vec.clamp(bounds.size3() + img_offset, Vec(0,0,0), shape)
+
+  class ChunkIterator():
+    def __len__(self):
+      csize = meta.chunk_size(mip)
+      bbox = Bbox(img_offset, img_end)
+      # round up and avoid conversion to float
+      n_chunks = (bbox.dx + csize[0] - 1) // csize[0]
+      n_chunks *= (bbox.dy + csize[1] - 1) // csize[1]
+      n_chunks *= (bbox.dz + csize[2] - 1) // csize[2]
+      return n_chunks
+    def __iter__(self):
+      for startpt in xyzrange( img_offset, img_end, meta.chunk_size(mip) ):
+        startpt = startpt.clone()
+        endpt = min2(startpt + meta.chunk_size(mip), shape)
+        spt = (startpt + bounds.minpt).astype(int)
+        ept = (endpt + bounds.minpt).astype(int)
+        yield (startpt, endpt, spt, ept)
+
+  return ChunkIterator()
 
diff --git a/cloudvolume/datasource/n5/__init__.py b/cloudvolume/datasource/n5/__init__.py
index 342670c2..9a7ade15 100644
--- a/cloudvolume/datasource/n5/__init__.py
+++ b/cloudvolume/datasource/n5/__init__.py
@@ -21,7 +21,7 @@ def create_n5(
   compress:CompressType=None, compress_level:Optional[int]=None,
   non_aligned_writes:bool=False, 
   parallel:ParallelType=1,green_threads:bool=False, 
-  secrets:SecretsType=None, cache_locking:bool = True,
+  secrets:SecretsType=None, cache_locking:bool = True, info = None,
   **kwargs # absorb graphene arguments
 ):
     path = strict_extract(cloudpath)
@@ -35,6 +35,7 @@ def create_n5(
       progress=progress,
       secrets=secrets,
       spatial_index_db=None,
+      cache_locking=cache_locking,
     )
     cache = CacheService(
       cloudpath=get_cache_path(cache, cloudpath),
@@ -43,7 +44,7 @@ def create_n5(
       compress=compress_cache,
     )
 
-    meta = N5Metadata(cloudpath, config=config, cache=cache)
+    meta = N5Metadata(cloudpath, config=config, cache=cache, info=info)
     imagesrc = N5ImageSource(
       config, meta, cache, 
       autocrop=bool(autocrop),
diff --git a/cloudvolume/datasource/n5/image.py b/cloudvolume/datasource/n5/image.py
index de2bad4f..4bd9f45b 100644
--- a/cloudvolume/datasource/n5/image.py
+++ b/cloudvolume/datasource/n5/image.py
@@ -1,3 +1,5 @@
+from typing import Dict, Tuple, Sequence, Union, Optional
+
 import re
 import os
 
@@ -12,11 +14,13 @@
 from ...lib import ( 
   colorize, red, mkdir, Vec, Bbox,  
   jsonify, generate_random_string,
-  xyzrange
+  xyzrange, BboxLikeType
 )
-from ...secrets import boss_credentials
+from ... import paths
+from ...types import CompressType, MipType
 from ...volumecutout import VolumeCutout
 from ..precomputed.image.common import shade
+from ..precomputed.image import xfer
 
 class N5ImageSource(ImageSourceInterface):
   def __init__(
@@ -124,7 +128,7 @@ def download(self, bbox, mip, parallel=1, renumber=False):
     return VolumeCutout.from_volume(self.meta, mip, renderbuffer, bbox)
 
   @readonlyguard
-  def upload(self, image, offset, mip):
+  def upload(self, image, offset, mip, parallel:int = 1):
     raise NotImplementedError()
 
   def exists(self, bbox, mip=None):
@@ -134,5 +138,30 @@ def exists(self, bbox, mip=None):
   def delete(self, bbox, mip=None):
     raise NotImplementedError()
 
-  def transfer_to(self, cloudpath, bbox, mip, block_size=None, compress=True):
-    raise NotImplementedError()
\ No newline at end of file
+  def transfer_to(
+    self,
+    cloudpath:str, 
+    bbox:BboxLikeType, 
+    mip:MipType, 
+    block_size:Optional[int] = None, 
+    compress:CompressType = True, 
+    compress_level:Optional[int] = None, 
+    encoding:Optional[str] = None,
+    sharded:Optional[bool] = None,
+  ):
+    pth = paths.extract(cloudpath)
+
+    if pth.format != self.meta.path.format and encoding is None:
+      if pth.format == "zarr":
+        encoding = "blosc"
+      elif pth.format == "precomputed":
+        encoding = "raw"
+
+    return xfer.transfer_by_rerendering(
+      self, cloudpath,
+      bbox=bbox,
+      mip=mip,
+      compress=compress,
+      compress_level=compress_level,
+      encoding=encoding,
+    )
\ No newline at end of file
diff --git a/cloudvolume/datasource/n5/metadata.py b/cloudvolume/datasource/n5/metadata.py
index 0e82f0ae..61875239 100644
--- a/cloudvolume/datasource/n5/metadata.py
+++ b/cloudvolume/datasource/n5/metadata.py
@@ -9,26 +9,63 @@ class N5Metadata(PrecomputedMetadata):
   def __init__(self, cloudpath, config, cache, info=None):
     
     # some default values, to be overwritten
-    info = PrecomputedMetadata.create_info(
+    tmp_info = PrecomputedMetadata.create_info(
       num_channels=1, layer_type='image', data_type='uint8', 
       encoding='raw', resolution=[1,1,1], voxel_offset=[0,0,0], 
       volume_size=[1,1,1]
     )
 
     super().__init__(
-      cloudpath, config, cache, info=info, provenance=None
+      cloudpath, config, cache, info=tmp_info, provenance=None
     )
     self.attributes = {
       "root": {},
       "scales": {},
     }
 
-    self.info = self.fetch_info()
+    if info:
+      self.info = info
+    else:
+      self.info = self.fetch_info()
+
     self.provenance = DataLayerProvenance()
 
+  def info_to_attributes(self):
+    return {
+      'root': {
+        'pixelResolution': {
+          'unit': 'nm',
+          'dimensions': self.info['scales'][0]['resolution'],
+        },
+        'scales': [
+          list(self.downsample_ratio(i)) for i in self.available_mips
+        ],
+      },
+      'scales': [
+        {
+          'dataType': self.data_type,
+          'compression': { 
+            'type': self.config.compress,
+            'level': self.config.compress_level,
+          },
+          'blockSize': list(scale['chunk_sizes'][0]),
+          'dimensions': list(scale['volume_size']),
+        }
+        for scale in self.scales
+      ],
+    }
+
   def commit_info(self):
     """We only are supporing read-only."""
-    pass
+    self.attributes = self.info_to_attributes()
+
+    cf = CloudFiles(self.cloudpath, secrets=self.config.secrets)
+    cf.put_json("attributes.json", self.attributes["root"])
+
+    cf.put_jsons([
+      (cf.join(f"s{i}", "attributes.json"), scale)
+      for i, scale in enumerate(self.attributes["root"]["scales"]) 
+    ])
 
   def fetch_info(self):
     cf = CloudFiles(self.cloudpath, secrets=self.config.secrets)
@@ -51,11 +88,15 @@ def fetch_info(self):
     # should all be the same
     data_type = scale_attrs[0]["dataType"] 
 
+    encoding = scale_attrs[0]["compression"]["type"]
+    if encoding in ["gzip", "br", "lzma", "xz", "bz2"]:
+      encoding = "raw"
+
     info = PrecomputedMetadata.create_info(
       num_channels=1,
       layer_type="image",
       data_type=data_type,
-      encoding=scale_attrs[0]["compression"]["type"],
+      encoding=encoding,
       resolution=resolution,
       voxel_offset=[0,0,0],
       volume_size=scale_attrs[0]["dimensions"][:3],
diff --git a/cloudvolume/datasource/precomputed/image/__init__.py b/cloudvolume/datasource/precomputed/image/__init__.py
index c94e0699..1d9c3910 100644
--- a/cloudvolume/datasource/precomputed/image/__init__.py
+++ b/cloudvolume/datasource/precomputed/image/__init__.py
@@ -23,6 +23,7 @@
 from cloudvolume.lru import LRU
 from cloudvolume.scheduler import schedule_jobs, DEFAULT_THREADS
 from ....types import CompressType, MipType
+from .... import paths
 from ....lib import Bbox, Vec, sip, first, BboxLikeType, toiter
 from .... import sharedmemory, chunks
 
@@ -521,19 +522,29 @@ def transfer_to(
     if sharded is None:
       sharded = self.is_sharded(mip)
 
-    if not sharded and self.is_sharded(mip):
+    pth = paths.extract(cloudpath)
+    if self.meta.path.format != pth.format:
+      return xfer.transfer_by_rerendering(
+        self, cloudpath,
+        bbox=bbox,
+        mip=mip,
+        compress=compress,
+        compress_level=compress_level,
+        encoding=encoding,
+      )
+    elif not sharded and self.is_sharded(mip):
       return xfer.transfer_any_to_unsharded(
-        self, cloudpath,         
-        bbox=bbox, 
+        self, cloudpath,
+        bbox=bbox,
         mip=mip,
-        compress=compress, 
-        compress_level=compress_level, 
+        compress=compress,
+        compress_level=compress_level,
         encoding=encoding,
       )
     elif sharded and self.is_sharded(mip):
       return xfer.transfer_sharded_to_sharded(
-        self, cloudpath, 
-        bbox=bbox, 
+        self, cloudpath,
+        bbox=bbox,
         mip=mip, 
         block_size=block_size,
         compress=compress, 
@@ -543,10 +554,10 @@ def transfer_to(
     elif not sharded and not self.is_sharded(mip):
       return xfer.transfer_unsharded_to_unsharded(
         self, cloudpath,
-        bbox=bbox, 
+        bbox=bbox,
         mip=mip,
         block_size=block_size,
-        compress=compress, 
+        compress=compress,
         compress_level=compress_level, 
         encoding=encoding,
       )
diff --git a/cloudvolume/datasource/precomputed/image/common.py b/cloudvolume/datasource/precomputed/image/common.py
index d75555b3..f0db5e9c 100644
--- a/cloudvolume/datasource/precomputed/image/common.py
+++ b/cloudvolume/datasource/precomputed/image/common.py
@@ -240,7 +240,7 @@ def morton_code_to_gridpt(code, grid_size):
   return gridpt
 
 
-def shade(dest_img, dest_bbox, src_img, src_bbox):
+def shade(dest_img, dest_bbox, src_img, src_bbox, channel=None):
   """
   Shade dest_img at coordinates dest_bbox using the
   image contained in src_img at coordinates src_bbox.
@@ -248,6 +248,8 @@ def shade(dest_img, dest_bbox, src_img, src_bbox):
   The buffer will only be painted in the overlapping
   region of the content.
 
+  Note: channel is useful for zarr, not precomputed
+
   Returns: void
   """
   if not Bbox.intersects(dest_bbox, src_bbox) or src_img is None:
@@ -266,12 +268,24 @@ def shade(dest_img, dest_bbox, src_img, src_bbox):
   while src_img.ndim < 4:
     src_img = src_img[..., np.newaxis]
   
-  dest_img[ 
-    dest_minpt[0]:dest_maxpt[0],
-    dest_minpt[1]:dest_maxpt[1],
-    dest_minpt[2]:dest_maxpt[2],
-  ] = src_img[
-    istart[0]:iend[0], 
-    istart[1]:iend[1],
-    istart[2]:iend[2]
-  ]
+  if channel:
+    dest_img[ 
+      dest_minpt[0]:dest_maxpt[0],
+      dest_minpt[1]:dest_maxpt[1],
+      dest_minpt[2]:dest_maxpt[2],
+      channel
+    ] = src_img[
+      istart[0]:iend[0], 
+      istart[1]:iend[1],
+      istart[2]:iend[2]
+    ]
+  else:
+    dest_img[ 
+      dest_minpt[0]:dest_maxpt[0],
+      dest_minpt[1]:dest_maxpt[1],
+      dest_minpt[2]:dest_maxpt[2],
+    ] = src_img[
+      istart[0]:iend[0], 
+      istart[1]:iend[1],
+      istart[2]:iend[2]
+    ]
diff --git a/cloudvolume/datasource/precomputed/image/tx.py b/cloudvolume/datasource/precomputed/image/tx.py
index 885163df..1c05e7fe 100644
--- a/cloudvolume/datasource/precomputed/image/tx.py
+++ b/cloudvolume/datasource/precomputed/image/tx.py
@@ -20,7 +20,7 @@
 
 import cloudvolume.sharedmemory as shm
 
-from ... import check_grid_aligned
+from ... import check_grid_aligned, generate_chunks
 from .common import (
   parallel_execution, chunknames, shade
 ) 
@@ -403,44 +403,3 @@ def process_and_update(i, *args, **kwargs):
   )
 
   return len(chunk_ranges)
-
-def generate_chunks(meta, img, offset, mip):
-  shape = Vec(*img.shape)[:3]
-  offset = Vec(*offset)[:3]
-
-  bounds = Bbox( offset, shape + offset)
-
-  alignment_check = bounds.round_to_chunk_size(meta.chunk_size(mip), meta.voxel_offset(mip))
-
-  if not np.all(alignment_check.minpt == bounds.minpt):
-    raise AlignmentError(f"""
-      Only chunk aligned writes are supported by this function. 
-
-      Got:             {bounds}
-      Volume Offset:   {meta.voxel_offset(mip)} 
-      Nearest Aligned: {alignment_check}
-    """)
-
-  bounds = Bbox.clamp(bounds, meta.bounds(mip))
-
-  img_offset = bounds.minpt - offset
-  img_end = Vec.clamp(bounds.size3() + img_offset, Vec(0,0,0), shape)
-
-  class ChunkIterator():
-    def __len__(self):
-      csize = meta.chunk_size(mip)
-      bbox = Bbox(img_offset, img_end)
-      # round up and avoid conversion to float
-      n_chunks = (bbox.dx + csize[0] - 1) // csize[0]
-      n_chunks *= (bbox.dy + csize[1] - 1) // csize[1]
-      n_chunks *= (bbox.dz + csize[2] - 1) // csize[2]
-      return n_chunks
-    def __iter__(self):
-      for startpt in xyzrange( img_offset, img_end, meta.chunk_size(mip) ):
-        startpt = startpt.clone()
-        endpt = min2(startpt + meta.chunk_size(mip), shape)
-        spt = (startpt + bounds.minpt).astype(int)
-        ept = (endpt + bounds.minpt).astype(int)
-        yield (startpt, endpt, spt, ept)
-
-  return ChunkIterator()
diff --git a/cloudvolume/datasource/precomputed/image/xfer.py b/cloudvolume/datasource/precomputed/image/xfer.py
index b93b30af..c9815458 100644
--- a/cloudvolume/datasource/precomputed/image/xfer.py
+++ b/cloudvolume/datasource/precomputed/image/xfer.py
@@ -8,7 +8,7 @@
 
 from cloudvolume import lib, exceptions
 from ....types import CompressType, MipType
-from ....lib import Bbox, Vec, sip, first, BboxLikeType, toiter
+from ....lib import Bbox, Vec, sip, first, BboxLikeType, toiter, xyzrange
 from .... import chunks
 
 from ... import autocropfn, readonlyguard, ImageSourceInterface
@@ -44,6 +44,44 @@ def create_destination(source, cloudpath, mip, encoding):
 
   return destvol
 
+def transfer_by_rerendering(
+  source,
+  cloudpath:str,
+  bbox:BboxLikeType,
+  mip:int,
+  compress:CompressType = None,
+  compress_level:Optional[int] = None,
+  encoding:Optional[str] = None,
+):
+  from cloudvolume import CloudVolume
+
+  dest_cv = create_destination(source, cloudpath, mip, encoding)
+  dest_cv.commit_info()
+  dest_cv.progress = False
+  mip = dest_cv.mip
+
+  progress = source.config.progress
+
+  source.config.progress = False
+
+  shape = np.array(dest_cv.chunk_size * 4)
+
+  bbox = bbox.expand_to_chunk_size(dest_cv.chunk_size, offset=dest_cv.voxel_offset)
+
+  grid_box = bbox / shape
+  grid_size = grid_box.size()
+  total = int(grid_size[0] * grid_size[1] * grid_size[2])
+
+  for gx,gy,gz in tqdm(xyzrange(grid_size), disable=(not progress), total=total):
+    gpt = Vec(gx,gy,gz, dtype=int)
+    bbx = Bbox(gpt * shape, (gpt+1) * shape) + bbox.minpt
+
+    if dest_cv.meta.path.format == "precomputed":
+      bbx = Bbox.clamp(bbx, dest_cv.bounds)
+    dest_cv[bbx] = source.download(bbx, mip=mip)
+
+  source.config.progress = progress
+
 def transfer_any_to_unsharded(
   source,
   cloudpath:str,
@@ -54,10 +92,6 @@ def transfer_any_to_unsharded(
   encoding:Optional[str] = None,
 ):
   """
-  Create a disposable in-memory CloudVolume (mem://) containing
-  the requested cutout region in the unsharded precomputed
-  format. The source volume may be sharded or unsharded.
-
   You can specify an alternative encoding and compression 
   settings for the new volume.
   """
diff --git a/cloudvolume/datasource/precomputed/mesh/unsharded.py b/cloudvolume/datasource/precomputed/mesh/unsharded.py
index b9ae9c2c..b080c578 100644
--- a/cloudvolume/datasource/precomputed/mesh/unsharded.py
+++ b/cloudvolume/datasource/precomputed/mesh/unsharded.py
@@ -292,27 +292,51 @@ def save(self, segids, filepath=None, file_format='ply'):
     """
     segids = toiter(segids)
 
-    mesh = self.get(segids, fuse=True, remove_duplicate_vertices=True)
-
-    if file_format == 'obj':
-      data = mesh.to_obj()
-    elif file_format == 'ply':
-      data = mesh.to_ply()
-    elif file_format == 'precomputed':
-      data = mesh.to_precomputed()
+    kwargs = {
+      "fuse": True,
+      "remove_duplicate_vertices": True,
+    }
+
+    if self.meta.is_sharded():
+      kwargs = {}
+
+    mesh = self.get(segids, **kwargs)
+
+    def to_fmt(mesh):
+      if file_format == 'obj':
+        return mesh.to_obj()
+      elif file_format == 'ply':
+        return mesh.to_ply()
+      elif file_format == 'precomputed':
+        return mesh.to_precomputed()
+      else:
+        raise NotImplementedError('Only .obj, .ply, and precomputed are currently supported.')
+
+    if not self.meta.is_sharded():
+      if not filepath:
+        filepath = str(segids[0]) + "." + file_format
+        if len(segids) > 1:
+          filepath = "{}_{}.{}".format(segids[0], segids[-1], file_format)
+
+      data = to_fmt(mesh)
+      try:
+        filepath.write(data)
+      except AttributeError:
+        with open(filepath, 'wb') as f:
+          f.write(data)
     else:
-      raise NotImplementedError('Only .obj, .ply, and precomputed are currently supported.')
-
-    if not filepath:
-      filepath = str(segids[0]) + "." + file_format
-      if len(segids) > 1:
-        filepath = "{}_{}.{}".format(segids[0], segids[-1], file_format)
-
-    try:
-      filepath.write(data)
-    except AttributeError:
-      with open(filepath, 'wb') as f:
-        f.write(data)
+      path = filepath
+      for segid, m in mesh.items():
+        if not filepath:
+          path = f"{segid}.{file_format}"
+
+        data = to_fmt(m)
+        try:
+          filepath.write(data)
+        except AttributeError:
+          with open(path, 'wb') as f:
+            f.write(data)
+    
 
   def get_bbox(self, bbox):
     if self.spatial_index is None:
diff --git a/cloudvolume/datasource/precomputed/metadata.py b/cloudvolume/datasource/precomputed/metadata.py
index ea02062c..148d433c 100644
--- a/cloudvolume/datasource/precomputed/metadata.py
+++ b/cloudvolume/datasource/precomputed/metadata.py
@@ -137,7 +137,7 @@ def create_info(cls,
  
     # add mip levels
     # the max_mip should be inclusive
-    for mip in range(max_mip + 1):
+    for mip in range(1, max_mip + 1):
       cls.add_scale(None, factor ** mip, encoding=encoding, chunk_size=chunk_size, info=info)
 
     if encoding == 'compressed_segmentation':
diff --git a/cloudvolume/datasource/zarr/__init__.py b/cloudvolume/datasource/zarr/__init__.py
new file mode 100644
index 00000000..abd1382b
--- /dev/null
+++ b/cloudvolume/datasource/zarr/__init__.py
@@ -0,0 +1,65 @@
+from typing import Optional
+
+from .image import ZarrImageSource
+from .metadata import ZarrMetadata
+
+from ...frontends.precomputed import CloudVolumePrecomputed
+
+from .. import get_cache_path
+from ...cacheservice import CacheService
+from ...cloudvolume import (
+  register_plugin, SharedConfiguration,
+  CompressType, ParallelType, CacheType,
+  SecretsType
+)
+from ...paths import strict_extract
+
+def create_zarr(
+  cloudpath:str, mip:int=0, bounded:bool=True, autocrop:bool=False,
+  fill_missing:bool=False, cache:CacheType=False, compress_cache:CompressType=None,
+  cdn_cache:bool=True, progress:bool=False, info:Optional[dict]=None,
+  compress:CompressType=None, compress_level:Optional[int]=None,
+  non_aligned_writes:bool=False, delete_black_uploads:bool=False,
+  parallel:ParallelType=1,green_threads:bool=False, 
+  secrets:SecretsType=None, cache_locking:bool = True,
+  **kwargs # absorb graphene arguments
+):
+    path = strict_extract(cloudpath)
+    config = SharedConfiguration(
+      cdn_cache=cdn_cache,
+      compress=compress,
+      compress_level=None,
+      green=green_threads,
+      mip=mip,
+      parallel=parallel,
+      progress=progress,
+      secrets=secrets,
+      spatial_index_db=None,
+      cache_locking=cache_locking,
+    )
+    cache = CacheService(
+      cloudpath=get_cache_path(cache, cloudpath),
+      enabled=bool(cache),
+      config=config,
+      compress=compress_cache,
+    )
+
+    meta = ZarrMetadata(cloudpath, config=config, cache=cache, info=info)
+    imagesrc = ZarrImageSource(
+      config, meta, cache, 
+      autocrop=bool(autocrop),
+      bounded=bool(bounded),
+      non_aligned_writes=bool(non_aligned_writes),
+      fill_missing=bool(fill_missing),
+    )
+
+    return CloudVolumePrecomputed(
+      meta, cache, config, 
+      imagesrc, mesh=None, skeleton=None,
+      mip=mip
+    )
+
+def register():
+  register_plugin('zarr', create_zarr)
+  register_plugin('zarr2', create_zarr)
+  register_plugin('zarr3', create_zarr)
\ No newline at end of file
diff --git a/cloudvolume/datasource/zarr/image.py b/cloudvolume/datasource/zarr/image.py
new file mode 100644
index 00000000..549124c3
--- /dev/null
+++ b/cloudvolume/datasource/zarr/image.py
@@ -0,0 +1,316 @@
+from typing import Optional
+
+import copy
+import re
+import os
+
+import numpy as np
+from cloudfiles import CloudFiles
+
+from .. import (
+  autocropfn, readonlyguard, 
+  ImageSourceInterface, check_grid_aligned,
+  generate_chunks
+)
+
+from ...types import CompressType, MipType
+from ... import compression
+from ... import chunks
+from ... import exceptions 
+from ...lib import ( 
+  colorize, red, mkdir, Vec, Bbox, BboxLikeType, 
+  jsonify, generate_random_string,
+  xyzrange
+)
+from ...paths import extract
+from ...volumecutout import VolumeCutout
+from ..precomputed.image.common import shade
+from ..precomputed.image import xfer
+
+
+class ZarrImageSource(ImageSourceInterface):
+  def __init__(
+    self, config, meta, cache,
+    autocrop=False, bounded=True,
+    non_aligned_writes=False,
+    delete_black_uploads=False,
+    fill_missing=False,
+    readonly=False,
+  ):
+    self.config = config
+    self.meta = meta 
+    self.cache = cache 
+
+    self.autocrop = bool(autocrop)
+    self.bounded = bool(bounded)
+    self.fill_missing = bool(fill_missing)
+    self.non_aligned_writes = bool(non_aligned_writes)
+    self.readonly = bool(readonly)
+
+  def decode_chunk(self, binary, mip, filename, default_shape):
+    if binary is None:
+      if self.fill_missing:
+        return None
+      else:
+        raise exceptions.EmptyVolumeException(f"{filename} is missing.")
+
+    import blosc
+    arr = np.frombuffer(blosc.decompress(binary), dtype=self.meta.dtype)
+    return arr.reshape(default_shape, order=self.meta.order(mip))
+
+  def download(
+    self, 
+    bbox:BboxLikeType, 
+    mip:MipType, 
+    parallel:int = 1, 
+    renumber:bool = False, 
+    label:Optional[int] = None,
+    t:int = 0,
+  ) -> VolumeCutout:
+    if parallel != 1:
+      raise ValueError("Only parallel=1 is supported for zarr.")
+    elif renumber != False:
+      raise ValueError("Only renumber=False is supported for zarr.")
+
+    bounds = Bbox.clamp(bbox, self.meta.bounds(mip))
+
+    if self.autocrop:
+      image, bounds = autocropfn(self.meta, image, bounds, mip)
+    
+    if bounds.subvoxel():
+      raise exceptions.EmptyRequestException(f'Requested less than one pixel of volume. {bounds}')
+
+    cf = CloudFiles(
+      self.meta.cloudpath, 
+      progress=self.config.progress, 
+      secrets=self.config.secrets,
+      green=self.config.green,
+    )
+
+    cv_chunk_size = self.meta.chunk_size(mip)
+
+    realized_bbox = bounds.expand_to_chunk_size(cv_chunk_size)
+    grid_bbox = realized_bbox // cv_chunk_size
+
+    sep = self.meta.dimension_separator(mip)
+
+    tchunk = int(t / self.meta.num_time_chunks(mip))
+
+    if self.meta.order(mip) == "C":
+      paths = [
+        cf.join(str(mip), sep.join([ str(tchunk), str(c), str(z), str(y), str(x) ]))
+        for x,y,z in xyzrange(grid_bbox.minpt, grid_bbox.maxpt)
+        for c in range(self.meta.num_channels)
+      ]
+    else:
+      paths = [
+        cf.join(str(mip), sep.join([ str(x), str(y), str(z), str(c), str(tchunk) ]))
+        for x,y,z in xyzrange(grid_bbox.minpt, grid_bbox.maxpt)
+        for c in range(self.meta.num_channels)
+      ]
+
+    all_chunks = cf.get(paths, parallel=parallel, return_dict=True)
+    shape = list(bounds.size3()) + [ self.meta.num_channels ]
+
+    if self.meta.background_color(mip) == 0:
+      renderbuffer = np.zeros(shape=shape, dtype=self.meta.dtype, order="F")
+    else:
+      renderbuffer = np.full(
+        shape=shape, fill_value=self.meta.background_color(mip), 
+        dtype=self.meta.dtype, order="F",
+      )
+
+    regexp = self.meta.filename_regexp(mip)
+
+    axis_mapping = self.meta.zarr_axes_to_cv_axes()
+
+    tslice = t - tchunk * self.meta.time_chunk_size(mip)
+
+    for fname, binary in all_chunks.items():
+      m = re.search(regexp, fname).groupdict()
+      assert mip == int(m["mip"])
+      gridpoint = Vec(*[ int(i) for i in [ m["x"], m["y"], m["z"] ] ])
+      chunk_bbox = Bbox(gridpoint, gridpoint + 1) * cv_chunk_size
+      chunk_bbox = Bbox.clamp(chunk_bbox, self.meta.bounds(mip))
+      chunk = self.decode_chunk(binary, mip, fname, self.meta.zarr_chunk_size(mip))
+      if chunk is None:
+        continue
+      chunk = np.transpose(chunk, axes=axis_mapping)[...,tslice]
+      slcs = (chunk_bbox - chunk_bbox.minpt).to_slices()
+      shade(renderbuffer, bounds, chunk[slcs], chunk_bbox, channel=int(m["c"]))
+
+    data = VolumeCutout.from_volume(self.meta, mip, renderbuffer, bounds)
+
+    if label is not None:
+      return data == label
+
+    return data
+
+  @readonlyguard
+  def upload(self, image, offset, mip, parallel=1, t=0):
+    import blosc
+
+    if not np.issubdtype(image.dtype, np.dtype(self.meta.dtype).type):
+      raise ValueError(f"""
+        The uploaded image data type must match the volume data type. 
+
+        Volume: {self.meta.dtype}
+        Image: {image.dtype}
+        """
+      )
+
+    shape = Vec(*image.shape)[:3]
+    offset = Vec(*offset)[:3]
+    bounds = Bbox( offset, shape + offset)
+
+    is_aligned = check_grid_aligned(
+      self.meta, image, bounds, mip, 
+      throw_error=True, # (self.non_aligned_writes == False)
+    )
+
+    expanded = bounds.expand_to_chunk_size(self.meta.chunk_size(mip), self.meta.voxel_offset(mip))
+    all_chunknames = self._chunknames(
+      expanded, self.meta.bounds(mip), 
+      mip, self.meta.chunk_size(mip),
+      t=t,
+    )
+
+    all_chunks = generate_chunks(self.meta, image, offset, mip)
+    order = self.meta.order(mip)
+
+    to_upload = []
+
+    axis_mapping = self.meta.cv_axes_to_zarr_axes()
+
+    compressor_args = copy.deepcopy(self.meta.zarrays[mip]["compressor"])
+    compressor_args.pop("id", None)
+    compressor_args.pop("blocksize", None)
+
+    def all_chunks_by_channel(all_chunks):
+      for ispt, iept, vol_spt, vol_ept in all_chunks:
+        for c in range(self.meta.num_channels):
+          yield image[ ispt.x:iept.x, ispt.y:iept.y, ispt.z:iept.z, c ]
+
+    for filename, imgchunk in zip(all_chunknames, all_chunks_by_channel(all_chunks)):
+      zarr_imgchunk = np.transpose(imgchunk[..., np.newaxis], axes=axis_mapping)
+      binary = zarr_imgchunk.tobytes(order)
+      del zarr_imgchunk
+      del imgchunk
+
+      binary = blosc.compress(binary, **compressor_args)
+      to_upload.append(
+        (filename, binary)
+      )
+
+    CloudFiles(self.meta.cloudpath).puts(to_upload)
+
+  def _chunknames(self, bbox, volume_bbox, mip, chunk_size, t):
+    sep = self.meta.dimension_separator(mip)
+    cf = CloudFiles(self.meta.cloudpath)
+    num_channels = self.meta.num_channels
+
+    tchunk = int(t / self.meta.num_time_chunks(mip))
+
+    class ZarrChunkNamesIterator():
+      def __len__(self):
+        # round up and avoid conversion to float
+        n_chunks = (bbox.dx + chunk_size[0] - 1) // chunk_size[0]
+        n_chunks *= (bbox.dy + chunk_size[1] - 1) // chunk_size[1]
+        n_chunks *= (bbox.dz + chunk_size[2] - 1) // chunk_size[2]
+        return n_chunks
+      def __iter__(self):
+        nonlocal volume_bbox
+        volume_bbox = Bbox.expand_to_chunk_size(volume_bbox, chunk_size)
+        volume_grid = volume_bbox // chunk_size
+        bbox_grid = bbox // chunk_size
+
+        for x,y,z in xyzrange(bbox_grid.minpt, bbox_grid.maxpt):
+          for c in range(num_channels):
+            filename = sep.join([
+              tchunk, str(c), str(z), str(y), str(x)
+            ])
+            yield cf.join(str(mip), filename)
+
+    return ZarrChunkNamesIterator()
+
+  def exists(self, bbox, mip=None, t=0):
+    if mip is None:
+      mip = self.config.mip
+
+    bounds = Bbox.clamp(bbox, self.meta.bounds(mip))
+
+    if self.autocrop:
+      image, bounds = autocropfn(self.meta, image, bounds, mip)
+    
+    if bounds.subvoxel():
+      raise exceptions.EmptyRequestException(f'Requested less than one pixel of volume. {bounds}')
+
+    expanded = bounds.expand_to_chunk_size(
+      self.meta.chunk_size(mip), self.meta.voxel_offset(mip)
+    )
+    all_chunknames = self._chunknames(
+      expanded, self.meta.bounds(mip), 
+      mip, self.meta.chunk_size(mip), 
+      t=t
+    )
+
+    cf = CloudFiles(
+      self.meta.cloudpath, 
+      progress=self.config.progress, 
+      secrets=self.config.secrets,
+      green=self.config.green,
+    )
+
+    return cf.exists(all_chunknames)
+
+  @readonlyguard
+  def delete(self, bbox, mip=None, t=0):
+    if mip is None:
+      mip = self.config.mip
+
+    if mip in self.meta.locked_mips():
+      raise exceptions.ReadOnlyException(
+        f"MIP {mip} is currently write locked. If this should not be the case, "
+        f"run vol.meta.unlock_mips({mip})."
+      )
+
+    bbox = Bbox.create(bbox, self.meta.bounds(mip), bounded=True)
+    realized_bbox = bbox.expand_to_chunk_size(
+      self.meta.chunk_size(mip), offset=self.meta.voxel_offset(mip)
+    )
+    all_chunknames = self._chunknames(
+      realized_bbox, self.meta.bounds(mip), 
+      mip, self.meta.chunk_size(mip),
+      t=t
+    )
+
+    cf = CloudFiles(self.meta.cloudpath, progress=self.config.progress, secrets=self.config.secrets)
+    cf.delete(all_chunknames)
+
+  def transfer_to(
+    self,
+    cloudpath:str, 
+    bbox:BboxLikeType, 
+    mip:MipType, 
+    block_size:Optional[int] = None, 
+    compress:CompressType = True, 
+    compress_level:Optional[int] = None, 
+    encoding:Optional[str] = None,
+    sharded:Optional[bool] = None,
+  ):
+    pth = extract(cloudpath)
+
+    if pth.format != self.meta.path.format and encoding is None:
+      if pth.format == "n5":
+        encoding = "gzip"
+      elif pth.format == "precomputed":
+        encoding = "raw"
+
+    return xfer.transfer_by_rerendering(
+      self, cloudpath,
+      bbox=bbox,
+      mip=mip,
+      compress=compress,
+      compress_level=compress_level,
+      encoding=encoding,
+    )
\ No newline at end of file
diff --git a/cloudvolume/datasource/zarr/metadata.py b/cloudvolume/datasource/zarr/metadata.py
new file mode 100644
index 00000000..2583b2a0
--- /dev/null
+++ b/cloudvolume/datasource/zarr/metadata.py
@@ -0,0 +1,649 @@
+import re
+import os
+
+import numpy as np
+
+from cloudfiles import CloudFiles
+
+from cloudvolume.datasource.precomputed.metadata import PrecomputedMetadata
+from cloudvolume.lib import jsonify, Vec, Bbox
+
+from ... import exceptions
+from ...provenance import DataLayerProvenance
+
+CV_TO_ZARR_DTYPE = {
+  "int8": "|i1",
+  "int16": "<i2",
+  "int32": "<i4",
+  "int64": "<i8",
+
+  "uint8": "|u1",
+  "uint16": "<u2",
+  "uint32": "<u4",
+  "uint64": "<u8",
+
+  "float32": "<f4",
+  "float64": "<f8",
+}
+
+class ZarrMetadata(PrecomputedMetadata):
+  def __init__(self, cloudpath, config, cache,  info=None):
+    
+    orig_info = info
+
+    # some default values, to be overwritten
+    if not info:
+      info = PrecomputedMetadata.create_info(
+        num_channels=1, layer_type='image', data_type='uint8', 
+        encoding='raw', resolution=[1,1,1], voxel_offset=[0,0,0], 
+        volume_size=[1,1,1]
+      )
+
+    super().__init__(
+      cloudpath, config, cache, info=info, provenance=None
+    )
+
+    self.zarrays = []
+    self.zattrs = self.default_zattrs()
+
+    if orig_info is None:
+      self.info = self.fetch_info()
+    else:
+      self.render_zarr_metadata()
+
+    self.provenance = DataLayerProvenance()
+
+  def default_zattrs(self):
+    return {
+      "multiscales": [
+        {
+          "axes": [
+            {
+              "name": "t",
+              "type": "time",
+              "unit": "millisecond"
+            },
+            {
+              "name": "c",
+              "type": "channel"
+            },
+            {
+              "name": "z",
+              "type": "space",
+              "unit": "micrometer"
+            },
+            {
+              "name": "y",
+              "type": "space",
+              "unit": "micrometer"
+            },
+            {
+              "name": "x",
+              "type": "space",
+              "unit": "micrometer"
+            }
+          ],
+          "datasets": [
+            
+          ],
+          "name": "/",
+          "version": "0.4"
+        }
+      ],
+      "omero": {
+        "channels": [ {"active": True} ] * self.num_channels
+      }
+    }
+
+  def spatial_resolution_in_nm(self, mip, zattrs = None, zarrays = None):
+    if zarrays is None:
+      zarrays = self.zarrays
+    if zattrs is None:
+      zattrs = self.zattrs
+
+    def unit2factor(unit):
+      if unit == "meter":
+        return 1
+      elif unit == "centimeter":
+        return 1e-2
+      elif unit == "millimeter":
+        return 1e-3
+      elif unit == "micrometer":
+        return 1e-6
+      elif unit == "nanometer":
+        return 1e-9
+      elif unit == "picometer":
+        return 1e-12
+      else:
+        raise ValueError(f"unit not supported: {unit}")
+
+    scale_factors = np.ones([3], dtype=np.float32)
+    positions = [0,0,0]
+    for i, axis in enumerate(self.zattrs["multiscales"][0]["axes"]):
+      if axis["type"] != "space":
+        continue
+      
+      if axis["name"] == "x":
+        scale_factors[0] = unit2factor(axis["unit"])
+        positions[0] = i
+      elif axis["name"] == "y":
+        scale_factors[1] = unit2factor(axis["unit"])
+        positions[1] = i
+      elif axis["name"] == "z":
+        scale_factors[2] = unit2factor(axis["unit"])
+        positions[2] = i
+
+    resolution = self.zattrs["multiscales"][0]["datasets"][mip]["coordinateTransformations"][0]["scale"]
+    resolution = np.array([
+      resolution[positions[0]],
+      resolution[positions[1]],
+      resolution[positions[2]]
+    ], dtype=np.float32)
+
+    return resolution * (scale_factors / 1e-9)
+
+  def time_resolution_in_seconds(self, mip):
+    i = 0
+    unit = None
+    for axis in self.zattrs["multiscales"][0]["axes"]:
+      if axis["type"] == "time":
+        unit = axis["unit"]
+        break
+      i += 1
+
+    scale_factor = 1
+    if unit == "kilosecond":
+      scale_factor = 1e3
+    elif unit == "centisecond":
+      scale_factor = 1e-2
+    elif unit == "millisecond":
+      scale_factor = 1e-3
+    elif unit == "microsecond":
+      scale_factor = 1e-6
+    elif unit == "nanosecond":
+      scale_factor = 1e-9
+
+    resolution = self.zattrs["multiscales"][0]["datasets"][mip]["coordinateTransformations"][0]["scale"]
+    return resolution[i] * scale_factor
+
+  def time_index(self):
+    for i, axis in enumerate(self.zattrs["multiscales"][0]["axes"]):
+      if axis["type"] == "time":
+        return i
+    raise ValueError("No time axis.")
+
+  def time_chunk_size(self, mip):
+    i = self.time_index()
+    return self.zarrays[mip]["chunks"][i]
+
+  def num_time_chunks(self, mip):
+    nframes = self.num_frames(mip)
+    t_chunk_size = self.time_chunk_size(mip)
+    return int(np.ceil(nframes / t_chunk_size))
+
+  def num_frames(self, mip):
+    i = self.time_index()
+    return self.zarrays[mip]["shape"][i]
+
+  def duration_in_seconds(self):
+    return self.time_resolution_in_seconds(0) * self.num_frames(0)
+
+  def order(self, mip):
+    return self.zarrays[mip]["order"]
+
+  def background_color(self, mip):
+    return self.zarrays[mip].get("fill_value", 0)
+
+  def set_background_color(self, mip):
+    self.zarrays[mip]["fill_value"] = 0
+
+  def filename_regexp(self, mip):
+      scale = self.zattrs["multiscales"][0]
+      axes = scale["axes"]
+
+      cf = CloudFiles(self.cloudpath)
+      dsep = '/'
+      if cf.protocol == "file":
+        dsep = os.path.sep  
+      if dsep == '\\':
+        dsep = '\\\\' # compensate for regexp escaping
+
+      regexp = rf"(?P<mip>\d+){dsep}"
+
+      groups = []
+      for axis in axes:
+        groups.append(fr"(?P<{axis['name']}>-?\d+)")
+
+      regexp += self.dimension_separator(mip).join(groups)
+
+      return re.compile(regexp)
+
+  def dimension_separator(self, mip):
+    return self.zarrays[mip].get("dimension_separator", ".")
+
+  def commit_info(self):
+    self.render_zarr_metadata()
+
+    cf = CloudFiles(self.cloudpath, secrets=self.config.secrets)
+
+    to_upload = []
+    for i, zarray in enumerate(self.zarrays):
+      to_upload.append(
+        (cf.join(str(i), ".zarray"), zarray)
+      )
+
+    to_upload.append(
+      ( ".zattrs", self.zattrs )
+    )
+
+    compress = "br"
+    if cf.protocol == "file":
+      compress = False # otherwise zarr can't read the file
+
+    cf.put_jsons(to_upload, compress=compress)
+
+  def to_zarr_volume_size(self, mip):
+    axes = self.zattrs["multiscales"][0]["axes"]
+      
+    shape = []
+    for axis in axes:
+      if axis["type"] == "channel":
+        shape.append(self.num_channels)
+      elif axis["type"] == "time":
+        shape.append(1)
+      elif axis["type"] == "space" and axis["name"] == "x":
+        shape.append(self.volume_size(mip)[0])
+      elif axis["type"] == "space" and axis["name"] == "y":
+        shape.append(self.volume_size(mip)[1])
+      elif axis["type"] == "space" and axis["name"] == "z":
+        shape.append(self.volume_size(mip)[2])
+
+    return shape
+
+  def zarr_axes_to_cv_axes(self):
+    axes = self.zattrs["multiscales"][0]["axes"]
+
+    shape = []
+    for i, axis in enumerate(axes):
+      if axis["type"] == "channel":
+        shape.append(3)
+      elif axis["type"] == "time":
+        shape.append(4)
+      elif axis["type"] == "space" and axis["name"] == "x":
+        shape.append(0)
+      elif axis["type"] == "space" and axis["name"] == "y":
+        shape.append(1)
+      elif axis["type"] == "space" and axis["name"] == "z":
+        shape.append(2)
+
+    return shape
+
+  def cv_axes_to_zarr_axes(self):
+    seq = self.zarr_axes_to_cv_axes()
+    shape = [0] * len(seq)
+    for i, val in enumerate(seq):
+      shape[val] = i
+    return shape
+
+  def render_zarr_metadata(self):
+    """Convert the current info file into zarr metadata."""
+    datasets = []
+
+    while len(self.zarrays) < len(self.scales):
+      self.zarrays.append({})
+
+    for mip, scale in enumerate(self.scales):
+      dataset = {
+        "coordinateTransformations": [
+          {
+            "scale": [
+              1,
+              self.num_channels,
+              scale["resolution"][2] / 1000,
+              scale["resolution"][1] / 1000,
+              scale["resolution"][0] / 1000
+            ],
+            "type": "scale"
+          }
+        ],
+        "path": str(mip),
+      }
+      datasets.append(dataset)
+
+      zscale = self.zarrays[mip] or {}
+
+      zscale["dtype"] = CV_TO_ZARR_DTYPE[self.data_type]
+      zscale["chunks"] = [ 1, self.num_channels ] + list(scale["chunk_sizes"][0][::-1])
+      zscale["shape"] = self.to_zarr_volume_size(mip)
+
+      zscale["fill_value"] = zscale.get("fill_value", 0)
+      zscale["order"] = zscale.get("order", 'C')
+      zscale["zarr_format"] = zscale.get("zarr_format", 2)
+
+      zscale["compressor"] = zscale.get("compressor", {
+        "blocksize": 0,
+        "clevel": 5,
+        "cname": "lz4",
+        "id": "blosc",
+        "shuffle": 1,
+      })
+      zscale["filters"] = zscale.get("filters", None)
+      
+      self.zarrays[mip] = zscale
+
+    self.zattrs["multiscales"][0]["datasets"] = datasets
+
+  def zarr_to_info(self, zarrays, zattrs):
+    def extract_spatial(attr, dtype):
+      scale = zattrs["multiscales"][0]
+      axes = scale["axes"]
+      
+      spatial = np.ones([3], dtype=dtype)
+      for axis, res in zip(axes, attr):
+        if axis["type"] != "space":
+          continue
+        if axis["name"] == "x":
+          spatial[0] = res
+        elif axis["name"] == "y":
+          spatial[1] = res
+        elif axis["name"] == "z":
+          spatial[2] = res
+
+      return spatial
+
+    def extract_spatial_size(mip):
+      shape = zarrays[mip]["shape"]
+      return extract_spatial(shape, int)
+      
+    def get_full_resolution(mip):
+      scale = zattrs["multiscales"][0]
+      axes = scale["axes"]
+      return np.array(scale["datasets"][mip]["coordinateTransformations"][0]["scale"])
+
+    num_channels = len([ 
+      chan for chan in zattrs["omero"]["channels"] if chan["active"] 
+    ])
+
+    if not zarrays:
+      raise exceptions.InfoUnavailableError()
+
+    base_res = self.spatial_resolution_in_nm(0, zattrs, zarrays)
+
+    info = PrecomputedMetadata.create_info(
+      num_channels=num_channels,
+      layer_type='image',
+      data_type=str(np.dtype(zarrays[0]["dtype"])),
+      encoding=zarrays[0]["compressor"]["id"],
+      resolution=base_res,
+      voxel_offset=[0,0,0],
+      volume_size=extract_spatial_size(0),
+      chunk_size=zarrays[0]["chunks"][2:][::-1],
+    )
+
+    num_mips = len(zattrs["multiscales"][0]["datasets"])
+
+    for mip in range(1, num_mips):
+      res = self.spatial_resolution_in_nm(mip, zattrs, zarrays)
+      factor = np.round(res / base_res)
+
+      zarray = zarrays[mip]
+
+      if zarray is None:
+        continue
+
+      self.add_scale(
+        factor,
+        chunk_size=zarray["chunks"][2:][::-1],
+        encoding=zarray["compressor"]["id"],
+        info=info
+      )
+
+    return info
+
+  def fetch_info(self):
+    cf = CloudFiles(self.cloudpath, secrets=self.config.secrets)
+    metadata = cf.get_json([ "0/.zarray", ".zattrs" ])
+
+    if metadata[0] is not None:
+      self.zarrays.append(metadata[0])
+    else:
+      self.zarrays = []
+    
+    if metadata[1] is not None:
+      self.zattrs = metadata[1]
+    else:
+      self.zattrs = self.default_zattrs()
+
+    num_mips = len(self.zattrs["multiscales"][0]["datasets"])
+
+    zarray_paths = [
+      f"{i}/.zarray" for i in range(1, num_mips)
+    ]
+    res = cf.get_json(zarray_paths)
+
+    if res is not None:
+      self.zarrays.extend(res)
+
+    return self.zarr_to_info(self.zarrays, self.zattrs)
+
+  def zarr_chunk_size(self, mip):
+    return [1, self.num_channels ] + list(self.chunk_size(mip)[::-1])
+
+  def commit_provenance(self):
+    """Zarr doesn't support provenance files."""
+    pass
+
+  def fetch_provenance(self):
+    """Zarr doesn't support provenance files."""
+    pass
+
+# Example .zarray JSON file
+# {
+#     "chunks": [
+#         64,
+#         128,
+#         128
+#     ],
+#     "compressor": {
+#         "blocksize": 0,
+#         "clevel": 5,
+#         "cname": "lz4",
+#         "id": "blosc",
+#         "shuffle": 1
+#     },
+#     "dtype": "|u1",
+#     "fill_value": 0,
+#     "filters": null,
+#     "order": "C",
+#     "shape": [
+#         512,
+#         512,
+#         512
+#     ],
+#     "zarr_format": 2
+# }
+
+# Example .zattrs JSON file
+
+# {
+#   "multiscales": [
+#     {
+#       "axes": [
+#         {
+#           "name": "t",
+#           "type": "time",
+#           "unit": "millisecond"
+#         },
+#         {
+#           "name": "c",
+#           "type": "channel"
+#         },
+#         {
+#           "name": "z",
+#           "type": "space",
+#           "unit": "micrometer"
+#         },
+#         {
+#           "name": "y",
+#           "type": "space",
+#           "unit": "micrometer"
+#         },
+#         {
+#           "name": "x",
+#           "type": "space",
+#           "unit": "micrometer"
+#         }
+#       ],
+#       "datasets": [
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 1,
+#                 0.748,
+#                 0.748
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "0"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 2,
+#                 1.496,
+#                 1.496
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "1"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 4,
+#                 2.992,
+#                 2.992
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "2"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 8,
+#                 5.984,
+#                 5.984
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "3"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 16,
+#                 11.968,
+#                 11.968
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "4"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 32,
+#                 23.936,
+#                 23.936
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "5"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 64,
+#                 47.872,
+#                 47.872
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "6"
+#         },
+#         {
+#           "coordinateTransformations": [
+#             {
+#               "scale": [
+#                 1,
+#                 1,
+#                 128,
+#                 95.744,
+#                 95.744
+#               ],
+#               "type": "scale"
+#             }
+#           ],
+#           "path": "7"
+#         }
+#       ],
+#       "name": "/",
+#       "version": "0.4"
+#     }
+#   ],
+#   "omero": {
+#     "channels": [
+#       {
+#         "active": true,
+#         "coefficient": 1,
+#         "color": "000000",
+#         "family": "linear",
+#         "inverted": false,
+#         "label": "Channel:s3://aind-open-data/exaSPIM_653980_2023-08-10_20-08-29_fusion_2023-08-24/fused.zarr/:0",
+#         "window": {
+#           "end": 1,
+#           "max": 1,
+#           "min": 0,
+#           "start": 0
+#         }
+#       }
+#     ],
+#     "id": 1,
+#     "name": "s3://aind-open-data/exaSPIM_653980_2023-08-10_20-08-29_fusion_2023-08-24/fused.zarr/",
+#     "rdefs": {
+#       "defaultT": 0,
+#       "defaultZ": 11573,
+#       "model": "color"
+#     },
+#     "version": "0.4"
+#   }
+# }
diff --git a/cloudvolume/paths.py b/cloudvolume/paths.py
index 6677da0f..3f65e2e2 100644
--- a/cloudvolume/paths.py
+++ b/cloudvolume/paths.py
@@ -14,7 +14,10 @@
 )
 
 ALLOWED_PROTOCOLS = cloudfiles.paths.ALLOWED_PROTOCOLS
-ALLOWED_FORMATS = [ 'graphene', 'precomputed', 'boss', 'n5' ] 
+ALLOWED_FORMATS = [ 
+  'graphene', 'precomputed', 'boss', 'n5', 
+  'zarr', 'zarr2', 'zarr3',
+]
 
 def cloudpath_error(cloudpath):
   global ALLOWED_PROTOCOLS
diff --git a/setup.py b/setup.py
index 8c139c62..ba2c3e35 100644
--- a/setup.py
+++ b/setup.py
@@ -34,6 +34,7 @@ def requirements():
     "skeleton_viewer": [ 'matplotlib>=3.6' ],
     "all_viewers": [ 'vtk', 'matplotlib>=3.6' ],
     "dask": [ 'dask[array]' ],
+    "zarr": [ 'blosc' ],
     "test": [ "pytest", "pytest-cov", "codecov", "requests_mock", "scipy"]
   },
   author="William Silversmith, Nico Kemnitz, Ignacio Tartavull, and others",


Test patch:
diff --git a/test/test_cloudvolume.py b/test/test_cloudvolume.py
index d8113332..4bd833dd 100644
--- a/test/test_cloudvolume.py
+++ b/test/test_cloudvolume.py
@@ -1517,7 +1517,8 @@ def test_transfer():
 
   dcv.image.delete(dcv.bounds)
 
-
+  with pytest.raises(ValueError):
+    dcv.image.transfer_to("n5://s3://hello-world.", dcv.bounds, 0)
 
 
 def test_cdn_cache_control():