Skip to content

Commit b16a728

Browse files
authored
Merge pull request #27 from janelia-cellmap/expand_dims
# Changes **New feature: `--expand_dims` flag** Prepends a size-1 channel dimension to the output array, converting `(z,y,x)` → `(1,z,y,x)`. Supported for all input formats: TIFF, TIFF stack, MRC, zarr v2, and N5. **Bug fixes bundled in** - `TiffStack.__init__`: replaced `da.from_zarr(ZarrTiffStore)` with direct `.zarray` JSON read — `ZarrTiffStore` is incompatible with zarr v3 - `N5Group.apply_ome_template`: `coordinateTransformations` scale/translation vectors had wrong length for non-3D arrays (ndim was computed before the channel axis was prepended) # Implementation Each format's `write_to_zarr` and its worker function (`save_chunk` / `write_tile_slab` / `write_volume_slab`) receives `expand_dims`. The worker reads from the source using `chunk_slice[1:]` (skipping the channel dim absent in the source) and writes `data[np.newaxis]` into the destination. OME multiscales metadata is updated per format: - **Tiff/TiffStack/MRC**: channel axis prepended to `axes/scale/translation/units` in `to_zarr` before `add_ome_metadata` is called - **zarr v2**: post-processing walk (`_patch_group_multiscales`) rewrites the copied multiscales attrs - **N5**: `expand_dims` threaded through `normalize_to_omengff` → `apply_ome_template` / `ome_dataset_metadata` # Tests `tests/test_expand_dims_and_4d.py` - 9 tests - `test_expand_dims_{tiff,tiff_stack,mrc,zarr2,n5}`: verify output shape is `(1, *src_shape)` and OME axes metadata has a leading channel axis - `test_4d_{tiff,mrc,zarr2,n5}`: verify native 4D inputs pass through with correct shape and axes
2 parents c9ab8a9 + e068d69 commit b16a728

9 files changed

Lines changed: 675 additions & 187 deletions

File tree

pixi.lock

Lines changed: 160 additions & 93 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/zarrify/formats/mrc.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(
4545
self.shape = np.squeeze(self.memmap.data.shape)
4646
self.dtype = self.memmap.data.dtype
4747

48-
def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
48+
def write_to_zarr(self, dst_spec: dict, client: Client, expand_dims: bool = False) -> None:
4949
"""Read the MRC file in chunks and write each chunk to a zarr3 array via TensorStore.
5050
5151
Chunks that are entirely zero are skipped to avoid unnecessary writes.
@@ -57,6 +57,9 @@ def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
5757
by :func:`~zarrify.utils.ts_utils.zarr3_spec`.
5858
client:
5959
Dask distributed client used to parallelise chunk writes.
60+
expand_dims:
61+
When True, the destination array has a leading size-1 channel
62+
dimension; the leading slice is stripped when reading the source.
6063
"""
6164
logging.basicConfig(
6265
level=logging.INFO,
@@ -78,15 +81,16 @@ def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
7881
for idx, part in enumerate(out_slices_partitioned):
7982
logging.info(f"{idx + 1} / {len(out_slices_partitioned)}")
8083
start = time.time()
81-
fut = client.map(lambda v: save_chunk(src_path, dst_spec, v), part)
84+
fut = client.map(lambda v: save_chunk(src_path, dst_spec, v, expand_dims), part)
8285
logging.info(
8386
f"Submitted {len(part)} tasks to the scheduler in {time.time() - start:.2f}s"
8487
)
8588
wait(fut)
8689
logging.info(f"Completed {len(part)} tasks in {time.time() - start:.2f}s")
8790

8891

89-
def save_chunk(src_path: str, dst_spec: dict, chunk_slice: Tuple[slice, ...]) -> None:
92+
def save_chunk(src_path: str, dst_spec: dict, chunk_slice: Tuple[slice, ...],
93+
expand_dims: bool = False) -> None:
9094
"""Copy one chunk from an MRC file into a zarr3 TensorStore array.
9195
9296
Chunks that are entirely zero are skipped to avoid unnecessary I/O.
@@ -98,10 +102,14 @@ def save_chunk(src_path: str, dst_spec: dict, chunk_slice: Tuple[slice, ...]) ->
98102
dst_spec:
99103
TensorStore zarr3 spec dict for the destination array.
100104
chunk_slice:
101-
The slice tuple identifying the chunk region to copy.
105+
The slice tuple identifying the chunk region in the destination array.
106+
expand_dims:
107+
When True, strip the leading slice when reading the source (which has
108+
no channel dimension) and prepend np.newaxis before writing.
102109
"""
103110
mrc_file = mrcfile.mmap(src_path, mode="r")
104-
data = mrc_file.data[chunk_slice]
111+
src_slice = chunk_slice[1:] if expand_dims else chunk_slice
112+
data = mrc_file.data[src_slice]
105113
if not (data == 0).all():
106114
dest_arr = open_ts(dst_spec)
107-
dest_arr[chunk_slice].write(data).result()
115+
dest_arr[chunk_slice].write(data[np.newaxis] if expand_dims else data).result()

src/zarrify/formats/n5.py

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,16 @@ def _copy_n5_array_attrs(self, n5_root: str, dest: str, array_paths: list[str])
177177
for k, v in attrs.items():
178178
z_arr.attrs[k] = v
179179

180-
def apply_ome_template(self, zgroup: zarr.Group) -> dict:
180+
def apply_ome_template(self, zgroup: zarr.Group, expand_dims: bool = False) -> dict:
181181
"""Build an OME-NGFF v0.4 multiscales attribute dict from N5 group attributes.
182182
183183
Parameters
184184
----------
185185
zgroup:
186186
Zarr group with N5-style "axes", "units", and "scales" attributes.
187+
expand_dims:
188+
When True, prepend a channel axis to the axes list and an extra
189+
element to the top-level coordinateTransformations.
187190
188191
Returns
189192
-------
@@ -196,40 +199,44 @@ def apply_ome_template(self, zgroup: zarr.Group) -> dict:
196199
ureg = pint.UnitRegistry()
197200
units_list = [str(ureg.Unit(unit)) for unit in zgroup.attrs['units']]
198201

202+
axes = [{"name": axis, "type": "space", "unit": unit}
203+
for (axis, unit) in zip(zgroup.attrs['axes'], units_list)]
204+
if expand_dims:
205+
axes = [{"name": "c", "type": "channel"}] + axes
206+
199207
#populate .zattrs
200-
z_attrs['multiscales'][0]['axes'] = [{"name": axis,
201-
"type": "space",
202-
"unit": unit} for (axis, unit) in zip(zgroup.attrs['axes'],
203-
units_list)]
208+
z_attrs['multiscales'][0]['axes'] = axes
204209
z_attrs['multiscales'][0]['version'] = '0.4'
205210
z_attrs['multiscales'][0]['name'] = zgroup.name
206-
ndim = len(zgroup.attrs['axes'])
211+
ndim = len(axes)
207212
z_attrs['multiscales'][0]['coordinateTransformations'] = [
208213
{"type": "scale", "scale": [1.0] * ndim},
209214
{"type": "translation", "translation": [0.0] * ndim},
210215
]
211216

212217
return z_attrs
213218

214-
def normalize_to_omengff(self, zgroup: zarr.Group) -> None:
219+
def normalize_to_omengff(self, zgroup: zarr.Group, expand_dims: bool = False) -> None:
215220
"""Recursively convert N5 metadata to OME-NGFF multiscales attributes.
216221
217222
Parameters
218223
----------
219224
zgroup:
220225
Root zarr group of the output zarr store.
226+
expand_dims:
227+
When True, prepend a channel axis to axes and coordinate transforms.
221228
"""
222229
group_keys = zgroup.keys()
223230

224231
for key in chain(group_keys, '/'):
225232
if isinstance(zgroup[key], zarr.Group):
226233
if key!='/':
227-
self.normalize_to_omengff(zgroup[key])
234+
self.normalize_to_omengff(zgroup[key], expand_dims)
228235
if 'scales' in zgroup[key].attrs.asdict():
229-
zattrs = self.apply_ome_template(zgroup[key])
236+
zattrs = self.apply_ome_template(zgroup[key], expand_dims)
230237
unsorted_datasets = []
231238
for arr in self._iter_arrays(zgroup[key]):
232-
unsorted_datasets.append(self.ome_dataset_metadata(arr[1], zgroup[key]))
239+
unsorted_datasets.append(self.ome_dataset_metadata(arr[1], zgroup[key], expand_dims))
233240

234241
#1.apply natural sort to organize datasets metadata array for different resolution degrees (s0 -> s10)
235242
#2.add datasets metadata to the omengff template
@@ -246,7 +253,8 @@ def _iter_arrays(group: zarr.Group):
246253
yield from N5Group._iter_arrays(node)
247254

248255
@staticmethod
249-
def ome_dataset_metadata(n5arr: zarr.Array, group: zarr.Group) -> dict:
256+
def ome_dataset_metadata(n5arr: zarr.Array, group: zarr.Group,
257+
expand_dims: bool = False) -> dict:
250258
"""Build one OME-NGFF dataset metadata entry from an N5 array.
251259
252260
Parameters
@@ -255,23 +263,27 @@ def ome_dataset_metadata(n5arr: zarr.Array, group: zarr.Group) -> dict:
255263
Source N5 array with a "transform" attribute.
256264
group:
257265
Parent group used to compute the relative path.
266+
expand_dims:
267+
When True, prepend 1.0/0.0 to the scale/translation vectors.
258268
259269
Returns
260270
-------
261271
dict
262272
A single entry suitable for the "datasets" list in multiscales.
263273
"""
264-
265274
arr_attrs_n5 = n5arr.attrs['transform']
266-
dataset_meta = {
267-
"path": os.path.relpath(n5arr.path, group.path),
268-
"coordinateTransformations": [{
269-
'type': 'scale',
270-
'scale': arr_attrs_n5['scale']},{
271-
'type': 'translation',
272-
'translation' : arr_attrs_n5['translate']
273-
}]}
274-
275+
scale = arr_attrs_n5['scale']
276+
translate = arr_attrs_n5['translate']
277+
if expand_dims:
278+
scale = [1.0] + list(scale)
279+
translate = [0.0] + list(translate)
280+
dataset_meta = {
281+
"path": os.path.relpath(n5arr.path, group.path),
282+
"coordinateTransformations": [
283+
{'type': 'scale', 'scale': scale},
284+
{'type': 'translation', 'translation': translate},
285+
],
286+
}
275287
return dataset_meta
276288

277289
def write_to_zarr(
@@ -281,6 +293,7 @@ def write_to_zarr(
281293
chunk_shape: list[int],
282294
shard_shape: list[int] | None = None,
283295
codec: dict = zstd_codec(level=6),
296+
expand_dims: bool = False,
284297
) -> None:
285298
"""Copy all N5 arrays into a zarr3 store via TensorStore.
286299
@@ -325,12 +338,14 @@ def write_to_zarr(
325338
src_arr = open_ts(src_spec)
326339
shape = src_arr.shape
327340
dtype = np.dtype(src_arr.dtype.numpy_dtype)
341+
out_shape = (1, *shape) if expand_dims else shape
328342

329343
# trim chunk/shard shapes to array ndim; N5 trees can hold mixed-dimensionality arrays
330-
arr_chunk_shape = [min(c, s) for c, s in zip(list(chunk_shape)[-len(shape):], shape)]
344+
arr_chunk_shape_base = [min(c, s) for c, s in zip(list(chunk_shape)[-len(shape):], shape)]
345+
arr_chunk_shape = ([1] + arr_chunk_shape_base) if expand_dims else arr_chunk_shape_base
331346
arr_shard_shape = (
332347
align_shard_to_chunks(
333-
[min(s, dim) for s, dim in zip(list(shard_shape)[-len(shape):], shape)],
348+
[min(s, dim) for s, dim in zip(list(shard_shape)[-len(out_shape):], out_shape)],
334349
arr_chunk_shape,
335350
)
336351
if shard_shape is not None else None
@@ -341,7 +356,7 @@ def write_to_zarr(
341356
dst_spec = zarr3_spec(
342357
store_path=dest,
343358
array_path=rel_path,
344-
shape=shape,
359+
shape=out_shape,
345360
dtype=dtype,
346361
chunk_shape=arr_chunk_shape,
347362
shard_shape=arr_shard_shape,
@@ -353,7 +368,7 @@ def write_to_zarr(
353368
dest_chunks = dest_arr.chunk_layout.write_chunk.shape
354369

355370
out_slices = slices_from_chunks(
356-
normalize_chunks(dest_chunks, shape=shape)
371+
normalize_chunks(dest_chunks, shape=out_shape)
357372
)
358373
# break the slices up into batches, to make things easier for the dask scheduler
359374
out_slices_partitioned = tuple(partition_all(100000, out_slices))
@@ -362,7 +377,7 @@ def write_to_zarr(
362377
logging.info(f"{idx + 1} / {len(out_slices_partitioned)}")
363378
start = time.time()
364379
fut = client.map(
365-
lambda v: save_chunk(src_spec, dst_spec, v, invert=False), part
380+
lambda v: save_chunk(src_spec, dst_spec, v, invert=False, expand_dims=expand_dims), part
366381
)
367382
logging.info(
368383
f"Submitted {len(part)} tasks to the scheduler in {time.time() - start:.2f}s"
@@ -376,14 +391,15 @@ def write_to_zarr(
376391
# copy array-level N5 attributes (e.g. transform) then build OME metadata
377392
self._copy_n5_array_attrs(n5_root_path, dest, n5_array_paths)
378393
z_root = zarr.open_group(store=z_store, mode='a')
379-
self.normalize_to_omengff(z_root)
394+
self.normalize_to_omengff(z_root, expand_dims)
380395

381396

382397
def save_chunk(
383398
src_spec: dict,
384399
dst_spec: dict,
385400
chunk_slice: Tuple[slice, ...],
386401
invert: bool = False,
402+
expand_dims: bool = False,
387403
) -> None:
388404
"""Copy one chunk from an N5 array into a zarr3 TensorStore array.
389405
@@ -396,16 +412,22 @@ def save_chunk(
396412
dst_spec:
397413
TensorStore zarr3 driver spec for the destination array.
398414
chunk_slice:
399-
Slice tuple identifying the chunk region to copy.
415+
Slice tuple identifying the chunk region in the destination array.
400416
invert:
401417
When True, apply bitwise inversion to the data before writing.
418+
expand_dims:
419+
When True, strip the leading slice when reading the source (which has
420+
no channel dimension) and prepend np.newaxis before writing.
402421
"""
403422
src = open_ts(src_spec)
404-
data = src[chunk_slice].read().result()
423+
src_slice = chunk_slice[1:] if expand_dims else chunk_slice
424+
data = src[src_slice].read().result()
405425
# only store data if it is not all 0s
406426
if (data == 0).all():
407427
return
408428
if invert:
409429
data = np.invert(data)
430+
if expand_dims:
431+
data = data[np.newaxis]
410432
dest = open_ts(dst_spec)
411433
dest[chunk_slice].write(data).result()

src/zarrify/formats/tiff.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def __init__(
6969
self.metadata["translation"] = self.metadata["translation"][-self.ndim:]
7070
self.metadata["units"] = self.metadata["units"][-self.ndim:]
7171

72-
def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
72+
def write_to_zarr(self, dst_spec: dict, client: Client, expand_dims: bool = False) -> None:
7373
"""Read the TIFF file in slabs and write each slab to a zarr3 array via TensorStore.
7474
7575
Parameters
@@ -79,6 +79,9 @@ def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
7979
by :func:`~zarrify.utils.ts_utils.zarr3_spec`.
8080
client:
8181
Dask distributed client used to parallelise slab writes.
82+
expand_dims:
83+
When True, the destination array has a leading size-1 channel
84+
dimension; each slab is written with an extra np.newaxis prepended.
8285
"""
8386
# TODO: With large shard shapes (e.g. 1024^3) the slab thickness along
8487
# the slab axis becomes 1024 voxels, potentially exhausting worker memory.
@@ -87,21 +90,25 @@ def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
8790
# can process multiple passes concurrently without holding the full shard
8891
# in memory at once.
8992

90-
axes = self.metadata["axes"]
91-
slab_axis = axes.index("z") if "z" in axes else 0
93+
# axes in metadata are already trimmed to source ndim; use them to find z.
94+
src_axes = self.metadata["axes"][1:] if expand_dims else self.metadata["axes"]
95+
slab_axis = src_axes.index("z") if "z" in src_axes else 0
9296

9397
dest_arr = open_ts(dst_spec)
98+
# dest_chunks includes the leading 1 when expand_dims; strip it for
99+
# slice computation which is done against the source shape.
94100
dest_chunks = list(dest_arr.chunk_layout.write_chunk.shape)
101+
src_chunks = dest_chunks[1:] if expand_dims else dest_chunks
95102

96-
slice_chunks = dest_chunks
103+
slice_chunks = src_chunks
97104
if self.optimize_reads:
98105
logger.info("Optimizing read chunking...")
99106
logger.info(f"Output zarr3 write-chunk shape: {dest_chunks}")
100107
logger.info(f"Input TIFF chunk shape: {self._tiff_chunks}")
101-
slice_chunks = dest_chunks[: slab_axis + 1].copy()
108+
slice_chunks = src_chunks[: slab_axis + 1].copy()
102109

103110
for dest_chunkdim, tiff_chunkdim, tiff_dim in zip(
104-
dest_chunks[slab_axis + 1:],
111+
src_chunks[slab_axis + 1:],
105112
self._tiff_chunks[slab_axis + 1:],
106113
self.shape[slab_axis + 1:],
107114
):
@@ -123,7 +130,7 @@ def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
123130

124131
start = time.time()
125132
fut = client.map(
126-
lambda v: write_volume_slab(v, dst_spec, src_path), slice_tuples
133+
lambda v: write_volume_slab(v, dst_spec, src_path, expand_dims), slice_tuples
127134
)
128135
logger.info(
129136
f"Submitted {len(slice_tuples)} tasks to the scheduler in "
@@ -134,7 +141,8 @@ def write_to_zarr(self, dst_spec: dict, client: Client) -> None:
134141
logger.info(f"Completed {len(slice_tuples)} tasks in {time.time() - start:.2f}s")
135142

136143

137-
def write_volume_slab(slice_tuple: tuple, dst_spec: dict, src_path: str) -> None:
144+
def write_volume_slab(slice_tuple: tuple, dst_spec: dict, src_path: str,
145+
expand_dims: bool = False) -> None:
138146
"""Copy one slab from a TIFF file into a zarr3 TensorStore array.
139147
140148
The TIFF is read directly via tifffile into NumPy (no TensorStore TIFF
@@ -143,12 +151,18 @@ def write_volume_slab(slice_tuple: tuple, dst_spec: dict, src_path: str) -> None
143151
Parameters
144152
----------
145153
slice_tuple:
146-
The slice tuple identifying the slab region to copy.
154+
The slice tuple identifying the slab region in the source array.
147155
dst_spec:
148156
TensorStore zarr3 spec dict for the destination array.
149157
src_path:
150158
Path to the source TIFF file.
159+
expand_dims:
160+
When True, prepend a size-1 channel dimension to the data before
161+
writing and offset the destination slice accordingly.
151162
"""
152-
data = imread(src_path)[slice_tuple]
163+
data = np.asarray(imread(src_path)[slice_tuple])
153164
dest_arr = open_ts(dst_spec)
154-
dest_arr[slice_tuple].write(np.asarray(data)).result()
165+
if expand_dims:
166+
dest_arr[(slice(0, 1), *slice_tuple)].write(data[np.newaxis]).result()
167+
else:
168+
dest_arr[slice_tuple].write(data).result()

0 commit comments

Comments
 (0)