Repository: seung-lab/cloud-volume
Pull request #467 (2021-04-29 03:00:59)

feat: Support for floating point resolution


Patch:
diff --git a/.travis.yml b/.travis.yml
index f0d170b..058cc4d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,7 +10,6 @@ before_install:
 - mkdir -p ~/.cloudvolume/secrets/
 - echo $AWS_SECRET > ~/.cloudvolume/secrets/aws-secret.json
 - echo $GOOGLE_SECRET > ~/.cloudvolume/secrets/google-secret.json
-- echo $BOSS_SECRET > ~/.cloudvolume/secrets/boss-secret.json
 install:
 - virtualenv -p python3 venv
 - source venv/bin/activate
diff --git a/cloudvolume/__init__.py b/cloudvolume/__init__.py
index cc5ef19..43ae9dc 100644
--- a/cloudvolume/__init__.py
+++ b/cloudvolume/__init__.py
@@ -68,7 +68,7 @@ from . import secrets
 from . import microviewer
 from .microviewer import view, hyperview
 
-__version__ = '3.11.0'
+__version__ = '3.12.1'
 
 # Register plugins
 from .datasource.precomputed import register as register_precomputed
diff --git a/cloudvolume/cloudvolume.py b/cloudvolume/cloudvolume.py
index 6e7a714..3e85ceb 100644
--- a/cloudvolume/cloudvolume.py
+++ b/cloudvolume/cloudvolume.py
@@ -4,7 +4,7 @@ import time
 import multiprocessing as mp
 import numpy as np
 
-from .exceptions import UnsupportedFormatError
+from .exceptions import UnsupportedFormatError, DimensionError
 from .lib import generate_random_string
 from .paths import strict_extract, to_https_protocol
 
@@ -240,21 +240,22 @@ class CloudVolume(object):
                         or np.issubdtype(arr.dtype, np.floating):
         layer_type = 'image'
       else:
-        raise NotImplementedError
+        raise ValueError(f"{arr.dtype} is not supported.")
 
     if arr.ndim == 3:
       num_channels = 1
     elif arr.ndim == 4:
       num_channels = arr.shape[-1]
     else:
-      raise NotImplementedError
-
+      raise DimensionError(f"CloudVolume only accepts 3 or 4 dimensional images. Got: {arr.ndim}")
+      
     info = cls.create_new_info(
       num_channels, layer_type, arr.dtype.name,
       encoding, resolution,
       voxel_offset, arr.shape[:3],
       chunk_size=chunk_size, max_mip=max_mip
     )
+    
     vol = CloudVolume(vol_path, info=info, bounded=True, compress=compress)
     # save the info file
     vol.commit_info()
diff --git a/cloudvolume/datasource/precomputed/mesh/unsharded.py b/cloudvolume/datasource/precomputed/mesh/unsharded.py
index d065aa1..cb52a9d 100644
--- a/cloudvolume/datasource/precomputed/mesh/unsharded.py
+++ b/cloudvolume/datasource/precomputed/mesh/unsharded.py
@@ -41,7 +41,8 @@ class UnshardedLegacyPrecomputedMeshSource(object):
       self.spatial_index = CachedSpatialIndex(
         self.cache, self.config,
         cloudpath=self.meta.layerpath, 
-        bounds=self.meta.meta.bounds(mip) * self.meta.meta.resolution(mip),
+        bounds=self.meta.meta.bounds(mip), 
+        resolution=self.meta.info['spatial_index'].get('resolution', self.meta.meta.resolution(mip)),
         chunk_size=self.meta.info['spatial_index']['chunk_size'],
       )
 
diff --git a/cloudvolume/datasource/precomputed/metadata.py b/cloudvolume/datasource/precomputed/metadata.py
index a69f700..34c2ac8 100644
--- a/cloudvolume/datasource/precomputed/metadata.py
+++ b/cloudvolume/datasource/precomputed/metadata.py
@@ -82,7 +82,7 @@ class PrecomputedMetadata(object):
       layer_type: (str) typically "image" or "segmentation"
       data_type: (str) e.g. "uint8", "uint16", "uint32", "float32"
       encoding: (str) "raw" for binaries like numpy arrays, "jpeg"
-      resolution: int (x,y,z), x,y,z voxel dimensions in nanometers
+      resolution: float (x,y,z), x,y,z voxel dimensions in nanometers
       voxel_offset: int (x,y,z), beginning of dataset in positive cartesian space
       volume_size: int (x,y,z), extent of dataset in cartesian space from voxel_offset
     
@@ -104,15 +104,20 @@ class PrecomputedMetadata(object):
     if not isinstance(data_type, str):
       data_type = np.dtype(data_type).name
 
+    precision = max(map(lib.getprecision, resolution))
+    res_dtype = float
+    if precision == 0:
+      res_dtype = int
+
     info = {
       "num_channels": int(num_channels),
       "type": layer_type,
       "data_type": data_type,
       "scales": [{
         "encoding": encoding,
-        "chunk_sizes": [chunk_size],
+        "chunk_sizes": [ list(map(int, chunk_size)) ],
         "key": "_".join(map(str, resolution)),
-        "resolution": list(map(int, resolution)),
+        "resolution": list(map(res_dtype, resolution)),
         "voxel_offset": list(map(int, voxel_offset)),
         "size": list(map(int, volume_size)),
       }],
@@ -126,17 +131,8 @@ class PrecomputedMetadata(object):
  
     # add mip levels
     for _ in range(max_mip):
-      new_resolution = list(map(int, Vec(*fullres['resolution']) * factor_in_mip ))
-      newscale = {
-        u"encoding": encoding,
-        u"chunk_sizes": [ list(map(int, chunk_size)) ],
-        u"key": "_".join(map(str, new_resolution)),
-        u"resolution": new_resolution,
-        u"voxel_offset": downscale(fullres['voxel_offset'], factor_in_mip, np.floor),
-        u"size": downscale(fullres['size'], factor_in_mip, np.ceil),
-      }
-      info['scales'].append(newscale)
-      factor_in_mip *= factor
+      scale = cls.add_scale(None, factor, encoding=encoding, chunk_size=chunk_size, info=info)
+      info["scales"].append(scale)
 
     if encoding == 'compressed_segmentation':
       info['scales'][0]['compressed_segmentation_block_size'] = list(map(int, compressed_segmentation_block_size))
@@ -547,7 +543,9 @@ Hops:
 
   def resolution(self, mip):
     """Vec(x,y,z) dimensions of each voxel in nanometers"""
-    return Vec(*self.info['scales'][mip]['resolution'])
+    res = self.info['scales'][mip]['resolution']
+    dtype = float if lib.floating(res) else int
+    return Vec(*res, dtype=dtype)
 
   def to_mip(self, mip):
     mip = list(mip) if isinstance(mip, Iterable) else int(mip)
@@ -631,8 +629,12 @@ Hops:
     """Used for manually resetting downsamples if something messed up."""
     self.info['scales'] = self.info['scales'][0:1]
 
-  def add_resolution(self, res, encodig=None, chunk_size=None, info=None):
-    factor = Vec(res, dtype=np.float32) // self.meta.resolution(0)
+  def add_resolution(self, res, encoding=None, chunk_size=None, info=None):
+    if isinstance(res[0], float):
+      factor = Vec(*res, dtype=float) / self.resolution(0)
+    else:
+      factor = Vec(*res, dtype=float) // self.resolution(0)
+
     return self.add_scale(factor, encoding, chunk_size, info)
 
   def add_scale(self, factor, encoding=None, chunk_size=None, info=None):
@@ -669,10 +671,17 @@ Hops:
     if encoding is None:
       encoding = fullres['encoding']
 
+    precision = max(map(lib.getprecision, fullres['resolution']))
+    precision = max(precision, max(map(lib.getprecision, factor)))
+
+    dtype = float
+    if precision == 0:
+      dtype = int
+
     newscale = {
       u"encoding": encoding,
       u"chunk_sizes": [ list(map(int, chunk_size)) ],
-      u"resolution": list(map(int, Vec(*fullres['resolution']) * factor )),
+      u"resolution": list(map(dtype, Vec(*fullres['resolution'], dtype=dtype) * factor )),
       u"voxel_offset": downscale(fullres.get('voxel_offset', (0,0,0)), factor, np.floor),
       u"size": downscale(fullres['size'], factor, np.ceil),
     }
@@ -685,11 +694,11 @@ Hops:
 
     newscale[u'key'] = str("_".join([ str(res) for res in newscale['resolution']]))
 
-    new_res = np.array(newscale['resolution'], dtype=int)
+    new_res = np.array(newscale['resolution'], dtype=dtype)
 
     preexisting = False
     for index, scale in enumerate(info['scales']):
-      res = np.array(scale['resolution'], dtype=int)
+      res = np.array(scale['resolution'], dtype=dtype)
       if np.array_equal(new_res, res):
         preexisting = True
         info['scales'][index] = newscale
diff --git a/cloudvolume/datasource/precomputed/skeleton/metadata.py b/cloudvolume/datasource/precomputed/skeleton/metadata.py
index 6b5ef6f..e506886 100644
--- a/cloudvolume/datasource/precomputed/skeleton/metadata.py
+++ b/cloudvolume/datasource/precomputed/skeleton/metadata.py
@@ -1,3 +1,4 @@
+import copy
 import re
 
 from ....lib import jsonify
@@ -79,14 +80,20 @@ class PrecomputedSkeletonMetadata(object):
     return self.info
 
   def commit_info(self):
-    if self.info:
-      self.cache.upload_single(
-        self.meta.join(self.skeleton_path, 'info'),
-        jsonify(self.info), 
-        content_type='application/json',
-        compress=False,
-        cache_control='no-cache',
-      )
+    if self.info is None:
+      return 
+
+    info = copy.deepcopy(self.info)
+    if info.get("sharding", None) is None:
+      del info["sharding"]
+
+    self.cache.upload_single(
+      self.meta.join(self.skeleton_path, 'info'),
+      jsonify(info), 
+      content_type='application/json',
+      compress=False,
+      cache_control='no-cache',
+    )
 
   def default_info(self):
     return {
diff --git a/cloudvolume/datasource/precomputed/skeleton/sharded.py b/cloudvolume/datasource/precomputed/skeleton/sharded.py
index b97c97a..a3cdc8d 100644
--- a/cloudvolume/datasource/precomputed/skeleton/sharded.py
+++ b/cloudvolume/datasource/precomputed/skeleton/sharded.py
@@ -19,7 +19,8 @@ class ShardedPrecomputedSkeletonSource(object):
       self.spatial_index = CachedSpatialIndex(
         self.cache, self.config,
         cloudpath=self.meta.layerpath, 
-        bounds=self.meta.meta.bounds(mip) * self.meta.meta.resolution(mip),
+        bounds=self.meta.meta.bounds(mip),
+        resolution=self.meta.info['spatial_index'].get('resolution', self.meta.meta.resolution(mip)),
         chunk_size=self.meta.info['spatial_index']['chunk_size'],
       )
 
diff --git a/cloudvolume/datasource/precomputed/skeleton/unsharded.py b/cloudvolume/datasource/precomputed/skeleton/unsharded.py
index 74b90dc..5b251aa 100644
--- a/cloudvolume/datasource/precomputed/skeleton/unsharded.py
+++ b/cloudvolume/datasource/precomputed/skeleton/unsharded.py
@@ -32,7 +32,8 @@ class UnshardedPrecomputedSkeletonSource(object):
       self.spatial_index = CachedSpatialIndex(
         self.cache, self.config,
         cloudpath=self.meta.layerpath, 
-        bounds=self.meta.meta.bounds(mip) * self.meta.meta.resolution(mip),
+        bounds=self.meta.meta.bounds(mip),
+        resolution=self.meta.info['spatial_index'].get('resolution', self.meta.meta.resolution(mip)),
         chunk_size=self.meta.info['spatial_index']['chunk_size'],
       )
 
diff --git a/cloudvolume/datasource/precomputed/spatial_index.py b/cloudvolume/datasource/precomputed/spatial_index.py
index 80edc85..521a548 100644
--- a/cloudvolume/datasource/precomputed/spatial_index.py
+++ b/cloudvolume/datasource/precomputed/spatial_index.py
@@ -10,7 +10,10 @@ from cloudfiles import CloudFiles
 
 from ...exceptions import SpatialIndexGapError
 from ... import paths
-from ...lib import Bbox, Vec, xyzrange, min2, toiter, sip, nvl
+from ...lib import (
+  Bbox, Vec, xyzrange, min2, 
+  toiter, sip, nvl, getprecision
+)
 
 class SpatialIndex(object):
   """
@@ -41,13 +44,25 @@ class SpatialIndex(object):
   """
   def __init__(
     self, cloudpath, bounds, chunk_size, 
-    config=None, sqlite_db=None
+    config=None, sqlite_db=None, resolution=None
   ):
     self.cloudpath = cloudpath
     self.path = paths.extract(cloudpath)
     self.bounds = Bbox.create(bounds)
     self.chunk_size = Vec(*chunk_size)
     self.sqlite_db = sqlite_db # optional DB for higher performance
+    self.resolution = None
+    self.precision = None
+
+    if resolution is None:
+      self.physical_bounds = self.bounds.clone()
+    else:
+      self.resolution = Vec(*resolution, dtype=float)
+      self.precision = max(map(getprecision, resolution))
+      if self.precision == 0:
+        self.resolution = Vec(*resolution, dtype=int)
+
+      self.physical_bounds = self.bounds.astype(self.resolution.dtype) * self.resolution
 
     if config is None:
       self.config = {}
@@ -100,13 +115,15 @@ class SpatialIndex(object):
     pbar.close()
 
   def index_file_paths_for_bbox(self, bbox):
-    bbox = bbox.expand_to_chunk_size(self.chunk_size, offset=self.bounds.minpt)
+    bbox = bbox.expand_to_chunk_size(self.chunk_size, offset=self.physical_bounds.minpt)
 
     if bbox.subvoxel():
       return []
 
     chunk_size = self.chunk_size
     bounds = self.bounds
+    resolution = self.resolution
+    precision = self.precision
 
     class IndexPathIterator():
       def __len__(self):
@@ -114,7 +131,8 @@ class SpatialIndex(object):
       def __iter__(self):
         for pt in xyzrange(bbox.minpt, bbox.maxpt, chunk_size):
           search = Bbox( pt, min2(pt + chunk_size, bounds.maxpt) )
-          yield search.to_filename() + '.spatial'
+          search *= resolution
+          yield search.to_filename(precision) + '.spatial'
 
     return IndexPathIterator()
 
@@ -309,15 +327,15 @@ class SpatialIndex(object):
 
     Returns: set(labels)
     """
-    bbox = Bbox.create(bbox, context=self.bounds, autocrop=True)
+    bbox = Bbox.create(bbox, context=self.physical_bounds, autocrop=True)
     original_bbox = bbox.clone()
-    bbox = bbox.expand_to_chunk_size(self.chunk_size, offset=self.bounds.minpt)
+    bbox = bbox.expand_to_chunk_size(self.chunk_size.astype(self.physical_bounds.dtype), offset=self.physical_bounds.minpt)
 
     if bbox.subvoxel():
       return []
 
     labels = set()
-    fast_path = bbox.contains_bbox(self.bounds)
+    fast_path = bbox.contains_bbox(self.physical_bounds)
 
     if self.sqlite_db and fast_path:
       conn = sqlite3.connect(self.sqlite_db)
@@ -363,12 +381,17 @@ class SpatialIndex(object):
     return labels
 
 class CachedSpatialIndex(SpatialIndex):
-  def __init__(self, cache, config, cloudpath, bounds, chunk_size):
+  def __init__(
+    self, cache, config, 
+    cloudpath, bounds, chunk_size,
+    resolution
+  ):
     self.cache = cache
     self.subdir = os.path.relpath(cloudpath, cache.meta.cloudpath)
 
     super(CachedSpatialIndex, self).__init__(
-      cloudpath, bounds, chunk_size, config
+      cloudpath, bounds, chunk_size, config, 
+      resolution=resolution
     )
 
   def fetch_index_files(self, index_files, progress=None):
diff --git a/cloudvolume/exceptions.py b/cloudvolume/exceptions.py
index dd7de10..ab07509 100644
--- a/cloudvolume/exceptions.py
+++ b/cloudvolume/exceptions.py
@@ -4,6 +4,10 @@ class AuthenticationError(BaseException):
   """Incorrect credentials."""
   pass
 
+class DimensionError(BaseException):
+  """Wrong number of dimensions."""
+  pass
+
 class InfoError(Exception):
   pass
 
diff --git a/cloudvolume/frontends/graphene.py b/cloudvolume/frontends/graphene.py
index 0830d90..b1206ae 100644
--- a/cloudvolume/frontends/graphene.py
+++ b/cloudvolume/frontends/graphene.py
@@ -286,13 +286,23 @@ class CloudVolumeGraphene(CloudVolumePrecomputed):
         Layer 2+: Between chunk interconnections (skip connections possible)
     """
     segids = toiter(segids)
-    if isinstance(segids, np.ndarray):
-      segids = segids.tolist()
+    input_segids = np.array(segids, dtype=self.meta.dtype)
 
-    try:
-      segids.remove(0) # background segid
-    except ValueError:
-      pass
+    if input_segids.size == 0:
+      return np.array([], dtype=self.meta.dtype)
+
+    segids = fastremap.unique(input_segids)
+
+    base_remap = { 0: 0 }
+    # skip ids that are already root IDs
+    for segid in segids:
+      if self.meta.decode_layer_id(segid) == self.meta.n_layers:
+        base_remap[segid] = segid
+
+    segids = np.array(
+      [ segid for segid in segids if segid not in base_remap ], 
+      dtype=self.meta.dtype
+    )
 
     timestamp = to_unix_time(timestamp)
 
@@ -313,7 +323,10 @@ class CloudVolumeGraphene(CloudVolumePrecomputed):
         + ", ".join([ str(_) for _ in self.meta.supported_api_versions ])
       )
 
-    return np.array(roots, dtype=self.meta.dtype)
+    for segid, root_id in zip(segids, roots):
+      base_remap[segid] = root_id
+
+    return fastremap.remap(input_segids, base_remap)
 
   def get_chunk_mappings(self, chunk_id, timestamp=None):
     """
@@ -369,6 +382,9 @@ class CloudVolumeGraphene(CloudVolumePrecomputed):
     return chunk_mappings
 
   def _get_roots_v1(self, segids, timestamp, binary=False, stop_layer=None):
+    if len(segids) == 0:
+      return []
+
     args = {}
 
     headers = {}
@@ -412,6 +428,9 @@ class CloudVolumeGraphene(CloudVolumePrecomputed):
       return orjson.loads(response.content)['root_ids']
 
   def _get_roots_legacy(self, segids, timestamp):
+    if len(segids) == 0:
+      return []
+
     args = {}
     if timestamp is not None:
       args['timestamp'] = timestamp
diff --git a/cloudvolume/lib.py b/cloudvolume/lib.py
index 10ef982..25dab33 100644
--- a/cloudvolume/lib.py
+++ b/cloudvolume/lib.py
@@ -1,15 +1,14 @@
-from __future__ import print_function
-from six.moves import range, reduce
-
+import decimal
+from functools import reduce
 import json
+import math
+import operator
 import os
+import random
 import re 
 import sys
-import math
-import operator
 import time
 import types
-import random
 import string 
 from itertools import product
 
@@ -154,6 +153,12 @@ def touch(path):
   mkdir(os.path.dirname(path))
   open(path, 'a').close()
 
+def getprecision(num):
+  try:
+    return len(str(num).split('.')[1])
+  except IndexError:
+    return 0
+
 def find_closest_divisor(to_divide, closest_to):
   """
   This is used to find the right chunk size for
@@ -278,8 +283,8 @@ class Vec(np.ndarray):
       return int(''.join(map(str, self)))
 
     def __repr__(self):
-      values = u",".join(list(self.astype(str)))
-      return u"Vec({}, dtype={})".format(values, self.dtype)
+      values = ",".join([ str(x) for x in self ])
+      return f"Vec({values}, dtype={self.dtype})"
 
 def __assign(self, val, index):
   self[index] = val
@@ -457,9 +462,23 @@ class Bbox(object):
 
     return Bbox( mins, maxes, dtype=np.int64)
 
-  def to_filename(self):
+  def to_filename(self, precision=None):
+    """
+    Renders the Bbox as a string. For example:
+    
+    >>> Bbox([0,2,4],[1,3,5]).to_filename()
+    > '0-1_2-3_4-5'
+
+    If the data is floating point, adding a precision
+    allows will round the numbers to that decimal place.
+    """
+    def render(x):
+      if precision:
+        return f"{round(x, precision):.{precision}f}"
+      return str(x)
+
     return '_'.join(
-      ( str(self.minpt[i]) + '-' + str(self.maxpt[i]) for i in range(self.ndim) )
+      ( render(self.minpt[i]) + '-' + render(self.maxpt[i]) for i in range(self.ndim) )
     )
 
   def to_slices(self):
@@ -728,25 +747,27 @@ class Bbox(object):
 
   # note that operand can be a vector 
   # or a scalar thanks to numpy
-  def __sub__(self, operand): 
-    tmp = self.clone()
-    
+  def __isub__(self, operand): 
     if isinstance(operand, Bbox):
-      tmp.minpt -= operand.minpt
-      tmp.maxpt -= operand.maxpt
+      self.minpt = np.subtract(self.minpt, operand.minpt, casting="safe")
+      self.maxpt = np.subtract(self.maxpt, operand.maxpt, casting="safe")
     else:
-      tmp.minpt -= operand
-      tmp.maxpt -= operand
+      self.minpt = np.subtract(self.minpt, operand, casting="safe")
+      self.maxpt = np.subtract(self.maxpt, operand, casting="safe")
 
-    return tmp
+    return self.astype(self.minpt.dtype)
+
+  def __sub__(self, operand):
+    tmp = self.clone()
+    return tmp.__isub__(operand)
 
   def __iadd__(self, operand):
     if isinstance(operand, Bbox):
-      self.minpt += operand.minpt
-      self.maxpt += operand.maxpt
+      self.minpt = np.add(self.minpt, operand.minpt, casting="safe")
+      self.maxpt = np.add(self.maxpt, operand.maxpt, casting="safe")
     else:
-      self.minpt += operand
-      self.maxpt += operand
+      self.minpt = np.add(self.minpt, operand, casting="safe")
+      self.maxpt = np.add(self.maxpt, operand, casting="safe")
 
     return self
 
@@ -755,14 +776,15 @@ class Bbox(object):
     return tmp.__iadd__(operand)
 
   def __imul__(self, operand):
-    self.minpt *= operand
-    self.maxpt *= operand
+    self.minpt = np.multiply(self.minpt, operand, casting="safe")
+    self.maxpt = np.multiply(self.maxpt, operand, casting="safe")
+    self._dtype = self.minpt.dtype 
     return self
 
   def __mul__(self, operand):
     tmp = self.clone()
-    tmp.minpt *= operand
-    tmp.maxpt *= operand
+    tmp.minpt = np.multiply(tmp.minpt, operand, casting="safe")
+    tmp.maxpt = np.multiply(tmp.maxpt, operand, casting="safe")
     return tmp.astype(tmp.minpt.dtype)
 
   def __idiv__(self, operand):
@@ -823,7 +845,7 @@ class Bbox(object):
     return int(''.join(map(str, map(int, self.to_list()))))
 
   def __repr__(self):
-    return "Bbox({},{}, dtype={})".format(list(self.minpt), list(self.maxpt), self.dtype)
+    return f"Bbox({list(self.minpt)},{list(self.maxpt)}, dtype={self.dtype})"
 
 def save_images(
   image, directory=None, axis='z', 
diff --git a/cloudvolume/microviewer/datacube.js b/cloudvolume/microviewer/datacube.js
index 03cd750..f40573a 100644
--- a/cloudvolume/microviewer/datacube.js
+++ b/cloudvolume/microviewer/datacube.js
@@ -291,9 +291,9 @@ class SegmentationVolume extends MonoVolume {
       z = slice;
     }
 
-    x = Math.round(x);
-    y = Math.round(y);
-    z = Math.round(z);
+    x = Math.floor(x);
+    y = Math.floor(y);
+    z = Math.floor(z);
 
     let segid = _this.channel.get(x, y, z);
     
@@ -447,9 +447,10 @@ class HyperVolume extends MonoVolume {
     const brightener = this.colorToUint32({ r: 10, g: 10, b: 10, a: 0 });
     const hover_id = this.hover_id;
 
+    let segments = this.segments;
     let selected_segments = new Uint8Array(this.renumbering.length);
     Object.keys(_this.segments).forEach((label) => {
-      selected_segments[label] = 1;
+      selected_segments[label] = !!segments[label];
     });
 
     let pxdata = pixels.data;
@@ -526,9 +527,9 @@ class HyperVolume extends MonoVolume {
       z = slice;
     }
 
-    x = Math.round(x);
-    y = Math.round(y);
-    z = Math.round(z);
+    x = Math.floor(x);
+    y = Math.floor(y);
+    z = Math.floor(z);
 
     let segid = _this.segmentation.get(x, y, z);
     
@@ -538,6 +539,15 @@ class HyperVolume extends MonoVolume {
 
     return segid;
   }
+
+  selectSegment(segid) {
+    let _this = this;
+    segid = _this.segmentation.cast(segid);
+    segid = _this.renumbering.indexOf(segid);
+    if (segid !== -1) {
+      _this.segments[segid] = true;
+    }
+  }
 }
 
 class CachedImageData {
diff --git a/cloudvolume/microviewer/index.html b/cloudvolume/microviewer/index.html
index a35b546..ef9db17 100644
--- a/cloudvolume/microviewer/index.html
+++ b/cloudvolume/microviewer/index.html
@@ -36,6 +36,8 @@
         max-height: 768px;
         background: #0d0d0d;
         z-index: 10;
+
+        image-rendering: crisp-edges;
       }
 
       img {
@@ -60,6 +62,7 @@
       var PARAMETERS = {};
       var PXVALUE = [];
       var MOUSE_OVER_IMAGE = false;
+      var MAGNIFICATION = 1;
 
       var elems; 
       var _needsrender = true;
@@ -110,20 +113,20 @@
           let x = e.offsetX / $(this).innerWidth(), 
             y = e.offsetY / $(this).innerHeight();
 
-          x = clamp(x, 0, 1);
-          y = clamp(y, 0, 1);
+          x = clamp(x, 0, 0.999999999);
+          y = clamp(y, 0, 0.999999999);
 
           if (AXIS == 'z') {
-            SLICE.x = (x * (SIZE.x - 1))|0;
-            SLICE.y = (y * (SIZE.y - 1))|0;
+            SLICE.x = (x * SIZE.x)|0;
+            SLICE.y = (y * SIZE.y)|0;
           }
           else if (AXIS == 'y') {
-            SLICE.x = (x * (SIZE.x - 1))|0;
-            SLICE.z = (y * (SIZE.z - 1))|0;
+            SLICE.x = (x * SIZE.x)|0;
+            SLICE.z = (y * SIZE.z)|0;
           }
           else if (AXIS == 'x') {
-            SLICE.y = (x * (SIZE.y - 1))|0;
-            SLICE.z = (y * (SIZE.z - 1))|0;
+            SLICE.y = (x * SIZE.y)|0;
+            SLICE.z = (y * SIZE.z)|0;
           }
 
           render(); 
@@ -167,20 +170,20 @@
           var x = e.offsetX / $(this).innerWidth(), 
             y = e.offsetY / $(this).innerHeight();
 
-          x = clamp(x, 0, 1);
-          y = clamp(y, 0, 1);
+          x = clamp(x, 0, 0.999999999);
+          y = clamp(y, 0, 0.999999999);
 
           if (AXIS == 'z') {
-            SLICE.x = (x * (SIZE.x - 1))|0;
-            SLICE.y = (y * (SIZE.y - 1))|0;
+            SLICE.x = (x * SIZE.x)|0;
+            SLICE.y = (y * SIZE.y)|0;
           }
           else if (AXIS == 'y') {
-            SLICE.x = (x * (SIZE.x - 1))|0;
-            SLICE.z = (y * (SIZE.z - 1))|0;
+            SLICE.x = (x * SIZE.x)|0;
+            SLICE.z = (y * SIZE.z)|0;
           }
           else if (AXIS == 'x') {
-            SLICE.y = (x * (SIZE.y - 1))|0;
-            SLICE.z = (y * (SIZE.z - 1))|0;
+            SLICE.y = (x * SIZE.y)|0;
+            SLICE.z = (y * SIZE.z)|0;
           }
 
           render(); 
@@ -198,7 +201,8 @@
           x = clamp(x, 0, 1);
           y = clamp(y, 0, 1);  
           
-          vol.toggleSegment(AXIS, SLICE[AXIS], x, y);        
+          vol.toggleSegment(AXIS, SLICE[AXIS], x, y);
+          render(true);
         });
       }
 
@@ -252,22 +256,22 @@
             var x = e.offsetX / $(this).innerWidth(), 
               y = e.offsetY / $(this).innerHeight();
 
-            x = clamp(x, 0, 1);
-            y = clamp(y, 0, 1);
+            x = clamp(x, 0, 0.999999999);
+            y = clamp(y, 0, 0.999999999);
 
             if (AXIS == 'z') {
-              CLICK.x = (x * (SIZE.x - 1))|0;
-              CLICK.y = (y * (SIZE.y - 1))|0;
+              CLICK.x = (x * SIZE.x)|0;
+              CLICK.y = (y * SIZE.y)|0;
               CLICK.z = SLICE.z;
             }
             else if (AXIS == 'y') {
-              CLICK.x = (x * (SIZE.x - 1))|0;
-              CLICK.z = (y * (SIZE.z - 1))|0;
+              CLICK.x = (x * SIZE.x)|0;
+              CLICK.z = (y * SIZE.z)|0;
               CLICK.y = SLICE.y;
             }
             else if (AXIS == 'x') {
-              CLICK.y = (x * (SIZE.y - 1))|0;
-              CLICK.z = (y * (SIZE.z - 1))|0;
+              CLICK.y = (x * SIZE.y)|0;
+              CLICK.z = (y * SIZE.z)|0;
               CLICK.x = SLICE.x;
             }
 
@@ -383,6 +387,18 @@
             !elems.show_hover.prop("checked")
           );
         }
+        else if (evt.keyCode === "1".charCodeAt(0)) {
+          MAGNIFICATION /= 2;
+          MAGNIFICATION = Math.max(MAGNIFICATION, 1);
+          elems.channel.css("width", (channel.width * MAGNIFICATION) + "px");
+          elems.channel.css("height", (channel.height * MAGNIFICATION) + "px");
+        }
+        else if (evt.keyCode === "2".charCodeAt(0)) {
+          MAGNIFICATION *= 2;
+          MAGNIFICATION = Math.min(MAGNIFICATION, 256);
+          elems.channel.css("width", (channel.width * MAGNIFICATION) + "px");
+          elems.channel.css("height", (channel.height * MAGNIFICATION) + "px");
+        }
       });
 
       function rotate_axis(delta) {
@@ -564,6 +580,7 @@
         <li class="hyperview">X - clear selected segments</li>
         <li class="hyperview">+/- increase, decrease opacity</li>
         <li class='segmentation'>H - toggle hover highlight</li>
+        <li>1/2 - zoom out/in</li>
       </ul>
 
       <input class="segmentation" type="checkbox" id="show_hover" name="show_hover" checked="checked"/> <label for="show_hover">Highlight Hover?</label>
diff --git a/setup.py b/setup.py
index aa2974f..9133f10 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ def requirements():
 
 setuptools.setup(
   name="cloud-volume",
-  version="3.11.0",
+  version="3.12.1",
   setup_requires=[
     'numpy<1.17; python_version<"3.5"',
     'numpy; python_version>="3.5"',


Test patch:
diff --git a/test/test_graphene.py b/test/test_graphene.py
index 836f16c..ac403e5 100644
--- a/test/test_graphene.py
+++ b/test/test_graphene.py
@@ -331,8 +331,20 @@ def graphene_vol(cv_supervoxels,  requests_mock, monkeypatch, N=64):
     "data_dir": cv_supervoxels,
     "data_type": "uint64",
     "graph": {
-      "chunk_size": [64, 64, 64]
+      "chunk_size": [64, 64, 64],
+      "bounding_box": [2048, 2048, 512], 
+      "chunk_size": [256, 256, 512], 
+      "cv_mip": 0, 
+      "n_bits_for_layer_id": 8, 
+      "n_layers": 12, 
+      "spatial_bit_masks": {
+        '1': 10, '2': 10, '3': 9, 
+        '4': 8, '5': 7, '6': 6, 
+        '7': 5, '8': 4, '9': 3, 
+        '10': 2, '11': 1, '12': 1
+      }
     },
+    "app": { "supported_api_versions": [0, 1] },
     "mesh": "mesh_mip_2_err_40_sv16",
     "num_channels": 1,
     "scales": [
@@ -371,6 +383,18 @@ def test_gcv(graphene_vol):
   assert graphene_vol[0,0,0].shape == (1,1,1,1)
 
 
+def test_get_roots(graphene_vol):
+  roots = graphene_vol.get_roots([])
+  assert np.all(roots == [])
+
+  segids = [0, 0, 0, 0, 0]
+  roots = graphene_vol.get_roots(segids)
+  assert np.all(roots == segids)
+
+  segids = [0, 864691135462849854, 864691135462849854, 0]
+  roots = graphene_vol.get_roots(segids)
+  assert np.all(roots == segids)
+
 def faces_to_edges(faces, return_index=False):
   """
   Given a list of faces (n,3), return a list of edges (n*3,2)
diff --git a/test/test_lib.py b/test/test_lib.py
index 0813598..a69c2a3 100644
--- a/test/test_lib.py
+++ b/test/test_lib.py
@@ -194,3 +194,23 @@ def test_bbox_from_filename():
   for fn in filenames:
     with pytest.raises(ValueError):
       bbox = Bbox.from_filename(fn)
+
+def test_bbox_to_filename():
+  bbx = Bbox([0,2,4], [1,3,5])
+
+  assert bbx.to_filename() == "0-1_2-3_4-5"
+  assert bbx.to_filename(None) == "0-1_2-3_4-5"
+  assert bbx.to_filename(0) == "0-1_2-3_4-5"
+  assert bbx.to_filename(1) == "0.0-1.0_2.0-3.0_4.0-5.0"
+
+  bbx = Bbox([1.1,3.2,5.49], [2.000003,4.0000000000000005,6.12372412421])
+
+  assert bbx.to_filename(3) == "1.100-2.000_3.200-4.000_5.490-6.124"
+
+
+
+
+
+
+
+
diff --git a/test/test_skeletons.py b/test/test_skeletons.py
index 25b3f61..26a4cfa 100644
--- a/test/test_skeletons.py
+++ b/test/test_skeletons.py
@@ -738,9 +738,9 @@ def test_sharded():
 
   shutil.rmtree('/tmp/removeme/skeletons')
 
-def test_spatial_index():
+def test_integer_spatial_index():
   test_dir = os.path.dirname(os.path.abspath(__file__))
-  vol = CloudVolume('file://{}/test_cv'.format(test_dir))
+  vol = CloudVolume(f'file://{test_dir}/test_cv')
   
   spatial_index = vol.skeleton.spatial_index