Repository: lincbrain/linc-convert
Pull request #42: Add LSM `multi_slice` and `spool` modules
Author: login: calvinchai
Created: 2025-04-01T18:25:56Z | Merged: 2025-04-24T15:25:19Z



Diff:
diff --git a/conda.yaml b/conda.yaml
index c8015bf2..54781a6d 100644
--- a/conda.yaml
+++ b/conda.yaml
@@ -20,3 +20,6 @@ dependencies:
   - scipy
   - pip:
     - cyclopts>=3.0.0
+    - wkw
+    - nifti-zarr
+    
diff --git a/linc_convert/modalities/lsm/__init__.py b/linc_convert/modalities/lsm/__init__.py
index abf7cee5..c605f13d 100644
--- a/linc_convert/modalities/lsm/__init__.py
+++ b/linc_convert/modalities/lsm/__init__.py
@@ -3,7 +3,7 @@
 try:
     import tifffile as _  # noqa: F401
 
-    __all__ = ["cli", "mosaic"]
-    from . import cli, mosaic
+    __all__ = ["cli", "mosaic", "multi_slice", "spool"]
+    from . import cli, mosaic, multi_slice, spool
 except ImportError:
     pass
diff --git a/linc_convert/modalities/lsm/multi_slice.py b/linc_convert/modalities/lsm/multi_slice.py
new file mode 100644
index 00000000..c071cac2
--- /dev/null
+++ b/linc_convert/modalities/lsm/multi_slice.py
@@ -0,0 +1,373 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        files named `*_y{:02d}_z{:02d}*.tiff*`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if `out` path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimensions, in microns.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*.tiff"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chunk shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
+
+        zstart = sum(shape[chunky][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
+    while any(x > 1 for x in omz[str(level)].shape[-3:]):
+        prev_array = omz[str(level)]
+        prev_shape = prev_array.shape[-3:]
+        level += 1
+
+        new_shape = list(map(lambda x: max(1, x // 2), prev_shape))
+        if all(x < chunk for x in new_shape):
+            break
+        print("Compute level", level, "with shape", new_shape)
+        omz.create_dataset(str(level), shape=new_shape, **opt)
+        new_array = omz[str(level)]
+
+        nz, ny, nx = prev_array.shape[-3:]
+        ncz = ceildiv(nz, max_load)
+        ncy = ceildiv(ny, max_load)
+        ncx = ceildiv(nx, max_load)
+
+        for cz in range(ncz):
+            for cy in range(ncy):
+                for cx in range(ncx):
+                    print(f"chunk ({cz}, {cy}, {cx}) / ({ncz}, {ncy}, {ncx})", end="\r")
+
+                    dat = prev_array[
+                        ...,
+                        cz * max_load : (cz + 1) * max_load,
+                        cy * max_load : (cy + 1) * max_load,
+                        cx * max_load : (cx + 1) * max_load,
+                    ]
+                    crop = [0 if x == 1 else x % 2 for x in dat.shape[-3:]]
+                    slicer = [slice(-1) if x else slice(None) for x in crop]
+                    dat = dat[(Ellipsis, *slicer)]
+                    pz, py, px = dat.shape[-3:]
+
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            min(pz, 2),
+                            max(py // 2, 1),
+                            min(py, 2),
+                            max(px // 2, 1),
+                            min(px, 2),
+                        ]
+                    )
+                    dat = dat.transpose([0, 2, 4, 1, 3, 5])
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            max(py // 2, 1),
+                            max(px // 2, 1),
+                            -1,
+                        ]
+                    )
+                    dat = np.median(dat, -1)
+
+                    new_array[
+                        ...,
+                        cz * max_load // 2 : (cz + 1) * max_load // 2,
+                        cy * max_load // 2 : (cy + 1) * max_load // 2,
+                        cx * max_load // 2 : (cx + 1) * max_load // 2,
+                    ] = dat
+
+    print("")
+    nblevel = level
+
+    # Write OME-Zarr multiscale metadata
+    print("Write metadata")
+    multiscales = [
+        {
+            "version": "0.4",
+            "axes": [
+                {"name": "z", "type": "space", "unit": "micrometer"},
+                {"name": "y", "type": "space", "unit": "micrometer"},
+                {"name": "x", "type": "space", "unit": "micrometer"},
+            ],
+            "datasets": [],
+            "type": "median window 2x2x2",
+            "name": "",
+        }
+    ]
+
+    voxel_size = list(map(float, reversed(voxel_size)))
+    factor = [1] * 3
+    for n in range(nblevel):
+        shape = omz[str(n)].shape[-3:]
+        multiscales[0]["datasets"].append({})
+        level = multiscales[0]["datasets"][-1]
+        level["path"] = str(n)
+
+        # We made sure that the downsampling level is exactly 2
+        # However, once a dimension has size 1, we stop downsampling.
+        if n > 0:
+            shape_prev = omz[str(n - 1)].shape[-3:]
+            if shape_prev[0] != shape[0]:
+                factor[0] *= 2
+            if shape_prev[1] != shape[1]:
+                factor[1] *= 2
+            if shape_prev[2] != shape[2]:
+                factor[2] *= 2
+
+        level["coordinateTransformations"] = [
+            {
+                "type": "scale",
+                "scale": [
+                    factor[0] * voxel_size[0],
+                    factor[1] * voxel_size[1],
+                    factor[2] * voxel_size[2],
+                ],
+            },
+            {
+                "type": "translation",
+                "translation": [
+                    (factor[0] - 1) * voxel_size[0] * 0.5,
+                    (factor[1] - 1) * voxel_size[1] * 0.5,
+                    (factor[2] - 1) * voxel_size[2] * 0.5,
+                ],
+            },
+        ]
+    multiscales[0]["coordinateTransformations"] = [
+        {"scale": [1.0] * 3, "type": "scale"}
+    ]
+    omz.attrs["multiscales"] = multiscales
+
+    if not nii:
+        print("done.")
+        return
+
+    # Write NIfTI-Zarr header
+    # NOTE: we use nifti2 because dimensions typically do not fit in a short
+    # TODO: we do not write the json zattrs, but it should be added in
+    #       once the nifti-zarr package is released
+    shape = list(reversed(omz["0"].shape))
+    shape = shape[:3] + [1] + shape[3:]  # insert time dimension
+    affine = orientation_to_affine(orientation, *voxel_size)
+    if center:
+        affine = center_affine(affine, shape[:3])
+    header = nib.Nifti2Header()
+    header.set_data_shape(shape)
+    header.set_data_dtype(omz["0"].dtype)
+    header.set_qform(affine)
+    header.set_sform(affine)
+    header.set_xyzt_units(nib.nifti1.unit_codes.code["micron"])
+    header.structarr["magic"] = b"nz2\0"
+    header = np.frombuffer(header.structarr.tobytes(), dtype="u1")
+    opt = {
+        "chunks": [len(header)],
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": "|u1",
+        "fill_value": None,
+        "compressor": None,
+    }
+    omz.create_dataset("nifti", data=header, shape=shape, **opt)
+    print("done.")
diff --git a/linc_convert/modalities/lsm/spool.py b/linc_convert/modalities/lsm/spool.py
new file mode 100644
index 00000000..9dfb3a1b
--- /dev/null
+++ b/linc_convert/modalities/lsm/spool.py
@@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into Zarr.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimensions, in microns.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with "
+                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
+            )
+        tiles_info_by_index[(tile.y, tile.z)] = tile
+
+    # Set default output path if not provided
+    if not out:
+        out = all_tiles_info[0].prefix + all_tiles_info[0].suffix
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # Determine unique Y and Z tile indices
+    z_tiles = set(tile.z for tile in all_tiles_info)
+    y_tiles = set(tile.y for tile in all_tiles_info)
+    min_y_tile, max_y_tile = min(y_tiles), max(y_tiles)
+    min_z_tile, max_z_tile = min(z_tiles), max(z_tiles)
+    num_y_tiles, num_z_tiles = len(y_tiles), len(z_tiles)
+
+    # Initialize dtype and shapes storage
+    dtypes = defaultdict(list)
+    expected_sx = 0
+    expected_sy = {}
+    expected_sz = {}
+    all_shapes = np.empty((num_y_tiles, num_z_tiles, 3), dtype=int)
+
+    # Collect shape and dtype info from all tiles
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        for y_tile in range(min_y_tile, max_y_tile + 1):
+            tile = tiles_info_by_index.get((y_tile, z_tile))
+            if tile is None:
+                warnings.warn(f"Missing tile {y_tile}, {z_tile}")
+                continue
+            reader = tile.reader
+            sz, sy, sx = reader.assembled_spool_shape
+            dt = reader.dtype
+            # Collect shapes and dtypes.
+            rel_y, rel_z = y_tile - min_y_tile, z_tile - min_z_tile
+            all_shapes[rel_y, rel_z] = sx, sy, sz
+            expected_sx = sx
+            expected_sy[y_tile] = sy
+            expected_sz[z_tile] = sz
+            dtypes[dt].append((y_tile, z_tile))
+
+    # Ensure consistent dtype
+    if len(dtypes) != 1:
+        warnings.warn("Two or more dtypes in tiles:\n" + str(dict(dtypes)))
+    dtype = next(iter(dtypes))
+
+    # Ensure tiles's shapes are compatible
+    diff_sx = (all_shapes[:, :, 0] != expected_sx)
+    if diff_sx.any():
+        y_idxs, z_idxs = np.where(diff_sx)
+        raise ValueError(
+            f"Inconsistent x shapes at indices: {list(zip(y_idxs, z_idxs))}")
+    for y_tile in range(min_y_tile, max_y_tile + 1):
+        if y_tile not in expected_sy:
+            raise ValueError(f"Missing y tile {y_tile}")
+        diff_sy = (all_shapes[:, :, 1] != expected_sy[y_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sy)
+            raise ValueError(
+                f"Inconsistent y shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        if z_tile not in expected_sz:
+            raise ValueError(f"Missing z tile {z_tile}")
+        diff_sz = (all_shapes[:, :, 2] != expected_sz[z_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sz)
+            raise ValueError(
+                f"Inconsistent z shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+
+    # Calculate full dataset dimensions
+    full_shape_x = expected_sx
+    full_shape_y = sum(expected_sy.values()) - (num_y_tiles - 1) * overlap
+    full_shape_z = sum(expected_sz.values())
+    fullshape = [full_shape_z, full_shape_y, full_shape_x]
+
+    # Initialize Zarr group and array
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    # Populate Zarr array from tiles
+    for i, tile_info in enumerate(all_tiles_info):
+        rel_y, rel_z = tile_info.y - min_y_tile, tile_info.z - min_z_tile
+        dat = tile_info.reader.assemble_cropped()
+        if num_y_tiles != 1 and overlap != 0:
+            # if not first y tile, crop half overlapped rows at the beginning
+            if tile_info.y != min_y_tile:
+                dat = dat[:, overlap // 2:, :]
+            # if not last y tile, crop half overlapped rows at the end
+            # if overlap is odd, we need to crop an extra column
+            if tile_info.y != max_y_tile:
+                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
+        ystart = sum(expected_sy[min_y_tile + y_idx] - overlap for y_idx in
+                     range(rel_y))
+        zstart = sum(expected_sz[min_z_tile + z_idx] for z_idx in range(rel_z))
+        if rel_y != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart + dat.shape[0]}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + dat.shape[0]),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # Generate Zarr pyramid and metadata
+    generate_pyramid(omz)
+    write_ome_metadata(omz, axes = ["z","y","x"],space_scale=voxel_size)
+
+    # Write NIfTI-Zarr header:
+    if not nii:
+        return
+
+    header, _ = default_nifti_header(
+        omz["0"],
+        omz.attrs.get("ome", omz.attrs).get("multiscales", None)
+    )
+
+    shape = list(reversed(omz["0"].shape))
+    shape = shape[:3] + [1] + shape[3:]  # insert time dimension
+    affine = orientation_to_affine(orientation, *voxel_size)
+    if center:
+        affine = center_affine(affine, shape[:3])
+    header.set_data_shape(shape)
+    header.set_data_dtype(omz["0"].dtype)
+    header.set_qform(affine)
+    header.set_sform(affine)
+    header.set_xyzt_units(nib.nifti1.unit_codes.code["micron"])
+
+    write_nifti_header(omz, header)
+
diff --git a/linc_convert/utils/spool.py b/linc_convert/utils/spool.py
new file mode 100644
index 00000000..64bbeb0b
--- /dev/null
+++ b/linc_convert/utils/spool.py
@@ -0,0 +1,191 @@
+"""
+The following script is derived from https://github.com/CBI-PITT/holis_tools/blob/main/holis_tools/zyla_spool_reader.py.
+
+Copyright (c) 2021, Alan M Watson
+
+For more information, see the [`holis_tools` LICENCE](https://github.com/CBI-PITT/holis_tools/blob/main/LICENCE)
+"""
+
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_spool_files_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_spool_files_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numColumns = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+
+        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
+            numRows = numDepths + 1
+        else:
+            numRows = numDepths + 2
+
+        self.spool_shape = (numFramesPerSpool, numRows, numColumns)
+        self.numDepths = numDepths
+
+    def _load_spool_file(self, spool_file_name):
+        file = self._make_filename_from_spool_set(spool_file_name)
+        print(f'Reading file {spool_file_name}')
+        with open(file, 'rb') as f:
+            array = np.frombuffer(f.read(), dtype=self.dtype)
+        return np.reshape(array, self.spool_shape)
+
+    def __getitem__(self, key):
+        if isinstance(key, str):
+            assert key in self.spool_files, 'Must be a spool file in self.spool_files OR integer index in self.spool_files'
+            return self._load_spool_file(key)
+        elif isinstance(key, int):
+            return self._load_spool_file(self.spool_files[key])
+
+    def __iter__(self):
+        yield from (self[x] for x in range(len(self)))
+
+    def __contains__(self, item):
+        return item in self.spool_files
+
+    def __len__(self):
+        return len(self.spool_files)
+
+    def _get_spool_names_in_order(self):
+        '''
+        Spool files are ordered sequentially in the order they were collected 0,1,2,...,201,202,203,...
+        but file names are recorded as the reversed number padded to
+        10 digits (0000000000,1000000000,20000000000,...,1020000000,2020000000,3020000000,...) + spool.dat
+        '''
+        spool_files = self._list_spool_files()
+        for idx in range(len(spool_files)):
+            # Convert index to string, pad with zeros to 10 digits and reverse
+            tmp = str(idx).zfill(10)[::-1]
+            tmp = f'{tmp}spool.dat'
+            if tmp in spool_files:
+                yield tmp
+            else:
+                warnings.warn(f"{tmp} not located in spool directory")
+
+    def assemble(self):
+        axis_0_shape = self.spool_shape[0]
+        canvas = np.zeros((axis_0_shape * len(self), *self.spool_shape[1:]),
+                          dtype=self.dtype)
+        for idx, spool_file in enumerate(self):
+            start = idx * axis_0_shape
+            stop = start + axis_0_shape
+            canvas[start:stop] = spool_file
+        print(canvas.shape)
+        return canvas
+
+    # this is the modified version for lsm pipeline
+    def assemble_cropped(self):
+        return self.assemble().transpose(1, 2, 0)[:self.numDepths,:,:]
\ No newline at end of file
diff --git a/linc_convert/utils/zarr/generate_pyramid.py b/linc_convert/utils/zarr/generate_pyramid.py
new file mode 100644
index 00000000..a9a2fcdc
--- /dev/null
+++ b/linc_convert/utils/zarr/generate_pyramid.py
@@ -0,0 +1,165 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or if the level's shape is smaller than the chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape[-ndim:])
+            ]
+            # Only crop the axes that are downsampled
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
+
+            if any(n == 0 for n in dat.shape):
+                # last strip had a single voxel, nothing to do
+                continue
+
+            patch_shape = dat.shape[-ndim:]
+
+            # Reshape into patches of shape 2x2x2
+            windowed_shape = [
+                x for n in patch_shape for x in (max(n // 2, 1), min(n, 2))
+            ]
+            if no_pyramid_axis is not None:
+                windowed_shape[2 * no_pyramid_axis] = patch_shape[no_pyramid_axis]
+                windowed_shape[2 * no_pyramid_axis + 1] = 1
+
+            dat = dat.reshape(batch + windowed_shape)
+            # -> last `ndim` dimensions have shape 2x2x2
+            dat = dat.transpose(
+                list(range(len(batch)))
+                + list(range(len(batch), len(batch) + 2 * ndim, 2))
+                + list(range(len(batch) + 1, len(batch) + 2 * ndim, 2))
+            )
+            # -> flatten patches
+            smaller_shape = [max(n // 2, 1) for n in patch_shape]
+            if no_pyramid_axis is not None:
+                smaller_shape[no_pyramid_axis] = patch_shape[no_pyramid_axis]
+
+            dat = dat.reshape(batch + smaller_shape + [-1])
+
+            # Compute the median/mean of each patch
+            dtype = dat.dtype
+            dat = func(dat, axis=-1)
+            dat = dat.astype(dtype)
+
+            # Write output
+            slicer = [Ellipsis] + [
+                slice(i * max_load // 2, min((i + 1) * max_load // 2, n))
+                if axis_index != no_pyramid_axis
+                else slice(i * max_load, min((i + 1) * max_load, n))
+                for i, axis_index, n in zip(chunk_index, range(ndim), shape)
+            ]
+
+            omz[str(level)][tuple(slicer)] = dat
+
+    print("")
+
+    return allshapes
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index f4bde063..dd0ac250 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ cyclopts = "^3.0.0"
 numpy = "*"
 nibabel = "*"
 zarr = "^2.0.0"
+nifti-zarr = "*"
 # optionals
 glymur = { version = "*", optional = true }
 tifffile = { version = "*", optional = true }


Reviews:
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T15:22:33Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T15:37:47Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T15:38:56Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T17:27:55Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T17:38:42Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T17:47:24Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T18:00:04Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T15:49:44Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T15:50:32Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T15:51:35Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T15:51:45Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:58:34Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T16:00:56Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T16:37:59Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T17:03:37Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T17:04:04Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T17:04:27Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T17:13:58Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T18:26:59Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T19:26:53Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:07:51Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:11:18Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:19:10Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:28:40Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T19:44:44Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T20:56:03Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T20:56:40Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T20:57:44Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:06:14Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:07:38Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:11:48Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:16:09Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:22:09Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T21:22:21Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T01:16:00Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T01:17:12Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:00:06Z
user: login: balbasty
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T08:32:44Z
user: login: balbasty
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T08:49:50Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:37:12Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:49:49Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:50:39Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:51:32Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:54:12Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:57:52Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:59:10Z
user: login: balbasty
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T14:59:57Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:03:26Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:03:30Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:03:41Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:29:10Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:38:22Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T20:42:17Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T20:42:41Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T20:42:47Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T21:00:07Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T21:01:09Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T03:57:03Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T03:59:46Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-20T14:12:16Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T16:51:45Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T17:02:53Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:17:34Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:26:46Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:26:50Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:26:54Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:39:03Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:40:14Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:41:21Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:43:36Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T17:47:08Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T19:08:20Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T20:52:19Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T21:14:37Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T21:15:38Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T21:22:51Z
user: login: kabilar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T21:38:49Z
user: login: kabilar
body: Thanks @calvinchai.  Really great work.  Just a few remaining questions above.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-22T21:44:06Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:04:54Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:07:10Z
user: login: calvinchai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:19:27Z
user: login: kabilar
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T14:35:32Z

Review comments:
diff_hunk: @@ -0,0 +1,204 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            # I will first block zip support or we need extra dependencies
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+            # from compression_tools.alt_zip import alt_zip
+            # self.type = '.zip'
+            # self.compression_tools_zip_file = compression_tools_zip_file
+            # self.location = compression_tools_zip_file
+            # self.spool_set = alt_zip(self.location)
path: linc_convert/utils/spool.py
user: login: kabilar
body: ```suggestion
            raise NotImplementedError("Zip storage is not supported.")
            pass
```
created_at: 2025-04-02T15:22:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,204 @@
+# stdlib
path: linc_convert/utils/spool.py
user: login: kabilar
body: Since this script is derived from `zyla_spool_reader.py` and based off of the license in the `holis_tools` repo, we will need to include a copyright notice.  I am not an expert in this process so let me know if there is a better way.  cc @balbasty @satra 

```suggestion
"""
The following script is derived from https://github.com/CBI-PITT/holis_tools/blob/main/holis_tools/zyla_spool_reader.py.

Copyright (c) 2021, Alan M Watson

For more information, see the [`holis_tools` LICENCE](https://github.com/CBI-PITT/holis_tools/blob/main/LICENCE)
"""

# stdlib
```
created_at: 2025-04-02T15:37:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,204 @@
+# stdlib
path: linc_convert/utils/spool.py
user: login: calvinchai
body: this is actually from https://github.com/CBI-PITT/holis_tools
i am neither sure about the copyright.
created_at: 2025-04-02T15:38:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,198 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
path: linc_convert/utils/spool.py
user: login: kabilar
body: Should we just throw an error instead to say that the number of spool files don't match with the number of frames to load?  Because we don't know if the user would want to use the first n number of files or last n number of files in the directory.

```suggestion
        num_spool_files_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
        self.spool_files = self.spool_files[:num_spool_files_to_load]
```
created_at: 2025-04-02T17:27:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,198 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        if self.type == '.zip':
+            return self.spool_set.entries
+        elif self.type == 'dir':
+            return self.spool_set
path: linc_convert/utils/spool.py
user: login: kabilar
body: Since we are only supporting `dir`.

```suggestion
        return self.spool_set
```
created_at: 2025-04-02T17:38:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,198 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        if self.type == '.zip':
+            return self.spool_set.entries
+        elif self.type == 'dir':
+            return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        if self.type == '.zip':
+            ini = self.spool_set['acquisitionmetadata.ini']
+        elif self.type == 'dir':
+            file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+            with open(file, 'rb') as f:
+                ini = f.read()
path: linc_convert/utils/spool.py
user: login: kabilar
body: Since we are only supporting `dir`.

```suggestion
        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
        with open(file, 'rb') as f:
            ini = f.read()
```
created_at: 2025-04-02T17:47:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,198 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
path: linc_convert/utils/spool.py
user: login: calvinchai
body: I guess not. This cropping is based on the cropping from old scripts. [see discussion in ](https://mit-lincbrain.slack.com/archives/C0606UZF2QJ/p1743112170599239)
We can probably add a warning here to let the user know that we conducted some cropping. 
created_at: 2025-04-02T18:00:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,192 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numLatPix = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+        startIndex = self.acquisition_metadata['nbytes']
+        imageSize = self.acquisition_metadata['nbytes']
path: linc_convert/utils/spool.py
user: login: kabilar
body: I know this is from the `CBI-PITT` repo, but not sure why `nbytes` is set as the `startIndex`.  Also `startIndex` and `imageSize` don't seem to be used anywhere.

```suggestion
```
created_at: 2025-04-03T15:49:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numLatPix = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+        startIndex = self.acquisition_metadata['nbytes']
+        imageSize = self.acquisition_metadata['nbytes']
+
+        numRows = numDepths + 2
+
+        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
+            numRows = numDepths + 1
+
+        numColumns = numLatPix
+
+        self.spool_shape = (numFramesPerSpool, numRows, numColumns)
+        self.numDepths = numDepths
+
+    def _load_spool_file(self, spool_file_name):
+        if self.type == '.zip':
+            array = np.frombuffer(self.spool_set[spool_file_name], dtype=self.dtype)
+        elif self.type == 'dir':
+            file = self._make_filename_from_spool_set(spool_file_name)
+            print(f'Reading file {spool_file_name}')
+            with open(file, 'rb') as f:
+                array = np.frombuffer(f.read(), dtype=self.dtype)
path: linc_convert/utils/spool.py
user: login: kabilar
body: ```suggestion
        file = self._make_filename_from_spool_set(spool_file_name)
        print(f'Reading file {spool_file_name}')
        with open(file, 'rb') as f:
            array = np.frombuffer(f.read(), dtype=self.dtype)
```
created_at: 2025-04-03T15:50:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numLatPix = self.acquisition_metadata['stride'] // 2
path: linc_convert/utils/spool.py
user: login: kabilar
body: ```suggestion
        numColumns = self.acquisition_metadata['stride'] // 2
```
created_at: 2025-04-03T15:51:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numLatPix = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+        startIndex = self.acquisition_metadata['nbytes']
+        imageSize = self.acquisition_metadata['nbytes']
+
+        numRows = numDepths + 2
+
+        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
+            numRows = numDepths + 1
+
+        numColumns = numLatPix
+
path: linc_convert/utils/spool.py
user: login: kabilar
body: ```suggestion
```
created_at: 2025-04-03T15:51:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numLatPix = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+        startIndex = self.acquisition_metadata['nbytes']
+        imageSize = self.acquisition_metadata['nbytes']
path: linc_convert/utils/spool.py
user: login: calvinchai
body: I neither have any idea, we can safely remove these fields
created_at: 2025-04-03T15:58:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numLatPix = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+
+        numRows = numDepths + 2
+
+        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
+            numRows = numDepths + 1
+
path: linc_convert/utils/spool.py
user: login: kabilar
body: Functionality is the same.  But just changing for readability.

```suggestion
        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
            numRows = numDepths + 1
        else:
            numRows = numDepths + 2

```
created_at: 2025-04-03T16:00:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,185 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numColumns = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+
+        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
+            numRows = numDepths + 1
+        else:
+            numRows = numDepths + 2
+
+        self.spool_shape = (numFramesPerSpool, numRows, numColumns)
+        self.numDepths = numDepths
+
+    def _load_spool_file(self, spool_file_name):
+        file = self._make_filename_from_spool_set(spool_file_name)
+        print(f'Reading file {spool_file_name}')
+        with open(file, 'rb') as f:
+            array = np.frombuffer(f.read(), dtype=self.dtype)
+        return np.reshape(array, self.spool_shape)
+
+    def __getitem__(self, key):
+        if isinstance(key, str):
+            assert key in self.spool_files, 'Must be a spool file in self.spool_files OR integer index in self.spool_files'
+            return self._load_spool_file(key)
+        elif isinstance(key, int):
+            return self._load_spool_file(self.spool_files[key])
+
+    def __iter__(self):
+        yield from (self[x] for x in range(len(self)))
+
+    def __contains__(self, item):
+        return item in self.spool_files
+
+    def __len__(self):
+        return len(self.spool_files)
+
+    def _get_spool_names_in_order(self):
+        '''
+        Spool files are ordered sequentially in the order they were collected 0,1,2,...,201,202,203,...
+        but file names are recorded as the reversed number padded to
+        10 digits (0000000000,1000000000,20000000000,...,1020000000,2020000000,3020000000,...) + spool.dat
+        '''
+        spool_files = self._list_spool_files()
+        misses = 0
+        for idx in range(len(spool_files)):
+            # Convert index to string, pad with zeros to 10 digits and reverse
+            tmp = str(idx + misses).zfill(10)[::-1]
+            tmp = f'{tmp}spool.dat'
+            if tmp in spool_files:
+                yield tmp
+            else:
+                warnings.warn(f"{tmp} not located in spool directory")
+                misses += 1
path: linc_convert/utils/spool.py
user: login: kabilar
body: I could be mistaken but I don't think we need the `misses` variable here as the generator will proceed to the next `idx`.  See example below.

```
import warnings

def test(n):
    for i in range(n):
        if i == 3:
            warnings.warn('print 3')
        else:
            yield i

for item in test(5):
    print(item)
```

Output
```
0
1
2
<ipython-input-4-7abf6372959e>:4: UserWarning: print 3
  warnings.warn('print 3')
4
```
created_at: 2025-04-03T16:37:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
    omz : zarr.Group
        Zarr group object
```
created_at: 2025-04-03T17:03:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    shard : list[int] | bool | {"auto"} | None
+        Shard size.
+        * If `None`, use same shard size as the input array;
+        * If `False`, no dot use sharding;
+        * If `True` or `"auto"`, automatically find shard size;
+        * Otherwise, use provided shard size.
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
```
created_at: 2025-04-03T17:04:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: Please add this argument to the docstring.
created_at: 2025-04-03T17:04:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
+    levels : int
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
    levels : int | None
```
created_at: 2025-04-03T17:13:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: I suggest changing the type based on how the argument is used:

```
for i, length in enumerate(prev_shape):
    if i == no_pyramid_axis:
        ...
```
```suggestion
    no_pyramid_axis: int | None = None,
```
created_at: 2025-04-03T18:26:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    shard : list[int] | bool | {"auto"} | None
+        Shard size.
+        * If `None`, use same shard size as the input array;
+        * If `False`, no dot use sharding;
+        * If `True` or `"auto"`, automatically find shard size;
+        * Otherwise, use provided shard size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
+        if levels is None:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelared to underlying zarr chunks)
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
        # (note that these are unrelated to underlying zarr chunks)
```
created_at: 2025-04-03T19:26:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    shard : list[int] | bool | {"auto"} | None
+        Shard size.
+        * If `None`, use same shard size as the input array;
+        * If `False`, no dot use sharding;
+        * If `True` or `"auto"`, automatically find shard size;
+        * Otherwise, use provided shard size.
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: calvinchai
body: Is there a reason we want to use different sharding size in low resolution level?
created_at: 2025-04-03T20:07:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
+    levels : int
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: calvinchai
body: instead of nullable, I will make it take value -1 as default
created_at: 2025-04-03T20:11:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,173 @@
+import itertools
+import re
+from typing import Any, Literal
+
+import nibabel as nib
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.unit import convert_unit
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int | None = None,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | str | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    path : PathLike | str
+        Path to parent Zarr
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    shard : list[int] | bool | {"auto"} | None
+        Shard size.
+        * If `None`, use same shard size as the input array;
+        * If `False`, no dot use sharding;
+        * If `True` or `"auto"`, automatically find shard size;
+        * Otherwise, use provided shard size.
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: calvinchai
body: nevermind, I thought you added this docstring, but it was actually redundant one already there. No idea why.
created_at: 2025-04-03T20:19:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,185 @@
+# stdlib
+import configparser
+import io
+import os
+import warnings
+from glob import glob
+
+import numpy as np
+from scipy.io import loadmat
+
+from linc_convert.utils.math import ceildiv
+
+
+class SpoolSetInterpreter:
+
+    def __init__(self, spool_set_path, info_file=None):
+        if os.path.isfile(spool_set_path) and \
+                os.path.splitext(spool_set_path)[-1] == '.zip':
+            raise NotImplementedError("Zip storage is not supported.")
+            pass
+        elif os.path.isdir(spool_set_path):
+            self.type = 'dir'
+            self.parent = spool_set_path
+            self.file_list = glob(os.path.join(spool_set_path, '*'))
+            self.spool_set = tuple([os.path.split(x)[-1] for x in self.file_list])
+        else:
+            assert False, 'The input data structure is not a ZIP file or Directory'
+
+        self._what_spool_format()
+        self.spool_files = tuple(self._get_spool_names_in_order())  # In order
+        self._get_acquisitionparameters_str()
+        self._get_config()
+        self._extract_config_values()
+
+        if info_file is not None:
+            if os.path.isfile(info_file):
+                self._load_info_file(info_file)
+            else:
+                warnings.warn('ERROR: Info file not found.')
+        self.assembled_spool_shape = (self.numDepths,
+                                      self.spool_shape[2],
+                                      self.spool_shape[0]*len(self.spool_files),
+                                      )
+    def _load_info_file(self, info_file):
+        loaded_info = loadmat(info_file)
+        info = loaded_info.get("info", None)
+        if info is None:
+            warnings.warn("ERROR: 'info' structure not found in the MAT file.")
+            return None
+        try:
+            num_total_frames = int(info['camera'][0][0]['kineticSeriesLength'])
+            num_bg_frames = int(info['camera'][0][0]['backgroundFramesNum'])
+
+        except (KeyError, IndexError, ValueError) as e:
+            warnings.warn(
+                f"ERROR: Unable to extract frame information from info file. {e}")
+            return None
+        num_frames_per_spool = int(self.config['multiimage']['ImagesPerFile'])
+        num_frames_to_load = num_total_frames - num_bg_frames
+        num_frames_to_load = ceildiv(num_frames_to_load, num_frames_per_spool)
+        self.spool_files = self.spool_files[:num_frames_to_load]
+
+    def _make_filename_from_spool_set(self, spool_entry):
+        return os.path.join(self.parent, spool_entry)
+
+    @property
+    def entries(self):
+        return self.spool_set
+
+    def _what_spool_format(self):
+        if 'Spooled files.sifx' in self.entries:
+            format = 'zyla'
+        else:
+            raise TypeError("Unknown or unsupported spool file format")
+
+        self.format = format
+
+    def _list_spool_files(self):
+        return sorted(
+            tuple(
+                [x for x in self.entries if '0spool.dat' in x]
+            )
+        )
+
+    def _get_acquisitionparameters_str(self):
+        file = self._make_filename_from_spool_set('acquisitionmetadata.ini')
+        with open(file, 'rb') as f:
+            ini = f.read()
+        ini = ini.decode('UTF-8-sig')  # Encoding for acquisitionmetadata.ini
+        self.acquisitionparameters_str = ini
+
+    def _get_config(self):
+        buf = io.StringIO(self.acquisitionparameters_str)
+        self.config = configparser.ConfigParser()
+        self.config.read_file(buf)
+
+    def _extract_config_values(self):
+        self.acquisition_metadata = {'height': self.config.getint('data', 'AOIHeight'),
+                                     'width': self.config.getint('data', 'AOIWidth'),
+                                     'stride': self.config.getint('data', 'AOIStride')}
+        # ini info
+        dtype = self.config.get('data', 'PixelEncoding')
+
+        if dtype == 'Mono16':
+            dtype = np.dtype('uint16')
+        elif dtype == 'Mono8':
+            dtype = np.dtype('uint8')
+
+        self.acquisition_metadata['dtype'] = dtype
+        self.dtype = dtype
+
+        self.spool_nbytes = self.config.getint('data', 'ImageSizeBytes')
+        self.acquisition_metadata['nbytes'] = self.spool_nbytes
+
+        self.acquisition_metadata['images'] = self.config.getint('multiimage',
+                                                                 'ImagesPerFile')
+
+        numDepths = self.acquisition_metadata['height']
+        numColumns = self.acquisition_metadata['stride'] // 2
+        imageBytes = self.acquisition_metadata['nbytes']
+        numFramesPerSpool = self.acquisition_metadata['images']
+
+        if numDepths % 2:  # if there is an odd number of rows ->  KPEDIT - odd rows means 1 less column for some reason
+            numRows = numDepths + 1
+        else:
+            numRows = numDepths + 2
+
+        self.spool_shape = (numFramesPerSpool, numRows, numColumns)
+        self.numDepths = numDepths
+
+    def _load_spool_file(self, spool_file_name):
+        file = self._make_filename_from_spool_set(spool_file_name)
+        print(f'Reading file {spool_file_name}')
+        with open(file, 'rb') as f:
+            array = np.frombuffer(f.read(), dtype=self.dtype)
+        return np.reshape(array, self.spool_shape)
+
+    def __getitem__(self, key):
+        if isinstance(key, str):
+            assert key in self.spool_files, 'Must be a spool file in self.spool_files OR integer index in self.spool_files'
+            return self._load_spool_file(key)
+        elif isinstance(key, int):
+            return self._load_spool_file(self.spool_files[key])
+
+    def __iter__(self):
+        yield from (self[x] for x in range(len(self)))
+
+    def __contains__(self, item):
+        return item in self.spool_files
+
+    def __len__(self):
+        return len(self.spool_files)
+
+    def _get_spool_names_in_order(self):
+        '''
+        Spool files are ordered sequentially in the order they were collected 0,1,2,...,201,202,203,...
+        but file names are recorded as the reversed number padded to
+        10 digits (0000000000,1000000000,20000000000,...,1020000000,2020000000,3020000000,...) + spool.dat
+        '''
+        spool_files = self._list_spool_files()
+        misses = 0
+        for idx in range(len(spool_files)):
+            # Convert index to string, pad with zeros to 10 digits and reverse
+            tmp = str(idx + misses).zfill(10)[::-1]
+            tmp = f'{tmp}spool.dat'
+            if tmp in spool_files:
+                yield tmp
+            else:
+                warnings.warn(f"{tmp} not located in spool directory")
+                misses += 1
path: linc_convert/utils/spool.py
user: login: calvinchai
body: I think you are right. The current code will skip twice.
created_at: 2025-04-03T20:28:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | str | None
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
    no_pyramid_axis : int | None
```
created_at: 2025-04-04T19:44:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: Please add docstrings for `zarr_config` (including `chunk`, `compressor`, `compressor_opt`, `nii`) and `overlap`.
created_at: 2025-04-04T20:56:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
```
created_at: 2025-04-04T20:56:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
```
created_at: 2025-04-04T20:57:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
    * the first letter corresponds to the horizontal dimension and
        indicates the anatomical meaning of the _right_ of the image,
    * the second letter corresponds to the vertical dimension and
        indicates the anatomical meaning of the _bottom_ of the image.
```
created_at: 2025-04-04T21:06:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: Do we need to extend this definition for sagittal views?
created_at: 2025-04-04T21:07:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
        Path to the output Zarr directory [<INP>.ome.zarr or <INP>.nifti.zarr]
```
created_at: 2025-04-04T21:11:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
        Path to the root directory, which contains a collection of
        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
        collection of files named `*spool.dat`.
```
created_at: 2025-04-04T21:16:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: This variable doesn't appear to be used in this module.

```suggestion
```
created_at: 2025-04-04T21:22:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    max_load
+        Maximum input chunk size when building pyramid
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
```
created_at: 2025-04-04T21:22:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
path: linc_convert/modalities/lsm/spool.py
user: login: calvinchai
body: the docstring for this class is at where this class is. it can be properly propagated when using cli. 
created_at: 2025-04-05T01:15:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
path: linc_convert/modalities/lsm/spool.py
user: login: calvinchai
body: it should be `.nii.zarr`. I will make a separate commit.
created_at: 2025-04-05T01:17:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: calvinchai
body: I may need some help from @balbasty for this. I am not sure about how do we want to handle this `orientation` for each modality. 
created_at: 2025-04-07T14:00:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: balbasty
body: I would think this definition covers all types of orientation, no?

What I mean (or meant) by left/right and top/down was the left/right of the image, when opened in a standard viewer (this was originally written with jp2/tiff in mind). Not anatomical left/right.

Now I see how that can be confusing. Any suggested rewording?
created_at: 2025-04-08T08:32:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
path: linc_convert/modalities/lsm/multi_slice.py
user: login: balbasty
body: ```suggestion
    # check that all chunk shapes are compatible
```
created_at: 2025-04-08T08:35:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
path: linc_convert/modalities/lsm/multi_slice.py
user: login: balbasty
body: Do you want to do the F -> C change now, or in a later refactoring?
(We should probably use defaults read from a config file instead of hardcoded values).
created_at: 2025-04-08T08:36:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
path: linc_convert/modalities/lsm/multi_slice.py
user: login: balbasty
body: (I know this is existing code that has been moved)

Do you want to use the generate_pyramid routine here (ans elsewhere) now or in a later refactor?
created_at: 2025-04-08T08:38:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,279 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr] or <INP>.nii.zarr]
path: linc_convert/modalities/lsm/spool.py
user: login: balbasty
body: ```suggestion
        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
```
created_at: 2025-04-08T08:38:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,279 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr] or <INP>.nii.zarr]
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with {tiles_info_by_index[(tile.y, tile.z)].filename}")
path: linc_convert/modalities/lsm/spool.py
user: login: balbasty
body: ```suggestion
                f"Duplicate tile, file {tile.filename} conflicts with "
                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
            )
```
created_at: 2025-04-08T08:41:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
path: linc_convert/modalities/lsm/multi_slice.py
user: login: balbasty
body: Document in docstring
created_at: 2025-04-08T08:42:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
+    while any(x > 1 for x in omz[str(level)].shape[-3:]):
+        prev_array = omz[str(level)]
+        prev_shape = prev_array.shape[-3:]
+        level += 1
+
+        new_shape = list(map(lambda x: max(1, x // 2), prev_shape))
+        if all(x < chunk for x in new_shape):
+            break
+        print("Compute level", level, "with shape", new_shape)
+        omz.create_dataset(str(level), shape=new_shape, **opt)
+        new_array = omz[str(level)]
+
+        nz, ny, nx = prev_array.shape[-3:]
+        ncz = ceildiv(nz, max_load)
+        ncy = ceildiv(ny, max_load)
+        ncx = ceildiv(nx, max_load)
+
+        for cz in range(ncz):
+            for cy in range(ncy):
+                for cx in range(ncx):
+                    print(f"chunk ({cz}, {cy}, {cx}) / ({ncz}, {ncy}, {ncx})", end="\r")
+
+                    dat = prev_array[
+                        ...,
+                        cz * max_load : (cz + 1) * max_load,
+                        cy * max_load : (cy + 1) * max_load,
+                        cx * max_load : (cx + 1) * max_load,
+                    ]
+                    crop = [0 if x == 1 else x % 2 for x in dat.shape[-3:]]
+                    slicer = [slice(-1) if x else slice(None) for x in crop]
+                    dat = dat[(Ellipsis, *slicer)]
+                    pz, py, px = dat.shape[-3:]
+
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            min(pz, 2),
+                            max(py // 2, 1),
+                            min(py, 2),
+                            max(px // 2, 1),
+                            min(px, 2),
+                        ]
+                    )
+                    dat = dat.transpose([0, 2, 4, 1, 3, 5])
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            max(py // 2, 1),
+                            max(px // 2, 1),
+                            -1,
+                        ]
+                    )
+                    dat = np.median(dat, -1)
+
+                    new_array[
+                        ...,
+                        cz * max_load // 2 : (cz + 1) * max_load // 2,
+                        cy * max_load // 2 : (cy + 1) * max_load // 2,
+                        cx * max_load // 2 : (cx + 1) * max_load // 2,
+                    ] = dat
+
+    print("")
+    nblevel = level
+
+    # Write OME-Zarr multiscale metadata
+    print("Write metadata")
+    multiscales = [
+        {
+            "version": "0.4",
+            "axes": [
+                {"name": "z", "type": "space", "unit": "micrometer"},
+                {"name": "y", "type": "space", "unit": "micrometer"},
+                {"name": "x", "type": "space", "unit": "micrometer"},
+            ],
+            "datasets": [],
+            "type": "median window 2x2x2",
+            "name": "",
+        }
+    ]
+
+    voxel_size = list(map(float, reversed(voxel_size)))
+    factor = [1] * 3
+    for n in range(nblevel):
+        shape = omz[str(n)].shape[-3:]
+        multiscales[0]["datasets"].append({})
+        level = multiscales[0]["datasets"][-1]
+        level["path"] = str(n)
+
+        # We made sure that the downsampling level is exactly 2
+        # However, once a dimension has size 1, we stop downsampling.
+        if n > 0:
+            shape_prev = omz[str(n - 1)].shape[-3:]
+            if shape_prev[0] != shape[0]:
+                factor[0] *= 2
+            if shape_prev[1] != shape[1]:
+                factor[1] *= 2
+            if shape_prev[2] != shape[2]:
+                factor[2] *= 2
+
+        level["coordinateTransformations"] = [
+            {
+                "type": "scale",
+                "scale": [
+                    factor[0] * voxel_size[0],
+                    factor[1] * voxel_size[1],
+                    factor[2] * voxel_size[2],
+                ],
+            },
+            {
+                "type": "translation",
+                "translation": [
+                    (factor[0] - 1) * voxel_size[0] * 0.5,
+                    (factor[1] - 1) * voxel_size[1] * 0.5,
+                    (factor[2] - 1) * voxel_size[2] * 0.5,
+                ],
+            },
+        ]
+    multiscales[0]["coordinateTransformations"] = [
+        {"scale": [1.0] * 3, "type": "scale"}
+    ]
+    omz.attrs["multiscales"] = multiscales
+
+    if not nii:
+        print("done.")
+        return
+
+    # Write NIfTI-Zarr header
+    # NOTE: we use nifti2 because dimensions typically do not fit in a short
+    # TODO: we do not write the json zattrs, but it should be added in
+    #       once the nifti-zarr package is released
+    shape = list(reversed(omz["0"].shape))
path: linc_convert/modalities/lsm/multi_slice.py
user: login: balbasty
body: Did you want to use the nifti-zarr utilities now or in a later refactor?
created_at: 2025-04-08T08:45:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
+    while any(x > 1 for x in omz[str(level)].shape[-3:]):
+        prev_array = omz[str(level)]
+        prev_shape = prev_array.shape[-3:]
+        level += 1
+
+        new_shape = list(map(lambda x: max(1, x // 2), prev_shape))
+        if all(x < chunk for x in new_shape):
+            break
+        print("Compute level", level, "with shape", new_shape)
+        omz.create_dataset(str(level), shape=new_shape, **opt)
+        new_array = omz[str(level)]
+
+        nz, ny, nx = prev_array.shape[-3:]
+        ncz = ceildiv(nz, max_load)
+        ncy = ceildiv(ny, max_load)
+        ncx = ceildiv(nx, max_load)
+
+        for cz in range(ncz):
+            for cy in range(ncy):
+                for cx in range(ncx):
+                    print(f"chunk ({cz}, {cy}, {cx}) / ({ncz}, {ncy}, {ncx})", end="\r")
+
+                    dat = prev_array[
+                        ...,
+                        cz * max_load : (cz + 1) * max_load,
+                        cy * max_load : (cy + 1) * max_load,
+                        cx * max_load : (cx + 1) * max_load,
+                    ]
+                    crop = [0 if x == 1 else x % 2 for x in dat.shape[-3:]]
+                    slicer = [slice(-1) if x else slice(None) for x in crop]
+                    dat = dat[(Ellipsis, *slicer)]
+                    pz, py, px = dat.shape[-3:]
+
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            min(pz, 2),
+                            max(py // 2, 1),
+                            min(py, 2),
+                            max(px // 2, 1),
+                            min(px, 2),
+                        ]
+                    )
+                    dat = dat.transpose([0, 2, 4, 1, 3, 5])
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            max(py // 2, 1),
+                            max(px // 2, 1),
+                            -1,
+                        ]
+                    )
+                    dat = np.median(dat, -1)
+
+                    new_array[
+                        ...,
+                        cz * max_load // 2 : (cz + 1) * max_load // 2,
+                        cy * max_load // 2 : (cy + 1) * max_load // 2,
+                        cx * max_load // 2 : (cx + 1) * max_load // 2,
+                    ] = dat
+
+    print("")
+    nblevel = level
+
+    # Write OME-Zarr multiscale metadata
+    print("Write metadata")
+    multiscales = [
+        {
+            "version": "0.4",
+            "axes": [
+                {"name": "z", "type": "space", "unit": "micrometer"},
+                {"name": "y", "type": "space", "unit": "micrometer"},
+                {"name": "x", "type": "space", "unit": "micrometer"},
+            ],
+            "datasets": [],
+            "type": "median window 2x2x2",
+            "name": "",
+        }
+    ]
+
+    voxel_size = list(map(float, reversed(voxel_size)))
+    factor = [1] * 3
+    for n in range(nblevel):
+        shape = omz[str(n)].shape[-3:]
+        multiscales[0]["datasets"].append({})
+        level = multiscales[0]["datasets"][-1]
+        level["path"] = str(n)
+
+        # We made sure that the downsampling level is exactly 2
+        # However, once a dimension has size 1, we stop downsampling.
+        if n > 0:
+            shape_prev = omz[str(n - 1)].shape[-3:]
+            if shape_prev[0] != shape[0]:
+                factor[0] *= 2
+            if shape_prev[1] != shape[1]:
+                factor[1] *= 2
+            if shape_prev[2] != shape[2]:
+                factor[2] *= 2
+
+        level["coordinateTransformations"] = [
+            {
+                "type": "scale",
+                "scale": [
+                    factor[0] * voxel_size[0],
+                    factor[1] * voxel_size[1],
+                    factor[2] * voxel_size[2],
+                ],
+            },
+            {
+                "type": "translation",
+                "translation": [
+                    (factor[0] - 1) * voxel_size[0] * 0.5,
+                    (factor[1] - 1) * voxel_size[1] * 0.5,
+                    (factor[2] - 1) * voxel_size[2] * 0.5,
+                ],
+            },
+        ]
+    multiscales[0]["coordinateTransformations"] = [
+        {"scale": [1.0] * 3, "type": "scale"}
+    ]
+    omz.attrs["multiscales"] = multiscales
+
+    if not nii:
+        print("done.")
+        return
+
+    # Write NIfTI-Zarr header
+    # NOTE: we use nifti2 because dimensions typically do not fit in a short
+    # TODO: we do not write the json zattrs, but it should be added in
+    #       once the nifti-zarr package is released
+    shape = list(reversed(omz["0"].shape))
+    shape = shape[:3] + [1] + shape[3:]  # insert time dimension
+    affine = orientation_to_affine(orientation, *voxel_size)
+    if center:
+        affine = center_affine(affine, shape[:3])
+    header = nib.Nifti2Header()
+    header.set_data_shape(shape)
+    header.set_data_dtype(omz["0"].dtype)
+    header.set_qform(affine)
+    header.set_sform(affine)
+    header.set_xyzt_units(nib.nifti1.unit_codes.code["micron"])
+    header.structarr["magic"] = b"nz2\0"
path: linc_convert/modalities/lsm/multi_slice.py
user: login: balbasty
body: I think we said we would not use a nifti-zarr specific magic string in the end (so `"ni2" instead of `"nz2"`). But can also be done in a later refactor.
created_at: 2025-04-08T08:46:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: balbasty
body: Keep it for later, but this should be based on the chunk/stride size, as discussed.
created_at: 2025-04-08T08:49:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: I am working on it in refactor.
created_at: 2025-04-08T14:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
+    while any(x > 1 for x in omz[str(level)].shape[-3:]):
+        prev_array = omz[str(level)]
+        prev_shape = prev_array.shape[-3:]
+        level += 1
+
+        new_shape = list(map(lambda x: max(1, x // 2), prev_shape))
+        if all(x < chunk for x in new_shape):
+            break
+        print("Compute level", level, "with shape", new_shape)
+        omz.create_dataset(str(level), shape=new_shape, **opt)
+        new_array = omz[str(level)]
+
+        nz, ny, nx = prev_array.shape[-3:]
+        ncz = ceildiv(nz, max_load)
+        ncy = ceildiv(ny, max_load)
+        ncx = ceildiv(nx, max_load)
+
+        for cz in range(ncz):
+            for cy in range(ncy):
+                for cx in range(ncx):
+                    print(f"chunk ({cz}, {cy}, {cx}) / ({ncz}, {ncy}, {ncx})", end="\r")
+
+                    dat = prev_array[
+                        ...,
+                        cz * max_load : (cz + 1) * max_load,
+                        cy * max_load : (cy + 1) * max_load,
+                        cx * max_load : (cx + 1) * max_load,
+                    ]
+                    crop = [0 if x == 1 else x % 2 for x in dat.shape[-3:]]
+                    slicer = [slice(-1) if x else slice(None) for x in crop]
+                    dat = dat[(Ellipsis, *slicer)]
+                    pz, py, px = dat.shape[-3:]
+
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            min(pz, 2),
+                            max(py // 2, 1),
+                            min(py, 2),
+                            max(px // 2, 1),
+                            min(px, 2),
+                        ]
+                    )
+                    dat = dat.transpose([0, 2, 4, 1, 3, 5])
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            max(py // 2, 1),
+                            max(px // 2, 1),
+                            -1,
+                        ]
+                    )
+                    dat = np.median(dat, -1)
+
+                    new_array[
+                        ...,
+                        cz * max_load // 2 : (cz + 1) * max_load // 2,
+                        cy * max_load // 2 : (cy + 1) * max_load // 2,
+                        cx * max_load // 2 : (cx + 1) * max_load // 2,
+                    ] = dat
+
+    print("")
+    nblevel = level
+
+    # Write OME-Zarr multiscale metadata
+    print("Write metadata")
+    multiscales = [
+        {
+            "version": "0.4",
+            "axes": [
+                {"name": "z", "type": "space", "unit": "micrometer"},
+                {"name": "y", "type": "space", "unit": "micrometer"},
+                {"name": "x", "type": "space", "unit": "micrometer"},
+            ],
+            "datasets": [],
+            "type": "median window 2x2x2",
+            "name": "",
+        }
+    ]
+
+    voxel_size = list(map(float, reversed(voxel_size)))
+    factor = [1] * 3
+    for n in range(nblevel):
+        shape = omz[str(n)].shape[-3:]
+        multiscales[0]["datasets"].append({})
+        level = multiscales[0]["datasets"][-1]
+        level["path"] = str(n)
+
+        # We made sure that the downsampling level is exactly 2
+        # However, once a dimension has size 1, we stop downsampling.
+        if n > 0:
+            shape_prev = omz[str(n - 1)].shape[-3:]
+            if shape_prev[0] != shape[0]:
+                factor[0] *= 2
+            if shape_prev[1] != shape[1]:
+                factor[1] *= 2
+            if shape_prev[2] != shape[2]:
+                factor[2] *= 2
+
+        level["coordinateTransformations"] = [
+            {
+                "type": "scale",
+                "scale": [
+                    factor[0] * voxel_size[0],
+                    factor[1] * voxel_size[1],
+                    factor[2] * voxel_size[2],
+                ],
+            },
+            {
+                "type": "translation",
+                "translation": [
+                    (factor[0] - 1) * voxel_size[0] * 0.5,
+                    (factor[1] - 1) * voxel_size[1] * 0.5,
+                    (factor[2] - 1) * voxel_size[2] * 0.5,
+                ],
+            },
+        ]
+    multiscales[0]["coordinateTransformations"] = [
+        {"scale": [1.0] * 3, "type": "scale"}
+    ]
+    omz.attrs["multiscales"] = multiscales
+
+    if not nii:
+        print("done.")
+        return
+
+    # Write NIfTI-Zarr header
+    # NOTE: we use nifti2 because dimensions typically do not fit in a short
+    # TODO: we do not write the json zattrs, but it should be added in
+    #       once the nifti-zarr package is released
+    shape = list(reversed(omz["0"].shape))
+    shape = shape[:3] + [1] + shape[3:]  # insert time dimension
+    affine = orientation_to_affine(orientation, *voxel_size)
+    if center:
+        affine = center_affine(affine, shape[:3])
+    header = nib.Nifti2Header()
+    header.set_data_shape(shape)
+    header.set_data_dtype(omz["0"].dtype)
+    header.set_qform(affine)
+    header.set_sform(affine)
+    header.set_xyzt_units(nib.nifti1.unit_codes.code["micron"])
+    header.structarr["magic"] = b"nz2\0"
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: Yes, I did not touch this part yet as I plan to change for all modality during refactor. I will mark this for later.
created_at: 2025-04-08T14:49:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: It is changed in refactoring. It will become one command line argument defaults to C
created_at: 2025-04-08T14:50:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chink shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
+        ystart = sum(shape[1] - overlap for shape in allshapes[chunkz][:chunky])
+        if chunky != 0:
+            ystart += overlap // 2
+        print(
+            f"Write plane "
+            f"( {zstart} :{zstart+len(pages)}, {ystart}:{ystart + dat.shape[1]})",
+            end="\r",
+        )
+        slicer = (
+            slice(zstart, zstart + len(pages)),
+            slice(ystart, ystart + dat.shape[1]),
+            slice(None),
+        )
+        array[slicer] = dat
+    print("")
+
+    # build pyramid using median windows
+    level = 0
+    while any(x > 1 for x in omz[str(level)].shape[-3:]):
+        prev_array = omz[str(level)]
+        prev_shape = prev_array.shape[-3:]
+        level += 1
+
+        new_shape = list(map(lambda x: max(1, x // 2), prev_shape))
+        if all(x < chunk for x in new_shape):
+            break
+        print("Compute level", level, "with shape", new_shape)
+        omz.create_dataset(str(level), shape=new_shape, **opt)
+        new_array = omz[str(level)]
+
+        nz, ny, nx = prev_array.shape[-3:]
+        ncz = ceildiv(nz, max_load)
+        ncy = ceildiv(ny, max_load)
+        ncx = ceildiv(nx, max_load)
+
+        for cz in range(ncz):
+            for cy in range(ncy):
+                for cx in range(ncx):
+                    print(f"chunk ({cz}, {cy}, {cx}) / ({ncz}, {ncy}, {ncx})", end="\r")
+
+                    dat = prev_array[
+                        ...,
+                        cz * max_load : (cz + 1) * max_load,
+                        cy * max_load : (cy + 1) * max_load,
+                        cx * max_load : (cx + 1) * max_load,
+                    ]
+                    crop = [0 if x == 1 else x % 2 for x in dat.shape[-3:]]
+                    slicer = [slice(-1) if x else slice(None) for x in crop]
+                    dat = dat[(Ellipsis, *slicer)]
+                    pz, py, px = dat.shape[-3:]
+
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            min(pz, 2),
+                            max(py // 2, 1),
+                            min(py, 2),
+                            max(px // 2, 1),
+                            min(px, 2),
+                        ]
+                    )
+                    dat = dat.transpose([0, 2, 4, 1, 3, 5])
+                    dat = dat.reshape(
+                        [
+                            max(pz // 2, 1),
+                            max(py // 2, 1),
+                            max(px // 2, 1),
+                            -1,
+                        ]
+                    )
+                    dat = np.median(dat, -1)
+
+                    new_array[
+                        ...,
+                        cz * max_load // 2 : (cz + 1) * max_load // 2,
+                        cy * max_load // 2 : (cy + 1) * max_load // 2,
+                        cx * max_load // 2 : (cx + 1) * max_load // 2,
+                    ] = dat
+
+    print("")
+    nblevel = level
+
+    # Write OME-Zarr multiscale metadata
+    print("Write metadata")
+    multiscales = [
+        {
+            "version": "0.4",
+            "axes": [
+                {"name": "z", "type": "space", "unit": "micrometer"},
+                {"name": "y", "type": "space", "unit": "micrometer"},
+                {"name": "x", "type": "space", "unit": "micrometer"},
+            ],
+            "datasets": [],
+            "type": "median window 2x2x2",
+            "name": "",
+        }
+    ]
+
+    voxel_size = list(map(float, reversed(voxel_size)))
+    factor = [1] * 3
+    for n in range(nblevel):
+        shape = omz[str(n)].shape[-3:]
+        multiscales[0]["datasets"].append({})
+        level = multiscales[0]["datasets"][-1]
+        level["path"] = str(n)
+
+        # We made sure that the downsampling level is exactly 2
+        # However, once a dimension has size 1, we stop downsampling.
+        if n > 0:
+            shape_prev = omz[str(n - 1)].shape[-3:]
+            if shape_prev[0] != shape[0]:
+                factor[0] *= 2
+            if shape_prev[1] != shape[1]:
+                factor[1] *= 2
+            if shape_prev[2] != shape[2]:
+                factor[2] *= 2
+
+        level["coordinateTransformations"] = [
+            {
+                "type": "scale",
+                "scale": [
+                    factor[0] * voxel_size[0],
+                    factor[1] * voxel_size[1],
+                    factor[2] * voxel_size[2],
+                ],
+            },
+            {
+                "type": "translation",
+                "translation": [
+                    (factor[0] - 1) * voxel_size[0] * 0.5,
+                    (factor[1] - 1) * voxel_size[1] * 0.5,
+                    (factor[2] - 1) * voxel_size[2] * 0.5,
+                ],
+            },
+        ]
+    multiscales[0]["coordinateTransformations"] = [
+        {"scale": [1.0] * 3, "type": "scale"}
+    ]
+    omz.attrs["multiscales"] = multiscales
+
+    if not nii:
+        print("done.")
+        return
+
+    # Write NIfTI-Zarr header
+    # NOTE: we use nifti2 because dimensions typically do not fit in a short
+    # TODO: we do not write the json zattrs, but it should be added in
+    #       once the nifti-zarr package is released
+    shape = list(reversed(omz["0"].shape))
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: in a later refactor
created_at: 2025-04-08T14:51:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,372 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: kinda weird. I remembered I have added docstring for overlap before. I will add it now. 
created_at: 2025-04-08T14:54:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: calvinchai
body: how about let the user specify all three letters? like "LAS" "RAS"
created_at: 2025-04-08T14:57:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: calvinchai
body: also I plan to move this argument to a shared dataclass for all pipelines. So maybe it should be more generalizable. we can also set this value on the fly in each pipeline.
created_at: 2025-04-08T14:59:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,282 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        max_load: int = 512,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/spool.py
user: login: balbasty
body: Yes for LSM it makes sense (the two letter thing was for 2D histo).

It'll also force the person running the converter to keep track of the z orientation of the slice (and we'll have to emphasize to please do not mess this up)
created_at: 2025-04-08T14:59:57Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: Just noting that the way we currently have the data organized on lincbrain.org won't work with this code if we have more that 1 dataset in the `micr` directory.   So we will need to move these `*_y*_z*_HR/` directories within their respective `sub-*` and `ses-*` directories on lincbrain.org.
created_at: 2025-04-18T15:03:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
        Voxel size along the X, Y and Z dimensions, in microns.
```
created_at: 2025-04-18T15:03:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: ```suggestion
    Convert a collection of spool files generated by the LSM pipeline into Zarr.
```
created_at: 2025-04-18T15:03:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with "
+                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
+            )
+        tiles_info_by_index[(tile.y, tile.z)] = tile
+
+    # Set default output path if not provided
+    if not out:
+        out = all_tiles_info[0].prefix + all_tiles_info[0].suffix
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # Determine unique Y and Z tile indices
+    z_tiles = set(tile.z for tile in all_tiles_info)
+    y_tiles = set(tile.y for tile in all_tiles_info)
+    min_y_tile, max_y_tile = min(y_tiles), max(y_tiles)
+    min_z_tile, max_z_tile = min(z_tiles), max(z_tiles)
+    num_y_tiles, num_z_tiles = len(y_tiles), len(z_tiles)
+
+    # Initialize dtype and shapes storage
+    dtypes = defaultdict(list)
+    expected_sx = 0
+    expected_sy = {}
+    expected_sz = {}
+    all_shapes = np.empty((num_y_tiles, num_z_tiles, 3), dtype=int)
+
+    # Collect shape and dtype info from all tiles
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        for y_tile in range(min_y_tile, max_y_tile + 1):
+            tile = tiles_info_by_index.get((y_tile, z_tile))
+            if tile is None:
+                warnings.warn(f"Missing tile {y_tile}, {z_tile}")
+                continue
+            reader = tile.reader
+            sz, sy, sx = reader.assembled_spool_shape
+            dt = reader.dtype
+            # Collect shapes and dtypes.
+            rel_y, rel_z = y_tile - min_y_tile, z_tile - min_z_tile
+            all_shapes[rel_y, rel_z] = sx, sy, sz
+            expected_sx = sx
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: I might be misunderstanding, but do we want to check that this value is the same for all tiles?  It looks like this value is being overwritten as the for loops iterate.
created_at: 2025-04-18T15:29:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with "
+                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
+            )
+        tiles_info_by_index[(tile.y, tile.z)] = tile
+
+    # Set default output path if not provided
+    if not out:
+        out = all_tiles_info[0].prefix + all_tiles_info[0].suffix
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # Determine unique Y and Z tile indices
+    z_tiles = set(tile.z for tile in all_tiles_info)
+    y_tiles = set(tile.y for tile in all_tiles_info)
+    min_y_tile, max_y_tile = min(y_tiles), max(y_tiles)
+    min_z_tile, max_z_tile = min(z_tiles), max(z_tiles)
+    num_y_tiles, num_z_tiles = len(y_tiles), len(z_tiles)
+
+    # Initialize dtype and shapes storage
+    dtypes = defaultdict(list)
+    expected_sx = 0
+    expected_sy = {}
+    expected_sz = {}
+    all_shapes = np.empty((num_y_tiles, num_z_tiles, 3), dtype=int)
+
+    # Collect shape and dtype info from all tiles
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        for y_tile in range(min_y_tile, max_y_tile + 1):
+            tile = tiles_info_by_index.get((y_tile, z_tile))
+            if tile is None:
+                warnings.warn(f"Missing tile {y_tile}, {z_tile}")
+                continue
+            reader = tile.reader
+            sz, sy, sx = reader.assembled_spool_shape
+            dt = reader.dtype
+            # Collect shapes and dtypes.
+            rel_y, rel_z = y_tile - min_y_tile, z_tile - min_z_tile
+            all_shapes[rel_y, rel_z] = sx, sy, sz
+            expected_sx = sx
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: Disregard.  Just noticed that below you take the last `sx` value and check it against all tiles.
created_at: 2025-04-18T15:38:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
        # Stop if seen enough levels or if the level's shape is smaller than the chunk size
```
created_at: 2025-04-19T20:42:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape)
+            ]
+            # Don't crop the axis not down-sampling
+            # cannot do if not no_pyramid_axis since it could be 0
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: Sorry, I don't quite understand the second sentence, but perhaps the below suggestion suffices?

```suggestion
            # Only crop the axes that are downsampled
```
created_at: 2025-04-19T20:42:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape)
+            ]
+            # Don't crop the axis not down-sampling
+            # cannot do if not no_pyramid_axis since it could be 0
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
+
+            if any(n == 0 for n in dat.shape):
+                # last strip had a single voxel, nothing to do
+                continue
+
+            patch_shape = dat.shape[-ndim:]
+
+            # Reshape into patches of shape 2x2x2
+            windowed_shape = [
+                x for n in patch_shape for x in (max(n // 2, 1), min(n, 2))
+            ]
+            if no_pyramid_axis is not None:
+                windowed_shape[2 * no_pyramid_axis] = patch_shape[no_pyramid_axis]
+                windowed_shape[2 * no_pyramid_axis + 1] = 1
+
+            dat = dat.reshape(batch + windowed_shape)
+            # -> last `ndim`` dimensions have shape 2x2x2
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: ```suggestion
            # -> last `ndim` dimensions have shape 2x2x2
```
created_at: 2025-04-19T20:42:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
```
created_at: 2025-04-19T21:00:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%252Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+from linc_convert import utils
+# internals
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
# internals
from linc_convert import utils
```
created_at: 2025-04-19T21:01:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape)
+            ]
+            # Don't crop the axis not down-sampling
+            # cannot do if not no_pyramid_axis since it could be 0
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: Sorry, could you describe the goal of this section?
created_at: 2025-04-20T03:57:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape)
+            ]
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: I might be mistaken but I think we need to slice the `fullshape` variable.

```suggestion
            crop = [
                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape[-ndim:])
            ]
```
created_at: 2025-04-20T03:59:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,166 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or level shape smaller than chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape)
+            ]
+            # Don't crop the axis not down-sampling
+            # cannot do if not no_pyramid_axis since it could be 0
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: calvinchai
body: let say the dimension of the previous level is 5x5x5, the dimension of the next level will be 2x2x2, it will be pooled from a 4x4x4 region, so we need to crop the dimension with odd length
created_at: 2025-04-20T14:12:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,165 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or if the level's shape is smaller than the chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape[-ndim:])
+            ]
+            # Only crop the axes that are downsampled
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
+
+            if any(n == 0 for n in dat.shape):
+                # last strip had a single voxel, nothing to do
+                continue
+
+            patch_shape = dat.shape[-ndim:]
+
+            # Reshape into patches of shape 2x2x2
+            windowed_shape = [
+                x for n in patch_shape for x in (max(n // 2, 1), min(n, 2))
+            ]
+            if no_pyramid_axis is not None:
+                windowed_shape[2 * no_pyramid_axis] = patch_shape[no_pyramid_axis]
+                windowed_shape[2 * no_pyramid_axis + 1] = 1
+
+            dat = dat.reshape(batch + windowed_shape)
+            # -> last `ndim` dimensions have shape 2x2x2
+            dat = dat.transpose(
+                list(range(len(batch)))
+                + list(range(len(batch), len(batch) + 2 * ndim, 2))
+                + list(range(len(batch) + 1, len(batch) + 2 * ndim, 2))
+            )
+            # -> flatten patches
+            smaller_shape = [max(n // 2, 1) for n in patch_shape]
+            if no_pyramid_axis is not None:
+                smaller_shape[no_pyramid_axis] = patch_shape[no_pyramid_axis]
+
+            dat = dat.reshape(batch + smaller_shape + [-1])
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: I might be misunderstanding, but I am not sure why this flattening is needed since the data is already reshaped with 2x2x2 patches in the previous section.
created_at: 2025-04-21T16:51:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,165 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or if the level's shape is smaller than the chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape[-ndim:])
+            ]
+            # Only crop the axes that are downsampled
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
+
+            if any(n == 0 for n in dat.shape):
+                # last strip had a single voxel, nothing to do
+                continue
+
+            patch_shape = dat.shape[-ndim:]
+
+            # Reshape into patches of shape 2x2x2
+            windowed_shape = [
+                x for n in patch_shape for x in (max(n // 2, 1), min(n, 2))
+            ]
+            if no_pyramid_axis is not None:
+                windowed_shape[2 * no_pyramid_axis] = patch_shape[no_pyramid_axis]
+                windowed_shape[2 * no_pyramid_axis + 1] = 1
+
+            dat = dat.reshape(batch + windowed_shape)
+            # -> last `ndim` dimensions have shape 2x2x2
+            dat = dat.transpose(
+                list(range(len(batch)))
+                + list(range(len(batch), len(batch) + 2 * ndim, 2))
+                + list(range(len(batch) + 1, len(batch) + 2 * ndim, 2))
+            )
+            # -> flatten patches
+            smaller_shape = [max(n // 2, 1) for n in patch_shape]
+            if no_pyramid_axis is not None:
+                smaller_shape[no_pyramid_axis] = patch_shape[no_pyramid_axis]
+
+            dat = dat.reshape(batch + smaller_shape + [-1])
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: calvinchai
body: lets say first dimension is 8*8*8, after the previous reshaping, it became (4*2)(4*2)(4*2) and transposing it we have 4*4*4*2*2*2, the flatten is to make it 4*4*4*8 and we can pool it on the last dimension. but we probably don't need to recalculate smaller_shape here. 
created_at: 2025-04-21T17:02:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,165 @@
+import itertools
+from typing import Literal
+
+import numpy as np
+import zarr
+
+from linc_convert.utils.math import ceildiv
+
+
+def generate_pyramid(
+    omz: zarr.Group,
+    levels: int = -1,
+    ndim: int = 3,
+    max_load: int = 512,
+    mode: Literal["mean", "median"] = "median",
+    no_pyramid_axis: int | None = None,
+) -> list[list[int]]:
+    """
+    Generate the levels of a pyramid in an existing Zarr.
+
+    Parameters
+    ----------
+    omz : zarr.Group
+        Zarr group object
+    levels : int
+        Number of additional levels to generate.
+        By default, stop when all dimensions are smaller than their
+        corresponding chunk size.
+    ndim : int
+        Number of spatial dimensions.
+    max_load : int
+        Maximum number of voxels to load along each dimension.
+    mode : {"mean", "median"}
+        Whether to use a mean or median moving window.
+    no_pyramid_axis : int | None
+        Axis that should not be downsampled. If None, downsample
+        across all three dimensions.
+    Returns
+    -------
+    shapes : list[list[int]]
+        Shapes of all levels, from finest to coarsest, including the
+        existing top level.
+    """
+    if max_load % 2:
+        max_load += 1
+
+    # Read properties from base level
+    shape = list(omz["0"].shape)
+    chunk_size = omz["0"].chunks
+    opt = {
+        "dimension_separator": omz["0"]._dimension_separator,
+        "order": omz["0"]._order,
+        "dtype": omz["0"]._dtype,
+        "fill_value": omz["0"]._fill_value,
+        "compressor": omz["0"]._compressor,
+        "chunks": omz["0"].chunks,
+    }
+
+    # Select windowing function
+    if mode == "median":
+        func = np.median
+    else:
+        assert mode == "mean"
+        func = np.mean
+
+    level = 0
+    batch, shape = shape[:-ndim], shape[-ndim:]
+    allshapes = [shape]
+
+    while True:
+        level += 1
+
+        # Compute downsampled shape
+        prev_shape, shape = shape, []
+        for i, length in enumerate(prev_shape):
+            if i == no_pyramid_axis:
+                shape.append(length)
+            else:
+                shape.append(max(1, length // 2))
+
+        # Stop if seen enough levels or if the level's shape is smaller than the chunk size
+        if levels == -1:
+            if all(x <= c for x, c in zip(shape, chunk_size[-ndim:])):
+                break
+        elif level > levels:
+            break
+
+        print("Compute level", level, "with shape", shape)
+
+        allshapes.append(shape)
+        omz.create_dataset(str(level), shape=batch + shape, **opt)
+
+        # Iterate across `max_load` chunks
+        # (note that these are unrelated to underlying zarr chunks)
+        grid_shape = [ceildiv(n, max_load) for n in prev_shape]
+        for chunk_index in itertools.product(*[range(x) for x in grid_shape]):
+            print(f"chunk {chunk_index} / {tuple(grid_shape)})", end="\r")
+
+            # Read one chunk of data at the previous resolution
+            slicer = [Ellipsis] + [
+                slice(i * max_load, min((i + 1) * max_load, n))
+                for i, n in zip(chunk_index, prev_shape)
+            ]
+            fullshape = omz[str(level - 1)].shape
+            dat = omz[str(level - 1)][tuple(slicer)]
+
+            # Discard the last voxel along odd dimensions
+            crop = [
+                0 if y == 1 else x % 2 for x, y in zip(dat.shape[-ndim:], fullshape[-ndim:])
+            ]
+            # Only crop the axes that are downsampled
+            if no_pyramid_axis is not None:
+                crop[no_pyramid_axis] = 0
+            slcr = [slice(-1) if x else slice(None) for x in crop]
+            dat = dat[tuple([Ellipsis, *slcr])]
+
+            if any(n == 0 for n in dat.shape):
+                # last strip had a single voxel, nothing to do
+                continue
+
+            patch_shape = dat.shape[-ndim:]
+
+            # Reshape into patches of shape 2x2x2
+            windowed_shape = [
+                x for n in patch_shape for x in (max(n // 2, 1), min(n, 2))
+            ]
+            if no_pyramid_axis is not None:
+                windowed_shape[2 * no_pyramid_axis] = patch_shape[no_pyramid_axis]
+                windowed_shape[2 * no_pyramid_axis + 1] = 1
+
+            dat = dat.reshape(batch + windowed_shape)
+            # -> last `ndim` dimensions have shape 2x2x2
+            dat = dat.transpose(
+                list(range(len(batch)))
+                + list(range(len(batch), len(batch) + 2 * ndim, 2))
+                + list(range(len(batch) + 1, len(batch) + 2 * ndim, 2))
+            )
+            # -> flatten patches
+            smaller_shape = [max(n // 2, 1) for n in patch_shape]
+            if no_pyramid_axis is not None:
+                smaller_shape[no_pyramid_axis] = patch_shape[no_pyramid_axis]
+
+            dat = dat.reshape(batch + smaller_shape + [-1])
path: linc_convert/utils/zarr/generate_pyramid.py
user: login: kabilar
body: Thanks for clarifying.  That makes sense.
created_at: 2025-04-21T17:17:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
    Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
```
created_at: 2025-04-21T17:26:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
    * the first letter corresponds to the horizontal dimension and
        indicates the anatomical meaning of the _right_ of the image,
```
created_at: 2025-04-21T17:26:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
    * the second letter corresponds to the vertical dimension and
        indicates the anatomical meaning of the _bottom_ of the image.
```
created_at: 2025-04-21T17:26:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
```
created_at: 2025-04-21T17:39:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
        Convert to nifti-zarr. True if `out` path ends in ".nii.zarr".
```
created_at: 2025-04-21T17:40:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
        Voxel size along the X, Y and Z dimensions, in microns.
```
created_at: 2025-04-21T17:41:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: ```suggestion
        Path to the root directory, which contains a collection of
        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
        collection of files named `*_plane{:03d}_c{:d}.tiff`.
```
created_at: 2025-04-21T17:43:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: In lincbrain.org dataset 10, it looks like the data are stored in a flat directory, without the `plane` or `c` values in the filename.
created_at: 2025-04-21T17:47:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: right, I've changed the doc and the match pattern to tiff files
created_at: 2025-04-21T19:08:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into Zarr.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimensions, in microns.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with "
+                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
+            )
+        tiles_info_by_index[(tile.y, tile.z)] = tile
+
+    # Set default output path if not provided
+    if not out:
+        out = all_tiles_info[0].prefix + all_tiles_info[0].suffix
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # Determine unique Y and Z tile indices
+    z_tiles = set(tile.z for tile in all_tiles_info)
+    y_tiles = set(tile.y for tile in all_tiles_info)
+    min_y_tile, max_y_tile = min(y_tiles), max(y_tiles)
+    min_z_tile, max_z_tile = min(z_tiles), max(z_tiles)
+    num_y_tiles, num_z_tiles = len(y_tiles), len(z_tiles)
+
+    # Initialize dtype and shapes storage
+    dtypes = defaultdict(list)
+    expected_sx = 0
+    expected_sy = {}
+    expected_sz = {}
+    all_shapes = np.empty((num_y_tiles, num_z_tiles, 3), dtype=int)
+
+    # Collect shape and dtype info from all tiles
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        for y_tile in range(min_y_tile, max_y_tile + 1):
+            tile = tiles_info_by_index.get((y_tile, z_tile))
+            if tile is None:
+                warnings.warn(f"Missing tile {y_tile}, {z_tile}")
+                continue
+            reader = tile.reader
+            sz, sy, sx = reader.assembled_spool_shape
+            dt = reader.dtype
+            # Collect shapes and dtypes.
+            rel_y, rel_z = y_tile - min_y_tile, z_tile - min_z_tile
+            all_shapes[rel_y, rel_z] = sx, sy, sz
+            expected_sx = sx
+            expected_sy[y_tile] = sy
+            expected_sz[z_tile] = sz
+            dtypes[dt].append((y_tile, z_tile))
+
+    # Ensure consistent dtype
+    if len(dtypes) != 1:
+        warnings.warn("Two or more dtypes in tiles:\n" + str(dict(dtypes)))
+    dtype = next(iter(dtypes))
+
+    # Ensure tiles's shapes are compatible
+    diff_sx = (all_shapes[:, :, 0] != expected_sx)
+    if diff_sx.any():
+        y_idxs, z_idxs = np.where(diff_sx)
+        raise ValueError(
+            f"Inconsistent x shapes at indices: {list(zip(y_idxs, z_idxs))}")
+    for y_tile in range(min_y_tile, max_y_tile + 1):
+        if y_tile not in expected_sy:
+            raise ValueError(f"Missing y tile {y_tile}")
+        diff_sy = (all_shapes[:, :, 1] != expected_sy[y_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sy)
+            raise ValueError(
+                f"Inconsistent y shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        if z_tile not in expected_sz:
+            raise ValueError(f"Missing z tile {z_tile}")
+        diff_sz = (all_shapes[:, :, 2] != expected_sz[z_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sz)
+            raise ValueError(
+                f"Inconsistent z shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+
+    # Calculate full dataset dimensions
+    full_shape_x = expected_sx
+    full_shape_y = sum(expected_sy.values()) - (num_y_tiles - 1) * overlap
+    full_shape_z = sum(expected_sz.values())
+    fullshape = [full_shape_z, full_shape_y, full_shape_x]
+
+    # Initialize Zarr group and array
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    # Populate Zarr array from tiles
+    for i, tile_info in enumerate(all_tiles_info):
+        rel_y, rel_z = tile_info.y - min_y_tile, tile_info.z - min_z_tile
+        dat = tile_info.reader.assemble_cropped()
+        if num_y_tiles != 1 and overlap != 0:
+            # if not first y tile, crop half overlapped rows at the beginning
+            if tile_info.y != min_y_tile:
+                dat = dat[:, overlap // 2:, :]
+            # if not last y tile, crop half overlapped rows at the end
+            # if overlap is odd, we need to crop an extra row
+            if tile_info.z != max_z_tile:
+                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: I might be misunderstanding the comments, but I think we need the following:

```suggestion
            # if not first y tile, crop half overlapped rows at the beginning
            if tile_info.y != min_y_tile:
                dat = dat[:, overlap // 2:, :]
            # if not last y tile, crop half overlapped rows at the end
            # if overlap is odd, we need to crop an extra column
            if tile_info.y != max_y_tile:
                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
```
created_at: 2025-04-22T20:52:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into Zarr.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimensions, in microns.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with "
+                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
+            )
+        tiles_info_by_index[(tile.y, tile.z)] = tile
+
+    # Set default output path if not provided
+    if not out:
+        out = all_tiles_info[0].prefix + all_tiles_info[0].suffix
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # Determine unique Y and Z tile indices
+    z_tiles = set(tile.z for tile in all_tiles_info)
+    y_tiles = set(tile.y for tile in all_tiles_info)
+    min_y_tile, max_y_tile = min(y_tiles), max(y_tiles)
+    min_z_tile, max_z_tile = min(z_tiles), max(z_tiles)
+    num_y_tiles, num_z_tiles = len(y_tiles), len(z_tiles)
+
+    # Initialize dtype and shapes storage
+    dtypes = defaultdict(list)
+    expected_sx = 0
+    expected_sy = {}
+    expected_sz = {}
+    all_shapes = np.empty((num_y_tiles, num_z_tiles, 3), dtype=int)
+
+    # Collect shape and dtype info from all tiles
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        for y_tile in range(min_y_tile, max_y_tile + 1):
+            tile = tiles_info_by_index.get((y_tile, z_tile))
+            if tile is None:
+                warnings.warn(f"Missing tile {y_tile}, {z_tile}")
+                continue
+            reader = tile.reader
+            sz, sy, sx = reader.assembled_spool_shape
+            dt = reader.dtype
+            # Collect shapes and dtypes.
+            rel_y, rel_z = y_tile - min_y_tile, z_tile - min_z_tile
+            all_shapes[rel_y, rel_z] = sx, sy, sz
+            expected_sx = sx
+            expected_sy[y_tile] = sy
+            expected_sz[z_tile] = sz
+            dtypes[dt].append((y_tile, z_tile))
+
+    # Ensure consistent dtype
+    if len(dtypes) != 1:
+        warnings.warn("Two or more dtypes in tiles:\n" + str(dict(dtypes)))
+    dtype = next(iter(dtypes))
+
+    # Ensure tiles's shapes are compatible
+    diff_sx = (all_shapes[:, :, 0] != expected_sx)
+    if diff_sx.any():
+        y_idxs, z_idxs = np.where(diff_sx)
+        raise ValueError(
+            f"Inconsistent x shapes at indices: {list(zip(y_idxs, z_idxs))}")
+    for y_tile in range(min_y_tile, max_y_tile + 1):
+        if y_tile not in expected_sy:
+            raise ValueError(f"Missing y tile {y_tile}")
+        diff_sy = (all_shapes[:, :, 1] != expected_sy[y_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sy)
+            raise ValueError(
+                f"Inconsistent y shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        if z_tile not in expected_sz:
+            raise ValueError(f"Missing z tile {z_tile}")
+        diff_sz = (all_shapes[:, :, 2] != expected_sz[z_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sz)
+            raise ValueError(
+                f"Inconsistent z shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+
+    # Calculate full dataset dimensions
+    full_shape_x = expected_sx
+    full_shape_y = sum(expected_sy.values()) - (num_y_tiles - 1) * overlap
+    full_shape_z = sum(expected_sz.values())
+    fullshape = [full_shape_z, full_shape_y, full_shape_x]
+
+    # Initialize Zarr group and array
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    # Populate Zarr array from tiles
+    for i, tile_info in enumerate(all_tiles_info):
+        rel_y, rel_z = tile_info.y - min_y_tile, tile_info.z - min_z_tile
+        dat = tile_info.reader.assemble_cropped()
+        if num_y_tiles != 1 and overlap != 0:
+            # if not first y tile, crop half overlapped rows at the beginning
+            if tile_info.y != min_y_tile:
+                dat = dat[:, overlap // 2:, :]
+            # if not last y tile, crop half overlapped rows at the end
+            # if overlap is odd, we need to crop an extra row
+            if tile_info.z != max_z_tile:
+                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
+        ystart = sum(expected_sy[min_y_tile + y_idx] - overlap for y_idx in
+                     range(rel_y))
+        zstart = sum(expected_sz[min_z_tile + z_idx] for z_idx in range(rel_z))
path: linc_convert/modalities/lsm/spool.py
user: login: kabilar
body: Why are these values summed?  I would assume that `ystart` and `zstart` should be `expected_sy[min_y_tile + rel_y] - overlap*rel_y` and `expected_sz[min_z_tile + rel_z]`, respectively.
created_at: 2025-04-22T21:14:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: Why is this `if` clause and `break` needed?
created_at: 2025-04-22T21:15:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chunk shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: Should we use the same logic as in the `spool.py` module?  Or is this meant to be different?
```suggestion
                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
```
created_at: 2025-04-22T21:22:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chunk shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
+
+        zstart = sum(shape[0][0] for shape in allshapes[:chunkz])
path: linc_convert/modalities/lsm/multi_slice.py
user: login: kabilar
body: I might be mistaken, but I think we need the following change:

```suggestion
        zstart = sum(shape[chunky][0] for shape in allshapes[:chunkz])
```
created_at: 2025-04-22T21:38:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: this for loop is used to find the correct corresponding all_chunks_info. The logic to index it was improved in spool.py but not ported to this script yet.
created_at: 2025-04-24T14:04:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,374 @@
+"""
+Convert a collection of tiff files generated by the LSM pipeline into a Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Fderivatives
+"""
+
+# stdlib
+import ast
+import os
+import re
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from tifffile import TiffFile
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.math import ceildiv
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+multi_slice = cyclopts.App(name="multi_slice", help_format="markdown")
+lsm.command(multi_slice)
+
+
+@multi_slice.default
+def convert(
+    inp: str,
+    *,
+    out: str,
+    zarr_config: ZarrConfig = None,
+    overlap: int = 30,
+    max_load: int = 512,
+    orientation: str = "coronal",
+    center: bool = True,
+    thickness: float | None = None,
+    voxel_size: list[float] = (1, 1, 1),
+    **kwargs
+) -> None:
+    """
+    Convert a collection of tiff files generated by the LSM pipeline into ZARR.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the jp2 image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the jp2 image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_z{:02d}_y{:02d}*`, each containing a
+        collection of files named `*_plane{:03d}_c{:d}.tiff`.
+    out
+        Path to the output Zarr directory [<INP>.ome.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    chunk
+        Output chunk size
+    compressor : {blosc, zlib, raw}
+        Compression method
+    compressor_opt
+        Compression options
+    max_load
+        Maximum input chunk size when building pyramid
+    nii
+        Convert to nifti-zarr. True if path ends in ".nii.zarr".
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimension, in micron.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    if max_load % 2:
+        max_load += 1
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)" r"_y(?P<y>[0-9]+)" r"_z(?P<z>[0-9]+)" r"(?P<suffix>\w*)$"
+    )
+
+    all_chunks_filenames = list(sorted(glob(os.path.join(inp, "*_y*_z*"))))
+    all_chunks_info = dict(filename=[], prefix=[], suffix=[], z=[], y=[])
+
+    # parse all directory names
+    for filename in all_chunks_filenames:
+        parsed = CHUNK_PATTERN.fullmatch(
+            os.path.splitext(os.path.basename(filename))[0]
+        )
+        all_chunks_info["filename"].append(filename)
+        all_chunks_info["prefix"].append(parsed.group("prefix"))
+        all_chunks_info["suffix"].append(parsed.group("suffix"))
+        all_chunks_info["z"].append(int(parsed.group("z")))
+        all_chunks_info["y"].append(int(parsed.group("y")))
+
+    # default output name
+    if not out:
+        out = all_chunks_info["prefix"][0] + all_chunks_info["suffix"][0]
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # parse all individual file names
+    nchunkz = max(all_chunks_info["z"])
+    nchunky = max(all_chunks_info["y"])
+    allshapes = [[(0, 0, 0) for _ in range(nchunky)] for _ in range(nchunkz)]
+
+    dtype = None
+    for zchunk in range(nchunkz):
+        for ychunk in range(nchunky):
+            for i in range(len(all_chunks_info["filename"])):
+                if (
+                    all_chunks_info["z"][i] == zchunk + 1
+                    and all_chunks_info["y"][i] == ychunk + 1
+                ):
+                    break
+            filename = all_chunks_info["filename"][i]
+            f = TiffFile(filename)
+            dtype = f.pages[0].dtype
+            allshapes[zchunk][ychunk] = (len(f.pages), *f.pages[0].shape)
+
+    # check that all chunk shapes are compatible
+    for zchunk in range(nchunkz):
+        if len(set(shape[1] for shape in allshapes[zchunk])) != 1:
+            raise ValueError("Incompatible Y shapes")
+    for ychunk in range(nchunky):
+        if len(set(shape[ychunk][0] for shape in allshapes)) != 1:
+            raise ValueError("Incompatible Z shapes")
+    if len(set(shape[2] for subshapes in allshapes for shape in subshapes)) != 1:
+        raise ValueError("Incompatible X shapes")
+
+    # compute full shape
+    fullshape = [0, 0, 0]
+    fullshape[0] = sum(shape[0][0] for shape in allshapes)
+    fullshape[1] = (
+        sum(shape[1] for shape in allshapes[0])
+        - (len(set(all_chunks_info["y"])) - 1) * overlap
+    )
+    fullshape[2] = allshapes[0][0][2]
+
+    # Prepare Zarr group
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    for i, filename in enumerate(all_chunks_info["filename"]):
+        chunkz = all_chunks_info["z"][i] - 1
+        chunky = all_chunks_info["y"][i] - 1
+        f = TiffFile(filename)
+
+        pages = f.pages
+        dat = f.asarray()
+        if len(set(all_chunks_info["y"])) != 1 and overlap!=0:
+            if chunky != 0:
+                dat = dat[:, overlap // 2 :, :]
+            if chunky != max(all_chunks_info["y"])-1:
+                dat = dat[:, :-overlap // 2 - (overlap - 2 * (overlap // 2)), :]
path: linc_convert/modalities/lsm/multi_slice.py
user: login: calvinchai
body: Yes. It should be the same as in spool.py. I will port it.
created_at: 2025-04-24T14:07:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,283 @@
+# stdlib
+import ast
+import os
+import re
+import warnings
+from collections import namedtuple, defaultdict
+from glob import glob
+
+# externals
+import cyclopts
+import nibabel as nib
+import numpy as np
+import zarr
+from niizarr import default_nifti_header, write_nifti_header
+from niizarr._nii2zarr import write_ome_metadata
+
+# internals
+from linc_convert import utils
+from linc_convert.modalities.lsm.cli import lsm
+from linc_convert.utils.orientation import center_affine, orientation_to_affine
+from linc_convert.utils.spool import SpoolSetInterpreter
+from linc_convert.utils.zarr.compressor import make_compressor
+from linc_convert.utils.zarr.generate_pyramid import generate_pyramid
+from linc_convert.utils.zarr.zarr_config import ZarrConfig
+
+spool = cyclopts.App(name="spool", help_format="markdown")
+lsm.command(spool)
+
+"""
+Convert a collection of spool .dat files generated by the LSM pipeline into either a NIfTI-Zarr or OME-Zarr.
+
+Example input files can be found at
+https://lincbrain.org/dandiset/000010/draft/files?location=sourcedata%2Frawdata%2Fmicr%2Fsample18_run10__y10_z01_HR&page=1
+"""
+
+
+@spool.default
+def convert(
+        inp: str,
+        *,
+        out: str,
+        zarr_config: ZarrConfig = None,
+        overlap: int = 30,
+        orientation: str = "coronal",
+        center: bool = True,
+        voxel_size: list[float] = (1, 1, 1),
+        **kwargs
+) -> None:
+    """
+    Convert a collection of spool files generated by the LSM pipeline into Zarr.
+
+    Orientation
+    -----------
+    The anatomical orientation of the slice is given in terms of RAS axes.
+
+    It is a combination of two letters from the set
+    `{"L", "R", "A", "P", "I", "S"}`, where
+
+    * the first letter corresponds to the horizontal dimension and
+        indicates the anatomical meaning of the _right_ of the image,
+    * the second letter corresponds to the vertical dimension and
+        indicates the anatomical meaning of the _bottom_ of the image.
+
+    We also provide the aliases
+
+    * `"coronal"` == `"LI"`
+    * `"axial"` == `"LP"`
+    * `"sagittal"` == `"PI"`
+
+    The orientation flag is only useful when converting to nifti-zarr.
+
+    Parameters
+    ----------
+    inp
+        Path to the root directory, which contains a collection of
+        subfolders named `*_y{:02d}_z{:02d}*`, each containing a
+        collection of files named `*spool.dat`.
+    out
+        Path to the output Zarr directory [<INP>.{ome|nii}.zarr]
+    overlap
+        Number of pixels between slices that are overlapped
+    orientation
+        Orientation of the slice
+    center
+        Set RAS[0, 0, 0] at FOV center
+    voxel_size
+        Voxel size along the X, Y and Z dimensions, in microns.
+    """
+    zarr_config = utils.zarr.zarr_config.update(zarr_config, **kwargs)
+    chunk: int = zarr_config.chunk[0]
+    compressor: str = zarr_config.compressor
+    compressor_opt: str = zarr_config.compressor_opt
+    nii: bool = zarr_config.nii
+
+    if isinstance(compressor_opt, str):
+        compressor_opt = ast.literal_eval(compressor_opt)
+
+    CHUNK_PATTERN = re.compile(
+        r"^(?P<prefix>\w*)"
+        r"_run(?P<run>[0-9]+)"
+        r"_?"
+        r"_y(?P<y>[0-9]+)"
+        r"_z(?P<z>[0-9]+)"
+        r"(?P<suffix>\w*)$"
+    )
+
+    all_tiles_folders_names = sorted(glob(os.path.join(inp, "*_y*_z*_HR/")))
+    if not all_tiles_folders_names:
+        raise ValueError("No tile folder found")
+
+    all_tiles_info = []
+    tiles_info_by_index = {}
+    TileInfo = namedtuple(
+        "TileInfo",
+        ["prefix", "run", "y", "z", "suffix", "filename", "reader"]
+    )
+    for tile_folder_name in all_tiles_folders_names:
+        tile_folder_name = tile_folder_name.rstrip("/")
+        parsed = CHUNK_PATTERN.fullmatch(os.path.basename(tile_folder_name))
+        tile = TileInfo(
+            parsed.group("prefix"),
+            int(parsed.group("run")),
+            int(parsed.group("y")),
+            int(parsed.group("z")),
+            parsed.group("suffix"),
+            tile_folder_name,
+            SpoolSetInterpreter(tile_folder_name, tile_folder_name+"_info.mat")
+        )
+        all_tiles_info.append(tile)
+        # Check for duplicate tiles
+        if (tile.y, tile.z) in tiles_info_by_index:
+            raise ValueError(
+                f"Duplicate tile, file {tile.filename} conflicts with "
+                f"{tiles_info_by_index[(tile.y, tile.z)].filename}"
+            )
+        tiles_info_by_index[(tile.y, tile.z)] = tile
+
+    # Set default output path if not provided
+    if not out:
+        out = all_tiles_info[0].prefix + all_tiles_info[0].suffix
+        out += ".nii.zarr" if nii else ".ome.zarr"
+    nii = nii or out.endswith(".nii.zarr")
+
+    # Determine unique Y and Z tile indices
+    z_tiles = set(tile.z for tile in all_tiles_info)
+    y_tiles = set(tile.y for tile in all_tiles_info)
+    min_y_tile, max_y_tile = min(y_tiles), max(y_tiles)
+    min_z_tile, max_z_tile = min(z_tiles), max(z_tiles)
+    num_y_tiles, num_z_tiles = len(y_tiles), len(z_tiles)
+
+    # Initialize dtype and shapes storage
+    dtypes = defaultdict(list)
+    expected_sx = 0
+    expected_sy = {}
+    expected_sz = {}
+    all_shapes = np.empty((num_y_tiles, num_z_tiles, 3), dtype=int)
+
+    # Collect shape and dtype info from all tiles
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        for y_tile in range(min_y_tile, max_y_tile + 1):
+            tile = tiles_info_by_index.get((y_tile, z_tile))
+            if tile is None:
+                warnings.warn(f"Missing tile {y_tile}, {z_tile}")
+                continue
+            reader = tile.reader
+            sz, sy, sx = reader.assembled_spool_shape
+            dt = reader.dtype
+            # Collect shapes and dtypes.
+            rel_y, rel_z = y_tile - min_y_tile, z_tile - min_z_tile
+            all_shapes[rel_y, rel_z] = sx, sy, sz
+            expected_sx = sx
+            expected_sy[y_tile] = sy
+            expected_sz[z_tile] = sz
+            dtypes[dt].append((y_tile, z_tile))
+
+    # Ensure consistent dtype
+    if len(dtypes) != 1:
+        warnings.warn("Two or more dtypes in tiles:\n" + str(dict(dtypes)))
+    dtype = next(iter(dtypes))
+
+    # Ensure tiles's shapes are compatible
+    diff_sx = (all_shapes[:, :, 0] != expected_sx)
+    if diff_sx.any():
+        y_idxs, z_idxs = np.where(diff_sx)
+        raise ValueError(
+            f"Inconsistent x shapes at indices: {list(zip(y_idxs, z_idxs))}")
+    for y_tile in range(min_y_tile, max_y_tile + 1):
+        if y_tile not in expected_sy:
+            raise ValueError(f"Missing y tile {y_tile}")
+        diff_sy = (all_shapes[:, :, 1] != expected_sy[y_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sy)
+            raise ValueError(
+                f"Inconsistent y shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+    for z_tile in range(min_z_tile, max_z_tile + 1):
+        if z_tile not in expected_sz:
+            raise ValueError(f"Missing z tile {z_tile}")
+        diff_sz = (all_shapes[:, :, 2] != expected_sz[z_tile])
+        if diff_sy.any():
+            y_idxs, z_idxs = np.where(diff_sz)
+            raise ValueError(
+                f"Inconsistent z shapes at tiles: {list(zip(y_idxs, z_idxs))}")
+
+    # Calculate full dataset dimensions
+    full_shape_x = expected_sx
+    full_shape_y = sum(expected_sy.values()) - (num_y_tiles - 1) * overlap
+    full_shape_z = sum(expected_sz.values())
+    fullshape = [full_shape_z, full_shape_y, full_shape_x]
+
+    # Initialize Zarr group and array
+    omz = zarr.storage.DirectoryStore(out)
+    omz = zarr.group(store=omz, overwrite=True)
+    print(out)
+    # Prepare chunking options
+    opt = {
+        "chunks": [chunk] * 3,
+        "dimension_separator": r"/",
+        "order": "F",
+        "dtype": np.dtype(dtype).str,
+        "fill_value": None,
+        "compressor": make_compressor(compressor, **compressor_opt),
+    }
+    # write first level
+    omz.create_dataset("0", shape=fullshape, **opt)
+    array = omz["0"]
+    print("Write level 0 with shape", fullshape)
+
+    # Populate Zarr array from tiles
+    for i, tile_info in enumerate(all_tiles_info):
+        rel_y, rel_z = tile_info.y - min_y_tile, tile_info.z - min_z_tile
+        dat = tile_info.reader.assemble_cropped()
+        if num_y_tiles != 1 and overlap != 0:
+            # if not first y tile, crop half overlapped rows at the beginning
+            if tile_info.y != min_y_tile:
+                dat = dat[:, overlap // 2:, :]
+            # if not last y tile, crop half overlapped rows at the end
+            # if overlap is odd, we need to crop an extra row
+            if tile_info.z != max_z_tile:
+                dat = dat[:, :-overlap // 2 - (overlap % 2), :]
+        ystart = sum(expected_sy[min_y_tile + y_idx] - overlap for y_idx in
+                     range(rel_y))
+        zstart = sum(expected_sz[min_z_tile + z_idx] for z_idx in range(rel_z))
path: linc_convert/modalities/lsm/spool.py
user: login: calvinchai
body: expected_sy and expected_sz are the size of each tile, not the start of each tile.
created_at: 2025-04-24T14:19:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: calvinchai
created_at: 2025-04-01T18:26:52Z
body: note: until this point this works with zarr-python 2. I will then add sharding support and bump zarr version, it will then be a breaking change
author_association: CONTRIBUTOR
user: login: calvinchai
created_at: 2025-04-01T18:55:26Z
body: Since we are bumping zarr-python version in linc-convert from 2 to 3, and this will be a breaking changes for all modalities. I will first not merge this branch until we migrated  all related code. 
author_association: CONTRIBUTOR
user: login: kabilar
created_at: 2025-04-02T04:51:19Z
body: > note: until this point this works with zarr-python 2. I will then add sharding support and bump zarr version, it will then be a breaking change

> Since we are bumping zarr-python version in linc-convert from 2 to 3, and this will be a breaking changes for all modalities. I will first not merge this branch until we migrated all related code.

Thanks @calvinchai.  Would it make sense to submit these changes as separate pull requests that have smaller scopes so that it is easier and faster to review?  For example, the updates to the LSM module to read from the spool files and export with Zarr v2 would be the first pull request.

author_association: MEMBER
user: login: calvinchai
created_at: 2025-04-02T14:15:04Z
body: Yeah I agree with that. Let's do so.
author_association: CONTRIBUTOR
user: login: kabilar
created_at: 2025-04-02T15:05:00Z
body: Thanks @calvinchai.  I am a bit out of the loop.  Could you please update the first comment in this pull request to describe the changes proposed here?  For example, what's the high-level difference between `mosaic.py` and `multi_slice.py`?
author_association: MEMBER
user: login: calvinchai
created_at: 2025-04-02T15:40:56Z
body: > Thanks @calvinchai. I am a bit out of the loop. Could you please update the first comment in this pull request to describe the changes proposed here? For example, what's the high-level difference between `mosaic.py` and `multi_slice.py`?

`mosaic` was the one implemented by Yael and was the earliest one in lsm modalities. I believe it stiches tiff files on both y and z directions. `multi_slice` was written by me last November that combines tiff files only on y direction. Its logic and way to discover files is a little bit different from mosaic. 
author_association: CONTRIBUTOR
user: login: kabilar
created_at: 2025-04-02T17:34:35Z
body: If you have time, perhaps it would be good to add tests alongside these updates, while the details of the modules are fresh on the mind.
author_association: MEMBER
user: login: kabilar
created_at: 2025-04-02T17:35:00Z
body: > If you have time, perhaps it would be good to add tests alongside these updates, while the details of the modules are fresh on the mind.

Or we can wait until after the refactor.
author_association: MEMBER
user: login: calvinchai
created_at: 2025-04-02T17:58:06Z
body: > > If you have time, perhaps it would be good to add tests alongside these updates, while the details of the modules are fresh on the mind.
> 
> Or we can wait until after the refactor.

yes we need to wait until the refactor. because zarr3 will have completely different output content
author_association: CONTRIBUTOR
user: login: calvinchai
created_at: 2025-04-04T13:52:12Z
body: Hi @kabilar, I've pushed changes to fix the issues. Could you please take another look?
author_association: CONTRIBUTOR
user: login: kabilar
created_at: 2025-04-04T14:16:05Z
body: > Hi @kabilar, I've pushed changes to fix the issues. Could you please take another look?

Thank you.  Will take a look later today.
author_association: MEMBER
user: login: calvinchai
created_at: 2025-04-05T01:17:47Z
body: Thanks! @kabilar 
author_association: CONTRIBUTOR
user: login: calvinchai
created_at: 2025-04-17T14:04:47Z
body: @kabilar @balbasty Hey, could you please take a look at this PR and merge it after?
author_association: CONTRIBUTOR
user: login: kabilar
created_at: 2025-04-17T16:12:54Z
body: > @kabilar @balbasty Hey, could you please take a look at this PR and merge it after?

Thanks.  Will aim to get this merged by tomorrow.
author_association: MEMBER
user: login: kabilar
created_at: 2025-04-17T16:13:26Z
body: Looks like there is a conflict, can you please rebase from upstream?
author_association: MEMBER
user: login: calvinchai
created_at: 2025-04-17T20:06:46Z
body: > Looks like there is a conflict, can you please rebase from upstream?

done
author_association: CONTRIBUTOR
user: login: calvinchai
created_at: 2025-04-24T14:09:41Z
body: > Thanks @calvinchai. Really great work. Just a few remaining questions above.

Thank you! The suggestions are really helpful. I committed the most changes. For the rest, they will be ported from `spool.py` later or will be fixed in next PR, as I already made these changes locally. 
author_association: CONTRIBUTOR
user: login: calvinchai
created_at: 2025-04-24T14:29:06Z
body: I see now the PR says merging is blocked and change is requested. But I see no existing code change but only comments. Any idea? 
author_association: CONTRIBUTOR
user: login: kabilar
created_at: 2025-04-24T14:36:56Z
body: I think it was blocked because my last review requested changes.  Just approved.  You should be able to merge now.  Thanks. 
author_association: MEMBER
user: login: balbasty
created_at: 2025-04-25T12:37:14Z
body: Thanks @calvinchai and @kabilar  for getting this done! Great step forward!!
author_association: COLLABORATOR