neu-vol

Chunked I/O, conversion, and multiscale generation for large 3D EM volumes (images, probabilities/affinities, segmentations), orchestrated with dask — locally on a workstation or across a SLURM cluster.

Dask + SLURM orchestration background: blockrun/docs/dask-slurm.md

Environment

One conda environment covers this repo, its substrate (blockrun) and its consumers, each installed editable. Runtime deps come from conda-forge, so --no-deps keeps pip from re-resolving them.

conda activate neu-env
pip install --no-deps -e ../blockrun -e .
python -m pytest -q

blockrun must be a sibling directory — the layering depends on it. The neu-suite README describes how to build the shared environment.

The environment must live somewhere the cluster’s workers can also read: an env on a node’s local disk is invisible to them, and the failure looks like a missing import rather than a path problem.

Status

Working first vertical slice: image stack → multiscale OME-NGFF 0.5 zarr v3.

from neu_vol import ingest_image_stack

ingest_image_stack(
    "/path/to/slices/*.tif",     # glob, directory, or a single multipage TIFF
    "/path/to/out.zarr",
    voxel_size=(8, 8, 8), units="nm",
    profile="local",             # "local" | "ceph" (sharded) | ...
    kind="image",                # "image"/"probability" (mean) | "segmentation" (mode)
    # client=start_dask(...) to run blocks across a cluster
)

convert() does the same from any source backend (zarr v3, precomputed, DVID, webknossos, HDF5, image stack) to zarr v3 or neuroglancer-precomputed, single- or multi-channel. It reads voxel_size/offset/units from the source when present (OME-NGFF groups, precomputed info, a DVID instance, an HDF5 file that records its own frame); explicit args override. Segmentations default to compressed_segmentation encoding on precomputed.

The source format is detected, so --src-format is only for overriding it. Volumes are recognised by their metadata document (info, zarr.json, .zarray/.zgroup) and DVID by its URL scheme; an HDF5 file and a stack of 2D slices are recognised by name, since neither has a marker object — an .h5/.hdf5 file, a glob, a multipage TIFF, or a directory that actually contains .tif/.png slices. A marker always wins, so a stray image beside an info changes nothing.

from neu_vol import convert
convert("in.zarr", "out.precomputed",           # voxel_size read from in.zarr's OME metadata
        profile="s3-neuroglancer", kind="segmentation")   # mode pyramid, compressed_segmentation

crop_start/crop_stop copy one box instead of the whole volume, clipped to the source extent. The output’s physical offset shifts by the crop origin, so it stays in the source’s coordinate frame and overlays the original in a viewer; its pyramid is then built from the cropped level 0, never from slices of the source’s coarse levels.

convert("in.precomputed", "crop.precomputed", kind="segmentation",
        crop_start=(5632, 4480, 6784), crop_stop=(5760, 4736, 7040))   # zyx voxels

A webknossos layer is a source too, addressed as wk://DATASET/LAYER (a dataset name or id). The server and its API token come from ~/.config/webknossos/credentials.toml (url = "...", token = "...", optionally organization = "..."; keep it chmod 600), or from $WK_TOKEN/$WK_URL — never from the URL, a spec or provenance.json. Only the standard library is used, so nothing extra is installed. Reads fetch boxes over HTTP and are written straight to zarr or precomputed, local or on an object store, so a download and a conversion are one step:

neu-vol wk-ls                                    # your organization's datasets and layers
neu-vol wk-ls specimen --details                 # + description, uploader, boxes, views
neu-vol info wk://my_dataset/segmentation        # mags, and every mapping the layer has
neu-vol convert --src wk://my_dataset/segmentation --dst /path/to/sv.zarr --format zarr
neu-vol convert --src wk://my_dataset/segmentation --dst /path/to/view70.precomputed \
    --wk-mapping agglomerate_view_70

A segmentation layer stores supervoxels; neurons are an agglomerate mapping applied on top, and the viewer applies the layer’s default one. Without --wk-mapping the ids read are supervoxels, which is what the webknossos package’s own download() returns too; with it, the server applies that mapping on every read. convert warns when a layer has a default mapping that is not being applied, and provenance.json records which.

convert crops a layer to its bounding box. webknossos indexes a layer from the dataset’s voxel 0, and everything outside the box reads as zeros, so a full-extent copy plans (and steps through) every empty block of that margin. The output keeps the layer’s frame, with its offset shifted to the box’s corner, so it overlays the layer and any other copy of it. --wk-full-extent keeps the origin-anchored extent; --crop-bbox overrides both.

Keep --workers modest (~8). A webknossos server’s throughput is shared among your requests, so more workers do not go faster, and an overloaded server returns buckets it could not load in time as zeros, without an error. Reads are therefore capped at 128³ per request, and a response slower than 30 s is retried rather than trusted.

Many views of one supervoxel volume. Rather than converting every view, fetch each view’s table (table[supervoxel] = agglomerate, a few MB, about half a minute) and read the supervoxels through it:

neu-vol convert --src wk://my_dataset/segmentation --dst /path/to/sv.precomputed
neu-vol wk-mappings wk://my_dataset/segmentation --out /path/to/views \
    --volume /path/to/sv.precomputed                 # every view; --mapping NAME for one

This writes <view>.npy (the table), mappings.json (what was fetched, per-view agglomerate counts) and a descriptor <view>.json per view. A descriptor reads the volume through its table on the fly wherever a volume is accepted — neu-vol info, read_piece and so neu-eval compare --segmentation, and neu-vol convert --src, which turns a chosen view into a real volume. Level 0 of a view is exact. Coarser levels apply the table to the source’s downsampled supervoxels, so they are approximate (good for looking, not for measuring); a converted view rebuilds its pyramid from level 0 and is exact throughout.

extract_roi() is the same crop with the opposite out-of-bounds policy: start may be negative and stop may pass the extent, and the margin is filled with pad_value instead of trimmed. It delegates to convert().

mask_boxes is the complement — copy everything except those boxes, writing mask_value inside them. It masks on the read side, so every pyramid level inherits the hole; a box that misses the volume raises rather than quietly copying everything. Note that an all-fill block is elided rather than written, so a mask cannot erase a region the destination already holds from an earlier run.

On the command line, neu-vol convert --crop-bbox z0,y0,x0,z1,y1,x1 and --mask-bbox (repeatable) do this, with --bbox-scale N and --bbox-order xyz applying to every box the command takes, and neu-vol copy is the same command with the source’s own parameters as the defaults — format, chunking, voxel size and image/segmentation type all read from the source, with anything it does not record an error rather than a guess. That last part is why it exists: convert --kind defaults to image, so copying a segmentation and forgetting the flag averages label ids into ids that were never in the data, silently, while the source’s info said segmentation all along.

A source’s info may declare scales it never wrote, and level 0 is then a scale that does not exist: it opens, reports the extent the metadata claims, and reads as the fill value at every block — a structurally perfect output volume of zeros, with nothing raised anywhere. A registered neuropil mask declaring five scales and storing one is not hypothetical. So level 0 resolves to the finest scale that actually stores data, --src-level N names one explicitly, and a scale storing nothing while another one does is refused rather than read. On an ordinary volume the probe is a single existence check.

--dtype casts on the way out, and --kind segmentation usually needs it: the precomputed segmentation encoding is compressed_segmentation, which accepts uint32 and uint64 only, so a uint8 mask must be widened or it cannot be written as a segmentation at all. Widening interacts with chunk size — see Cluster config.

create_volume() + write_subvolume() are the other shape of the problem: several small pieces that belong at known positions inside one frame, rather than one source converted wholesale. Create the (empty) frame — optionally copying a reference volume’s geometry exactly — then place each piece into one level of it.

from neu_vol import create_volume, write_subvolume, write_subvolumes

create_volume("annotations.precomputed", like="s3://.../image.zarr",  # same frame, so
              dtype="uint64", kind="segmentation")                    # a voxel index
write_subvolume("annotations.precomputed", "piece.h5", (1024, 4096, 4096))  # matches
write_subvolumes("annotations.precomputed", glob("pieces/*.h5"))  # offsets from the files

An offset may be omitted when the source records one — an HDF5 file’s voxel_offset, looked for in the dataset’s attributes, the root group’s attributes, and a top-level dataset of that name. Any backend may supply one by implementing stored_offset. The axis order is likewise read from the file when it states one (an axes attribute, via stored_axes), falling back to zyx; offset_order= overrides both. Check it if the value came from a precomputed-flavoured writer that records no axes: that field name means xyz there, and everything here is zyx.

pack_hdf5() produces such a file — the inverse of write_subvolume. It packs an image stack (or any readable source) into one HDF5 dataset carrying voxel_offset, voxel_size, offset, units and axes, so the piece can be placed later with no arguments:

from neu_vol import pack_hdf5
pack_hdf5("slices/", "piece.h5", voxel_size=(40, 8, 8), voxel_offset=(24, 128, 256))

The dataset defaults to /data, which is what the reader assumes when it is not told — though a reader given only a path now resolves the name instead, and only falls back on the default when nothing else says. An existing file is added to if its recorded frame matches — several pieces of one volume in one file, each with its own voxel_offset — and refused if it does not. neu-vol info <file.h5> lists what such a file ended up holding.

read_piece() is the general read: a box out of any source, as a neu_lib.Piece that carries the frame, the kind and a name derived from the source. It is what the neu-glance viewer constructors call, so a notebook and a viewer see the same thing:

from neu_vol import read_piece
gt = read_piece("gt.h5:/vol_03700", "segmentation")   # frame from the file's attributes
em = read_piece("s3://my-bucket/em", crop=gt)         # the SAME physical box
gt.bbox, gt.bounds_nm                                 # where it is, in voxels and in nm

crop= takes a voxel box, another Piece (meaning the same physical box as that), or {"nm": (lo, hi)} — nanometres being the only space that transfers between frames, since two levels have different voxel sizes and a crop and its parent different origins. A single read is capped at 4 GiB, because a whole production level 0 is terabytes and a read that size does not fail, it hangs; the error names a coarser level that would fit.

dtype= casts after the read, so a piece arrives in the dtype it will be used in — crops exported by different tools come as uint8/16/32/64, and a consumer that has to handle all four is what this avoids. A narrowing cast is warned about rather than refused: widening among unsigned ints loses nothing, while narrowing wraps a label id into another plausible label id, which nothing downstream can detect.

write_piece() is the inverse, and the door for an array already in memory — where pack_hdf5 reads from a location. Read a crop, transform it, write the result:

from neu_vol import read_piece, write_piece
piece = read_piece("gt_v1_eval.h5:/vol_03700", "segmentation")
write_piece(piece.apply(clean), "gt_v1_eval_cleaned.h5")     # -> /vol_03700, same frame

Four of pack_hdf5’s arguments are absent because the piece answers them: voxel_size is the frame’s, voxel_offset is piece.origin_voxel (which raises rather than rounding a fractional origin), axes is always zyx and units always nm. The dataset name comes from the piece’s own name, so a whole bag of crops round-trips through a cleaning pass with no arguments at all, accumulating into one output file. kind travels too — a cleaned segmentation reads back as one. Both writers go through the same layout code in ops/pack, so write_subvolume places either file the same way.

open_hdf5() is the read side — one dataset of a file, ready to read regions from, with no spec to write out:

from neu_vol import open_hdf5
be = open_hdf5("piece.h5")                     # one dataset: found
be = open_hdf5("gt_v1_eval.h5", "/z07901")     # a container: name it
be.read_region((slice(0, 64), slice(0, 64), slice(0, 64)))

The name is required when the file holds several arrays, since choosing one of thirteen crops on your behalf would be wrong twelve times out of thirteen; the error lists them. open_backend stays spec-only on purpose — it is the primitive a per-block dask task reopens on a worker, so its reader is named rather than inferred. To find out what something is, use describe.

write_subvolumes plans every source — offsets, bounds, dtype — before writing any of them, so a mistyped offset in the last file is caught while the volume is still untouched. Pieces that overlap each other are reported, not refused.

Either format: format="zarr" or "precomputed", defaulting to the reference’s own — “like this volume” includes what kind of volume it is. Creating a level costs one zarr.json (an unwritten zarr array reads back as the fill value), and an empty precomputed volume is a single info listing every scale — so the frame is a few hundred bytes either way. The writes are single-scale by design: how a patch should look when coarsened is a separate decision (averaging label ids invents ids), so run downsample afterwards if the result needs a pyramid.

What each pyramid level is

read_scales(src) returns a neu_lib.ScaleInfo per level, finest first, read from the source’s own metadata — precomputed’s info or an OME group’s zarr.json:

from neu_vol import read_scales, scale_spec, describe_scales

levels = read_scales("s3://bucket/seg.precomputed")
levels[2].voxel_size          # (32.0, 32.0, 32.0) — this level's OWN resolution
levels[2].shape               # voxels, zyx
levels[2].frame.to_nm([0, 0, 0])   # where its voxel (0,0,0) sits, offset included
print(describe_scales(src))   # the pyramid as a table

Never derive a factor from 2 ** level. Real pyramids are anisotropic — halving x and y while leaving z alone is ordinary — so factor_from computes it from the real voxel sizes. Each level also carries its origin, from precomputed’s voxel_offset (that level’s voxels, so the nm origin is the product) or OME’s translation (already physical). A cropped volume has a non-zero one, and reconstructing a transform from just the voxel size silently places its data as though it started at nm zero.

scale_spec(src, n) builds the read spec for one level. Always use it rather than writing a spec by hand: the key is scale_index, an unrecognised key is silently ignored, and {"scale": 2} therefore opens at full resolution while reporting the scale-0 shape — so coordinates meant for scale 2 read the wrong place and come back empty rather than raising.

Implemented: TensorStore zarr v3 (sharded/unsharded) and precomputed (canonical-axis view + multiscale info), image-stack / HDF5 / crop-view sources, type-aware pyramids, storage profiles, OME-NGFF 0.5 metadata, per-level scale metadata, and the ingest / convert / extract_roi / create_volume / write_subvolume ops. Outputs verified via ngff-zarr’s reader (zarr) and TensorStore round-trip (precomputed); the distributed path is exercised over a real LocalCluster.

Reading a body’s mesh and skeleton

from neu_vol import read_body_mesh, read_body_skeleton

verts_xyz_nm, faces, lod = read_body_mesh(volume, body_id)        # coarsest LOD by default
verts_xyz_nm, edges, radii_nm = read_body_skeleton(volume, body_id)

Each returns None for a body with none. Multi-LOD Draco and legacy meshes and precomputed skeletons are read, sharded or not, and vertices come back in nm through each subresource’s own transform. neu-morpho writes the meshes and neu-skel the skeletons.

S3 / object stores

Destinations can be local paths, s3://bucket/prefix URLs, or kvstore dicts (for region/endpoint control) — TensorStore handles the transport. Credentials come from the environment (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) or ~/.aws.

convert("seg.zarr", "s3://my-bucket/seg/sample3.precomputed",
        profile="s3-neuroglancer", kind="segmentation", resume=True)

On SLURM the workers need credentials too. Slurm’s default --export=ALL propagates the submitting environment, so launching with the vars set is usually enough; if your site disables that, use a shared ~/.aws/credentials readable on the worker nodes, or add the exports to the dask job-script-prologue. Never bake secrets into a config file.

Resume & sparsity

resume=True makes a run continue after an interruption via a single-writer manifest (the driver records each completed block as results stream back), so no per-object existence scan is needed and it works for both zarr and precomputed. All-fill (e.g. all-zero) chunks are elided (not written) and recorded as empty, so sparse segmentations stay small and resume never reprocesses them. verify=True instead checks storage authoritatively per block.

The neu-vol command

Installing the package provides neu-vol (equivalently python -m neu_vol):

neu-vol info    <volume>                      # format, voxel sizes, chunking, levels
                                              #   (also an HDF5 file or a slice stack)
neu-vol convert --src ... --dst ...           # build a multiscale volume
neu-vol downsample <volume> --start-level 2   # rebuild levels above a trusted one
neu-vol progress <volume>                     # chunks written, per level
neu-vol create  <dst> --like <reference>      # an EMPTY volume in a known frame
neu-vol write   <volume> --src ... --offset   # put one subvolume into it
neu-vol to-hdf5 --src slices/ --out piece.h5  # pack a piece, frame and position included
neu-vol align-bbox --volume ... --bbox ...    # move a box onto the block grid
neu-vol relabel <volume> --out ...            # one id range per occupied region
neu-vol mask-by-value <volume> --values 1     # background that is not 0, made 0
neu-vol help    [COMMAND]                     # what --help prints, as a subcommand

Anything a viewer consumes lives in neu-glance: neu-glance gen for a link or a state, neu-glance bboxes for a layer of boxes over a sparse volume’s data, neu-glance annotate for a layer of your own coordinates. This package supplies the occupancy analysis those build on (ops.occupancy.labeled_regions) and otherwise knows nothing about neuroglancer. The three used to be neu-vol bboxes-json, annotate-json and ng-url-gen; the rename was a clean break with no aliases.

info, progress and align-bbox read only. downsample rebuilds a pyramid in place from a level you trust — cascaded downsampling means a bad level poisons everything above it — and --dry-run prints the schedule beside what is on disk, refusing if they disagree rather than leaving the pyramid inconsistent. Use convert to build a new volume.

create and write are the small-pieces path and are not block-mapped — they run in the calling process, no dask. Both take --dry-run.

neu-vol create /abs/annotations.precomputed --like s3://.../image.precomputed \
    --dtype uint64 --kind segmentation            # empty; same frame as the image
neu-vol write /abs/annotations.precomputed --src piece.h5 --offset 1024,4096,4096
neu-vol write /abs/annotations.precomputed --src slices/ --offset 4096,16384,16384 \
    --level 2 --offset-level 0                    # coords read off level 0
neu-vol write /abs/annotations.precomputed \
    --src a.h5 --src b.h5 --src c.h5              # each file's own voxel_offset

--src is repeatable and --offset is optional: with none given, each source is asked for its own (--offset-field, default voxel_offset). Every source in a batch is checked before any is written. --offset-order xyz if the stored numbers are xyz — the field name is precomputed’s, where it is, and reversed the piece lands mirrored through the z=x diagonal.

--all-datasets writes every volumetric dataset of an HDF5 --src, each at its own recorded offset — a bag of ground-truth crops in one file goes back into the volume in one command, rather than one --dataset at a time:

neu-vol write <volume> --src gt_v1_eval_cleaned.h5 --all-datasets
neu-vol write <volume> --src gt_v1_eval_cleaned.h5 --all-datasets 'z*'   # a subset

The optional glob is matched against the dataset path and its basename, so 'z*' and '/edge_*' both mean what they look like, and a pattern matching nothing raises rather than writing nothing and reporting success. The expansion feeds the same batch, so all of them are still planned before any is written. It cannot be combined with --dataset (which names one) or --offset (which belongs to a source you typed). ops.write.container_sources is the same thing from Python.

--format zarr|precomputed picks the target (default: the reference’s own). Precomputed keeps its scales in one intrinsic info and takes --encoding (compressed_segmentation for --kind segmentation, else raw); it cannot shard, and says so rather than dropping --shard on the floor.

--like copies the reference’s level shapes verbatim rather than recomputing a schedule, so the two volumes cannot drift a voxel apart partway up the pyramid. write reports whether the region is aligned to the destination’s chunk grid: an unaligned edge means those chunks are read-modify-written, which is correct on its own but loses one of two updates if overlapping writes ever run at once.

to-hdf5 makes such a piece from anything readable, including a box out of a DVID or webknossos instance at any level, recording the level’s voxel size and the source’s kind:

neu-vol to-hdf5 --src dvid://dvid.example.org/93fdbc:main/labels --out piece.h5 \
    --level 2 --crop-bbox 1000,1125,1575,1016,1141,1591 --dvid-locked

A branch ref is resolved once, so every block comes from the same node, and the command prints which node that was.

An HDF5 file can be the source too, which is how a file’s dtype, chunking or compression is changed. --all-datasets (or --src-dataset repeated) repacks a whole container, each dataset keeping its name and frame; --src and --out may be s3:// or gs://, worked on as temporary local copies and transferred once for the whole set:

neu-vol to-hdf5 --src s3://my-bucket/gt/crops.h5 --out s3://my-bucket/gt/crops_u16.h5 \
    --all-datasets --dtype uint16

A narrowing --dtype is refused if any value does not fit, naming the block; --truncate casts anyway. Large S3 files want the s3 extra (boto3: streamed, multipart); without it a file passes through memory and an S3 upload stops at 5 GB.

Inspecting anything readable, not just a volume

neu-vol info works on every format the package can read — a zarr or precomputed volume, a dvid:// instance, an HDF5 file, or a directory/glob of 2D slices:

neu-vol info piece.h5                        # shape, dtype, and the frame it records
neu-vol info slices/                          # a stack: shape and dtype; no scale to read
neu-vol info gt_v1_eval.h5                    # SEVERAL datasets: lists them all
neu-vol info gt_v1_eval.h5 --dataset /z07901  # ...then the full report on one

The last two are the case worth knowing. An HDF5 file is a container, and here it is usually a bag of crops rather than one volume — several annotated chunks in one file, each keeping its own voxel_offset so write can place it back. A path names the container, not an array, so info lists every 3D+ dataset with its shape, chunking and offset, and --dataset selects one. With exactly one dataset it is found and nothing has to be said.

describe() is the library form of all of this and returns the same picture — for a container, datasets listing every array with its own frame and shape/meta left None, since thirteen differently-shaped crops have no single answer. It is a dict that shows itself: print(describe(f)) gives the table above, a bare describe(f) in a notebook cell renders it, and describe(f).frame() is a DataFrame — one row per dataset for a container, one per level for a volume, holding the numbers rather than the formatted strings. Subscripting is unchanged, and pandas is only needed for .frame().

Everything else that resolves an array (create --like, align-bbox, convert, to-hdf5 --src) needs exactly one, and each takes the dataset name where it can — to-hdf5 alone can also take them all (--all-datasets). If a file records no frame, info says which attribute names it looked for — voxel_size, voxel_offset, offset, units, axes — because “records no scale” and “spells it differently” look identical otherwise, and those names are parameters (--voxel-size-field / --offset-field) for exactly that reason.

The ops that rewrite a volume in place — downsample, relabel, mask-by-value, write, progress — refuse a container and say so: one array in one container has no pyramid to rebuild, and no chunk objects whose presence answers “where is the data”. Convert it into a volume first, or use write to place it into one.

Putting a box on the grid

align-bbox moves a bounding box onto a block grid and prints it back, ready to substitute into --crop-bbox or --roi:

neu-vol align-bbox --volume V --bbox 5600,4470,6790,5770,4740,7050 --to both
neu-vol copy --src V --dst D --crop-bbox $(neu-vol align-bbox --volume V --bbox ... -q)

Which grid is the question, and there are three: the write unit (the chunk, or the shard where the level is sharded — a partial write is a read-modify-write, and two concurrent ones into a single object lose an update silently); the pyramid’s cumulative factor (a crop that misses it has coarse levels on their own grid, level 0 still exact); and the per-axis LCM of the two, which is what a cropped copy wants. --to read-chunk asks the fourth question — read amplification — which is not a safety one.

Modes: outer (cover; cannot fail), inner (be covered), nearest, and origin, which aligns the origin and keeps the extent exactly for a fixed-size crop. --block z,y,x needs no volume; --scale N takes the box in another level’s voxels via the real per-level voxel sizes. Boxes are half-open, so a bound already on a boundary stays put, and a bound at the volume’s own extent counts as aligned — that final block is partial in the volume too. neu-vol write reports alignment through the same predicate, so the two agree.

Finding the data in a sparse volume

A volume holding a few labeled boxes inside a large empty frame is hard to view — the boxes are needles in the frame. Finding them is ops.occupancy.labeled_regions, and the boxes come from the volume itself so they cannot drift from the data: an all-fill chunk is never stored, so which chunk objects exist is the occupancy question exactly — no voxel reads. Those cells are then covered with maximal boxes (not connected components: two regions written face to face merge into one, plus the empty corner between them), and each box is tightened to its nonzero voxels at a coarse level.

relabel, mask-by-value and downsample --sparse all ask that same question. To see the answer, neu-glance bboxes turns it into a viewer layer:

neu-glance bboxes s3://.../gt_v1 --label gt                     # layer JSON to stdout
neu-glance bboxes s3://.../gt_v1 --format url --out link.txt    # or a whole link

Ground truth annotated chunk by chunk

Annotation tools usually number each chunk from 1, so the same integer means a different cell in every chunk. Meshed, that becomes one body with components scattered across the volume — correct for the label, useless as ground truth. relabel gives each occupied region its own range:

neu-vol relabel s3://.../gt_v1 --out s3://.../gt_v2 --dry-run   # reads, writes nothing
neu-vol relabel s3://.../gt_v1 --out s3://.../gt_v2             # then --start-level 0
neu-vol downsample s3://.../gt_v2 --start-level 0

Regions are the same stored-chunk footprints labeled_regions reports, so they are pairwise disjoint and chunk-aligned — no write is a partial-chunk update. It is serial by construction, since each range begins where the last ended, so it runs in the calling process with no dask. --block-size N numbers region k from N*k+1 instead, making the source region readable off the id.

The old→new mapping is written to <destination>.relabel-<level>.json (--map to place it) — the only way back from a new id to the region and original label it came from. --out is preferred over --in-place: a sparse copy is nearly free and the original stays as the record of the raw annotation. Single-scale, like write, so the levels above are stale until downsample re-runs; it says so.

# smoke test locally, then launch on SLURM surviving logout:
neu-vol convert --src ... --dst ... --serial --single-level
nohup env PYTHONUNBUFFERED=1 neu-vol convert --src ... --dst s3://... \
    --config dask-slurm-example --config ~/my-site.yaml --workers 48 > run.log 2>&1 &
squeue -u "$USER"

PYTHONUNBUFFERED=1 is the console-script equivalent of python -u; without it the log lags a long run in 8 KB blocks.

Cluster config

--config takes a bundled template name or a path, and is repeatable, deep-merged left to right. The templates (dask-local, dask-slurm-example) ship with blockrun, next to start_dask, so all its consumers share one set. An overlay carries only the keys that differ:

# my-site.yaml
jobqueue:
  slurm:
    account: my-account
    queue: my-partition
    log-directory: /path/with/room

Unrecognised keys raise rather than merging silently. Site-specific configs are deliberately not in this repo; the top-level configs/ is gitignored.

Size workers against a pyramid task, not a level-0 one. Three multipliers compound, and only the first is visible in the plan the command prints:

factor

512³ chunk, uint64

output chunk

—

1 GiB

reduction window (2³) is read, not written

×8

8 GiB

the segmentation mode reducer peaks over its input

×8

64 GiB

A 512³ uint64 chunk therefore needs ~64 GiB per worker at level 1, against a level-0 task for the same chunk costing 1 GiB — so a copy sails through level 0 and every worker is killed on level 1. --chunk 128,128,128 (the profile default) makes the same task 1.1 GiB and is the right output anyway: a 512³ uint64 chunk is a 1 GiB object, which is poor for random access and for a viewer. Inheriting a source’s large chunk is copy’s source-preserving default and is worth overriding whenever --dtype widens the type.

Next: brightness/normalization + morphological transforms.

License

©2026 The Simons Foundation, Inc.

Licensed under the Apache License, Version 2.0. See CONTRIBUTING.md for how contributions are licensed.