Skip to content

Add light microscopy and light-sheet formats behind a seeking reader (0.6.0) - #4

Merged
aaronkanzer merged 3 commits into
mainfrom
feat/light-sheet-formats
Oct 17, 2026
Merged

aaronkanzer merged 3 commits into
mainfrom
feat/light-sheet-formats

Conversation

@aaronkanzer

Copy link
Copy Markdown
Member

What

The library decoded one of the formats a light-sheet rig produces (MRC). Its README listed TIFF and HDF5 as out of scope because their metadata sits behind internal pointers. This PR adds a bounded seeking reader and the decoders that need it.

Reader model

  • Read(seek=budget) registers a decoder that takes a Source (read_at, read_tail, size, key) instead of bytes. FileSource, UrlSource (HTTP Range, 64 KiB cached chunks, URL_MAX_REQUESTS cap) and BytesSource (bytes, or sparse ranges for fixtures) implement it. decode_source(key, src) serves every format from a Source.
  • A directory decodes through the first registered sidecar it holds (Zarr / N5 stores); an OME-Zarr store merges its level-0 array shape and dtype.
  • Golden fixtures for seeking decoders are sparse recordings of real files (tests/fixtures/*.sparse.json).

Formats

Format Extensions Verified on Cross-checked with
TIFF / BigTIFF / OME-TIFF / ImageJ / Micro-Manager tif tiff btf tf2 tf8 OME samples, libtiff samples, Zenodo 10471496 tifffile
Zeiss CZI czi LSM 980, Axioscan, Lightsheet Z.1, LLS7 (Zenodo 19047136, 10577186) czifile
HDF5: Imaris, BigDataViewer, Luxendo, generic h5 hdf5 he5 hdf ims OME Imaris + BDV samples, h5py-written files h5py
Leica LIF lif OME sample readlif
Nikon ND2 nd2 OME samples (Ver2 and Ver3) nd2
Imspector OBF msr obf 5 Abberior .msr (hoerlteam/msr-reader) obffile
KLB klb OME sample bytes
ICS ics OME Huygens sample bytes
OME-Zarr / Zarr v2+v3 / N5 zarr.json .zattrs .zarray .zgroup attributes.json IDR 6001240, OpenOrganelle jrc_hela-2 zarr
XML sidecars: BDV SpimData, OME-XML, EPU xml OME BDV + OME-XML samples second parse

Spec-only (no public sample): Micro-Manager-native TIFF, ImSpector TIFF, Luxendo .lux.h5 (shape only). Not decoded: OIB/OIR, DCIMG, raw .bin/.dat.

Measured remote cost

BDV drosophila.h5 (620 MB)                2 Range requests
BDV WB.h5 (51 GB)                         2 Range requests
Imaris Convallaria .ims (7 MB)            6
CZI MouseBrain light-sheet (492 MB)       5   (metadata segment at byte 492 M)
OME-TIFF multi-channel .btf (21 pages)   24   (one per trailing IFD)
Nikon ND2 sample_image (218 MB)           6

Behavior change

.xml is now claimed by a root-element dispatcher; unknown XML decodes to None, and has_decoder_for("x.xml") is True. Illumina file-name dispatch still wins. One assertion in tests/test_illumina.py that .xml was unclaimed is removed.

Tests

pytest -q: 425 passed, 2 skipped (fuzz covers every registered extension and name; magic-seeded fuzz added for the TIFF, HDF5 and CZI pointer walks).

…(0.6.0)

Pointer-based formats could not be decoded from a fixed leading or trailing
read, so decoders may now register Read(seek=budget) and receive a bounded
Source (read_at / read_tail, byte budget, read cap, HTTP Range request cap,
64 KiB cached chunks). decode_bytes still serves them from memory, and sparse
recordings of real files are the golden fixtures.

New decoders: TIFF / BigTIFF / OME-TIFF / ImageJ / Micro-Manager, Zeiss CZI,
HDF5 without libhdf5 (superblock v0-v3, object headers v1/v2, symbol tables,
dense links and attributes via fractal heaps and v2 B-trees) with Imaris,
BigDataViewer, Luxendo and generic flavors, Leica LIF, Nikon ND2 (Ver2 XML
and Ver3 LiteVariant), Imspector OBF/.msr, KLB, ICS, OME-Zarr / Zarr v2+v3 /
N5 sidecars (a store directory decodes through them), and an .xml dispatcher
for BigDataViewer SpimData, OME-XML and EPU.

Verified on real sample files from the OME image repository, Zenodo, IDR and
OpenOrganelle, cross-checked against tifffile, czifile, h5py, readlif, nd2,
obffile and zarr. Two Range requests describe a 51 GB BigDataViewer file.
…sed, HDF5 cycle guard

A sweep of 390 files on the OME sample server by URL found one real failure:
Ver3 ND2 writers pad the chunk-map chunk's name field to 4128 bytes and the
decoder rejected names over 4096, so the whole file read as None. Fixed, with
a sparse fixture of that file cross-checked against the nd2 package.

The same sweep showed OME-TIFFs with trailing directories spending one Range
request per plane (48, the cap, on a 105-plane file). The XML already states
the plane count, so the chain is no longer chased for OME-TIFF; pageCount
stops at 1 and is marked truncated, planeCount comes from the OME block.

Hostile files (self-referencing IFDs, HDF5 continuation and B-tree cycles,
CZI and ND2 offsets past EOF, billion-laughs XML) all terminated within the
read cap; the HDF5 v1 B-tree walk now stops a cycle at once instead of
spending the cap on it. Those cases are kept as tests/test_adversarial.py.
Also run: 200k random and magic-seeded buffers, 14k mutations of every
fixture, a 900-file parallel batch identical to serial.
@aaronkanzer
aaronkanzer merged commit 906f77c into main Oct 17, 2026
3 checks passed
@aaronkanzer
aaronkanzer deleted the feat/light-sheet-formats branch October 17, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant