Reading and writing from Python¶
The CLI works in whole volumes: convert one, create one, place a piece into one. In a notebook or a cleaning script the unit is smaller and the loop is different — read a box, transform it, write the result — and the thing that has to survive that loop is not the array. It is where the array is.
Two functions do it, and they are inverses:
import neu_vol
piece = neu_vol.read_piece("gt_v1_eval.h5:/vol_03700")
cleaned = piece.apply(neu_proc.dust, min_voxels=1000)
neu_vol.write_piece(cleaned, "gt_v1_eval_cleaned.h5") # -> /vol_03700, same frame
Nothing in that snippet names a voxel size, an offset, an axis order or a dataset. All four came out of the file, rode along through the transform, and went back in.
A piece is an array that knows where it is¶
neu_lib.Piece pairs an array with a Frame — a voxel size and an origin, both in
nanometres — plus what the voxels mean (kind) and what to call them (name). The
pairing is the point: an array on its own forgets where it came from after the first
operation, and the failure that causes is silent. Data that lands at nanometre zero looks
exactly like data that belongs there.
piece.shape # (256, 256, 256)
piece.voxel_size_nm # (8.0, 8.0, 8.0)
piece.origin_nm # (19200.0, 45600.0, 22160.0)
piece.origin_voxel # (2400, 5700, 2770) — whole voxels, or it raises
piece.bbox # BBox(lo=(2400, 5700, 2770), hi=(2656, 5956, 3026)) in FRAME voxels
piece.bounds_nm # the same box in nm — what transfers between two sources
piece.kind # 'segmentation'
origin_voxel raises rather than rounding when the origin is not a whole number of
voxels, because rounding shifts the piece by up to half a voxel against whatever it is meant
to line up with, and nothing downstream can detect that. bounds_nm is always available.
Note
Everything here is zyx in memory and nanometres in the world — invariants the whole suite
depends on. Both precomputed mesh/skeleton files and precomputed voxel_offset are xyz,
which is why the axis order is something the file states and the reader reads, never
something anybody guesses.
read_piece — the voxels, and everything known about where they are¶
read_piece("piece.h5") # one dataset in the file: found
read_piece("gt_v1_eval.h5:/vol_03700") # a container: name the array
read_piece(volume, level=2, crop=((0, 0, 0), (256, 256, 256)))
read_piece(volume, level=2, crop=piece) # the same PHYSICAL box as `piece`
src is PATH or PATH:/DATASET; only a leading slash makes the tail a dataset, so a
scheme’s own colon (s3://…) is left alone. A spec mapping or an already-open backend=
works too.
The crop may be given three ways and the physical one is what earns its keep. A voxel
box means nothing outside its own frame — two levels of one volume have different voxel
sizes, a crop and its parent have different origins — so nanometres are the only currency
that transfers. Handing read_piece another piece is how “show me the EM image under this
ground-truth crop” becomes one call:
gt = read_piece("gt_v1_eval.h5:/vol_03700")
image = read_piece(image_volume, level=0, crop=gt) # registered, by construction
A physical box is converted with the target level’s own voxel size and origin — read from
its metadata, never assumed to be 2**level, because real pyramids are anisotropic — and
grown outward, so the read contains what was asked for rather than dropping a face where the
levels do not divide evenly. A box that mostly misses is reported: losing most of a box
usually means the two sources are not the same volume.
Three things it will not do for you:
kind is not guessed. A uint8 label array is indistinguishable from an image by dtype,
and getting it wrong later authorises averaging label ids into ids that were never in the
data. Where the source records nothing, kind stays None and you pass it:
read_piece(path, kind="segmentation").
voxel_size is required for a source that records none — a slice stack, or a plain HDF5
file that carries no voxel_size attribute. There is nothing to read, so there is nothing
to infer.
A cast on the way in is allowed, and a narrowing one is called out. dtype= casts
after the read, so a piece arrives in the dtype it will be used in — a set of crops exported
by different tools comes as uint8/16/32/64, and a consumer that has to handle all four is
what this avoids:
piece = read_piece(gt_file, dataset=name, kind="segmentation", dtype="uint64")
Widening among unsigned ints loses nothing. Narrowing is warned about rather than refused, since only you know the range your labels use — but it wraps a label id into another plausible label id, and a segmentation has no invalid values for anything downstream to catch.
A read too large to finish is refused, not attempted. A whole level 0 of a production
volume is terabytes; read_piece(volume) on one does not fail, it hangs, which in a
notebook is indistinguishable from a wedged kernel with every later cell pending behind it.
The cap is 4 GiB, the error names the size and a coarser level that would fit, and
max_bytes=None lifts it for a caller who means it.
Transforming without losing the frame¶
cleaned = piece.apply(neu_proc.opening, radius=2).apply(neu_proc.dilate)
edited = piece.copy() # the array is copied too
edited.array[edited.array == 668] = 650
apply keeps the frame, the name and the kind across a chain, and guards the two cases
where it cannot: a transform that changes the spatial shape must supply the new frame
(a 2x downsample halves the shape and doubles the voxel size), and one that changes the
dtype must say what the result is (kind=..., or kind="same"). Both guards exist because
the alternative is metadata that quietly stops describing the array.
copy() copies the array, which dataclasses.replace does not — so an in-place edit on what
looks like a copy cannot reach back into the original, or into whatever the original was a
view of.
write_piece — back out to a file¶
plan = neu_vol.write_piece(cleaned, "gt_v1_eval_cleaned.h5")
plan["dataset"], plan["voxel_offset"], plan["kind"]
# ('/vol_03700', (2400, 5700, 2770), 'segmentation')
Four arguments to-hdf5 has to ask for are absent here, because the piece answers them:
voxel_size is the frame’s, voxel_offset is piece.origin_voxel, axes is always zyx
and units always nm. So this path has no axis-order question at all — the file records
axes="zyx" and neu-vol write reads it rather than assuming.
kind travels too, and that is what makes the round trip lossless: a cleaned segmentation
read back is still a segmentation. Use piece.with_kind(...) to change it.
The dataset name comes from the piece. read_piece names a piece after its source —
stem/inner for an array inside a container — so the last component is reused, and a bag of
crops round-trips through a whole cleaning pass with no arguments:
for name in neu_vol.describe(gt_file)["datasets"]:
piece = neu_vol.read_piece(gt_file, dataset=name, kind="segmentation")
neu_vol.write_piece(clean(piece), out_file) # one file, same names
A piece named after a volume has no such component and gets /data, which is also what
the reader assumes when it is not told. dataset= overrides both, and is the way to write a
nested path.
An existing file is added to when its recorded frame matches — several pieces of one
volume in one file is the ordinary arrangement here, each keeping its own voxel_offset.
Two things are refused rather than guessed: a frame that disagrees with the file’s, since
one file describing two coordinate systems has no correct reading, and a dataset name
already in use, unless overwrite=True.
What lands in the file, and why in two places:
attribute |
where |
what it says |
|---|---|---|
|
root and dataset |
the coordinate system, so either the file or the array alone is self-describing |
|
dataset |
where this piece starts, in whole voxels |
|
dataset |
the same place in |
|
dataset |
image, probability or segmentation |
Writing is blocked, so a piece holding a lazy array — a dask or zarr array, an open h5py
dataset — streams rather than being materialised whole. dry_run=True returns the same plan
dict without touching the file.
Warning
out must be an ordinary filesystem path ending in .h5 / .hdf5 / .hdf / .he5. h5py
has no object-store driver, and the extension is not cosmetic: HDF5 has no marker object, so
describe, neu-vol info and neu-vol write recognise a container by name. A file
written under any other name would be unreadable by everything that has to recognise it.
Note
Reading a file and then writing to it in the same process works, including a re-run of the
same cell. That needs saying because it once did not: a backend caches its open h5py.File,
and HDF5 refuses to open a file for writing while any handle holds it read-only, so the
write failed with an error naming the file rather than the reader still holding it. The
write path now releases those handles first. neu_vol.backends.base.release_backends(path) is the same
thing by hand, if something outside this package is holding one.
Then placing it back into a volume¶
write_piece writes a file, not a volume — and the file is exactly what neu-vol write
wants, so the two compose:
neu_vol.write_piece(cleaned, "piece.h5")
neu_vol.write_subvolume(volume, "piece.h5") # no offset: the file recorded one
neu-vol write <volume> --src piece.h5 # the same thing from the shell
That indirection is deliberate. Writing into a volume is a chunked operation with a
concurrency hazard — a region that does not land on the chunk grid makes the store
read-modify-write the boundary chunks, so two pieces sharing one written at the same time
lose an update with nothing left behind to detect it. Going through a file keeps the
placement in one serial, checked step. write is also single-scale on purpose: run neu-vol downsample afterwards, since how labels should look when coarsened is a separate decision.
Looking at one on the way past¶
A piece is a layer input, so a crop can go to a viewer without being written anywhere
first — neu_glance.serve and srv.add_layer both take one, and it arrives already named
and knowing its kind:
neu_glance.serve([piece]).add_layer(cleaned) # before and after, side by side
Checking what you wrote¶
neu_vol.describe("cleaned.h5")["datasets"] # every array, with its own offset
neu-vol info cleaned.h5 # the same table
neu-vol info cleaned.h5 --dataset /vol_03700 # the full report on one
If a file records no frame, the report says which attribute names it searched, because
“records no scale” and “spells it differently” look identical otherwise —
voxel_size_field / offset_field are parameters for exactly that reason, on both the
reading and the writing side, so a file keeps whatever spelling its siblings use.
What is deliberately not here¶
There is no region-by-region HDF5 write. HDF5Backend.write_region raises and says so:
an HDF5 file is produced in one call, by write_piece for an array in memory or
pack_hdf5 / neu-vol to-hdf5 to stream one out of another source. Both go through the same
layout code, so either file is placeable the same way.
There is no remote HDF5, in either direction. Copy the file, or write locally and upload.
write_piece does not take a volume as its destination. See above — that is
write_subvolume, and it reads from a file.
See also several small pieces into one frame for the same workflow driven entirely from the CLI, and ground truth to meshes for what happens to a cleaned GT volume next.