diff --git a/extensions/chaeo/test_zstack.py b/extensions/chaeo/test_zstack.py
index e423d0c5de352d7791c1774f5cf11e79a1b81731..ebe1172203187b345d9e4b16b35a5f932fd2d3ab 100644
--- a/extensions/chaeo/test_zstack.py
+++ b/extensions/chaeo/test_zstack.py
@@ -25,12 +25,13 @@ class TestZStackDerivedDataProducts(unittest.TestCase):
         self.obmap = InMemoryDataAccessor(self.pxmap.data > pipeline_params['threshold'])
         # write_accessor_data_to_file(output_path / 'obmap.tif', self.obmap)
 
-    def test_zmask_makes_correct_boxes(self, mask_type='boxes', filters=None):
+    def test_zmask_makes_correct_boxes(self, mask_type='boxes', filters=None, expand_box_by=None):
         zmask, meta = build_zmask_from_object_mask(
             self.obmap.get_one_channel_data(0),
             self.stack.get_one_channel_data(0),
             mask_type=mask_type,
             filters=filters,
+            expand_box_by=expand_box_by,
         )
         zmask_acc = InMemoryDataAccessor(zmask)
         self.assertTrue(zmask_acc.is_mask())
@@ -54,4 +55,7 @@ class TestZStackDerivedDataProducts(unittest.TestCase):
         return self.test_zmask_makes_correct_boxes(mask_type='contours')
 
     def test_zmask_makes_correct_boxes_with_filters(self):
-        return self.test_zmask_makes_correct_boxes(filters={'area': (1e3, 1e4)})
\ No newline at end of file
+        return self.test_zmask_makes_correct_boxes(filters={'area': (1e3, 1e4)})
+
+    def test_zmask_makes_correct_expanded_boxes(self):
+        return self.test_zmask_makes_correct_boxes(expand_box_by=(64, 2))
\ No newline at end of file
diff --git a/extensions/chaeo/zmask.py b/extensions/chaeo/zmask.py
index 0141386b82319c8dfad81d5533b296f7ffa00da4..03188688db74cff9dc168745ac0dbcb2ca61ea42 100644
--- a/extensions/chaeo/zmask.py
+++ b/extensions/chaeo/zmask.py
@@ -8,27 +8,40 @@ from model_server.accessors import GenericImageDataAccessor
 # build a single boolean 3d mask (objects v. bboxes) and return bounding boxes
 def build_zmask_from_object_mask(
         obmask: GenericImageDataAccessor,
-        stack: GenericImageDataAccessor,
+        zstack: GenericImageDataAccessor,
         filters=None,
         mask_type='contour',
-        expand_box_by=(0, 0)
+        expand_box_by=(0, 0),
 ):
     """
-    Given a 2D
-    filters: dict of (min, max) tuples
-    expand_box_by: (xy, z) pixelsf
+    Given a 2D mask of objects, build a 3D mask, where each object's z-position is determined by the index of
+    maximum intensity in z.  Return this zmask and a list of each object's meta information.
+    :param obmask: GenericImageDataAccessor monochrome 2D inary mask of objects
+    :param zstack: GenericImageDataAccessor monochrome zstack of same Y, X dimension as obmask
+    :param filters: dictionary of form {attribute: (min, max)}; valid attributes are 'area' and 'solidity'
+    :param mask_type: if 'boxes', zmask is True in each object's complete bounding box; otherwise 'contours'
+    :param expand_box_by: (xy, z) expands bounding box by (xy, z) pixels except where this hits a boundary
+    :return: tuple (zmask, meta)
+        np.ndarray zmask: boolean mask of same size as stack
+        meta: List containing one Dict per object, with keys:
+            info: object's properties from skimage.measure.regionprops_table, including bounding box (y0, y1, x0, x1)
+            slice: named slice (np.s_) of (optionally) expanded bounding box
+            relative_bounding_box: bounding box (y0, y1, x0, x1) in relative frame of (optionally) expanded bounding box
+            contour: object's contour returned by skimage.measure.find_contours
+            mask: mask of object in relative frame of (optionally) expanded bounding box
     """
 
     # validate inputs
-    assert stack.chroma == 1
-    assert stack.shape_dict['Z'] > 1
+    assert zstack.chroma == 1
+    assert zstack.nz > 1
     assert mask_type in ('contours', 'boxes'), mask_type
     assert obmask.is_mask()
     assert obmask.chroma == 1
-    assert obmask.shape_dict['Z'] == 1
-    lamap = label(obmask.data[:, :, 0, 0])
+    assert obmask.nz == 1
+    assert zstack.hw == obmask.hw
 
-    # build object query
+    # assign object labels and build object query
+    lamap = label(obmask.data[:, :, 0, 0])
     query_str = 'label > 0'  # always true
     if filters is not None:
         for k in filters.keys():
@@ -38,7 +51,7 @@ def build_zmask_from_object_mask(
             query_str = query_str + f' & {k} > {vmin} & {k} < {vmax}'
 
     # build dataframe of objects, assign z index to each object
-    argmax = stack.data.argmax(axis=3, keepdims=True)[:, :, 0, 0]
+    argmax = zstack.data.argmax(axis=3, keepdims=True)[:, :, 0, 0]
     df = (
         pd.DataFrame(
             regionprops_table(
@@ -63,11 +76,11 @@ def build_zmask_from_object_mask(
     lut = np.zeros(lamap.max() + 1) - 1
     lut[df.label] = df.zi
 
-    # convert bounding boxes to slices
+    # convert bounding boxes to numpy slice objects
     ebxy, ebz = expand_box_by
-    h, w, c, nz = stack.shape
+    h, w, c, nz = zstack.shape
 
-    boxes = []
+    meta = []
     for ob in df.itertuples(name='LabeledObject'):
         y0 = max(ob.y0 - ebxy, 0)
         y1 = min(ob.y1 + ebxy, h - 1)
@@ -84,7 +97,6 @@ def build_zmask_from_object_mask(
             'x1': ob.x1 - x0,
         }
 
-        # sl = np.s_[z0: z1 + 1, y0: y1, x0: x1]
         sl = np.s_[y0: y1, x0: x1, 0, z0: z1 + 1]
 
         # compute contours
@@ -92,7 +104,7 @@ def build_zmask_from_object_mask(
         contour = find_contours(obmask)
         mask = obmask[ob.y0: ob.y1, ob.x0: ob.x1]
 
-        boxes.append({
+        meta.append({
             'info': ob,
             'slice': sl,
             'relative_bounding_box': rbb,
@@ -101,7 +113,7 @@ def build_zmask_from_object_mask(
         })
 
     # build mask z-stack
-    zi_st = np.zeros(stack.shape, dtype='bool')
+    zi_st = np.zeros(zstack.shape, dtype='bool')
     if mask_type == 'contours':
         zi_map = (lut[lamap] + 1.0).astype('int')
         idxs = np.array(zi_map) - 1
@@ -116,8 +128,8 @@ def build_zmask_from_object_mask(
         zi_st[:, :, :, -1][lamap == 0] = 0
 
     elif mask_type == 'boxes':
-        for bb in boxes:
+        for bb in meta:
             sl = bb['slice']
             zi_st[sl] = 1
 
-    return zi_st, boxes
\ No newline at end of file
+    return zi_st, meta
\ No newline at end of file
diff --git a/model_server/accessors.py b/model_server/accessors.py
index d9211c4876c21e24ac4330af4de5806193a215a5..7183036a63c653328315761e89c066a30fc64d64 100644
--- a/model_server/accessors.py
+++ b/model_server/accessors.py
@@ -39,6 +39,22 @@ class GenericImageDataAccessor(ABC):
         c = int(channel)
         return InMemoryDataAccessor(self.data[:, :, c:(c+1), :])
 
+    @property
+    def dtype(self):
+        return self.data.dtype
+
+    @property
+    def hw(self):
+        """
+        Get data height and width as a tuple
+        :return: tuple of (Y, X) dimensions
+        """
+        return self.shape_dict['Y'], self.shape_dict['X']
+
+    @property
+    def nz(self):
+        return self.shape_dict['Z']
+
     @property
     def data(self):
         """