phenix-icechunk
Expose an Opera Phenix / Harmony Index.xml export as a virtual OME-Zarr 0.5 HCS plate
inside an icechunk repo. No pixels are copied: every zarr chunk is a
virtual reference into the original TIFFs, so the repo is a few hundred KB.
Setup
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -e .
Convert
.venv/bin/phenix-icechunk "/path/to/Measurement 2/Images/Index.xml" data/plate.icechunk
# + flatfield correction by default, + Bio-Formats OME-TIFFs (one per well) next to the repo
.venv/bin/phenix-icechunk "/path/to/Measurement 2/Images/Index.xml" data/plate.icechunk \
--flatfield --ome-tiff data/ome_tiff
# + max z projection (after flatfield), committed as its own transaction, exported as OME-TIFF
.venv/bin/phenix-icechunk "/path/to/Measurement 2/Images/Index.xml" data/plate.icechunk \
--flatfield --zproject --ome-tiff data/ome_tiff # --zproject mean|median|min|sum|std
| Option | Meaning |
|---|---|
--flatfield |
Make Harmony flatfield correction the repo's read default (open_field/open_datatree apply it unless flatfield=False) and apply it to --ome-tiff. Repo pixels stay raw and virtual |
--zproject [METHOD] |
Project every field along z, after flatfield correction if on, and commit it as a separate icechunk transaction under /projections/z<METHOD>. Bare flag = max; also min, mean, median, sum, std. Off by default |
--ome-tiff DIR |
Also write one OME-TIFF per well to DIR (see below) |
--ome-tiff-data auto|stack|zproject|both |
What --ome-tiff writes. auto (default) = the projection if --zproject is given, else the z stacks |
--ome-tiff-compression zlib|lzw|none |
Tile compression of the OME-TIFFs (default zlib, level 1) |
--chunking plane|strip |
Virtual chunk layout (default plane, see "How it works") |
--workers N |
Threads for the TIFF header scan (default 32) |
--container-prefix URL |
file:// prefix for the virtual chunk container (default: deepest URL-safe parent) |
--overwrite |
Replace an existing repo / OME-TIFFs |
Read as xarray
import zarr
from phenix_icechunk import open_plate, open_field, open_datatree
zarr.config.set({"async.concurrency": 16}) # default 10; 16 was best on /nfs
plate = open_plate("data/plate.icechunk") # open once, reuse for many fields
da = open_field(plate, "A4", 0) # lazy xr.DataArray, nothing read yet
dapi = da.sel(c="DAPI").isel(t=0).values # reads only the 12 DAPI planes
roi = da.sel(x=slice(1900, 2000), y=slice(-7100, -7000)) # select in µm
da.attrs["plate_id"], da.attrs["assay_layout"], da.coords["acquisition_time"]
da = open_field(plate, "A4", 0, chunks={}) # same, dask-backed (needs dask)
tree = open_datatree(plate) # whole plate as xr.DataTree, all lazy
tree["A/4/17"]["image"] # field 17 of well A4
tree["phenix/images"] # per-image table as a Dataset
open_field returns dims (t, c, z, y, x) with:
- coords:
c: channel names, pluschannel_id, excitation/emission wavelength, exposure time, acquisition type, binning, camera, objective, colour.z: plane z in µm, plusplane_id.y,x: µm in stage space. This is the same transform as the OME-Zarr metadata, so neighbouring fields line up.t: seconds, plustimepoint_id.- Per-plane
(t, c, z)coords:image_id,acquisition_time(datetime64, UTC),abs_position_z,intensity_mean/stddev/min/max,temperature,co2,humidity. Pick other table columns withplane_coords={"name": "column", ...}.
- attrs: well (ID, row/col, assay layout), field (ID, stage position, acquisition
start/end, OME-XML image ID), plate (IDs, type, measurement, user, instrument, source
Index.xml), and the field's full OME-Zarr metadata under
attrs["ome"].
Reads through xarray cost the same as raw zarr: about 0.35 s for a full 4×12-plane field.
Nothing is decoded to float, because fill values are not applied, so pixels stay uint16.
Flatfield correction
from phenix_icechunk import flatfield_profiles
da = open_field(plate, "A4", 0, flatfield=True) # still lazy, float32
# flatfield=None (default) follows the repo: True if it was converted with --flatfield
da.attrs["flatfield"] # formula + per-channel profile summary
tree = open_datatree(plate, flatfield=True) # same for every field
flatfield_profiles(plate) # the (c, y, x) profiles themselves
Each channel's Harmony FlatfieldProfile (Background, an Acapella 2-D polynomial) is
evaluated as P = Σ Coefficients[k][j] · u^(k−j) · v^j, with
u = (x − Origin₀)·Scale₀ and v = (y − Origin₁)·Scale₁. It is normalised to mean 1, and
pixels become raw / P, so intensities keep their scale.
- The correction is applied on read, per plane. Nothing in the repo changes, and it costs about 0–0.1 s per full field on top of the 0.5 s read.
- The term order was checked against the data. The median of all 56 fields per channel
correlates with
Pat 0.95–0.98, against 0.52–0.81 for the transposed order. After correction, background non-flatness drops from 3.6–6.3 % to 0.9–2.1 %, with the mean unchanged. - Channels without a polynomial (e.g.
Character: Flat) are left as they are (attrs["flatfield"]["channels"][name]["applied"] == False). - If the image size differs from the profile's
Dims(binning), the profile is rescaled.
Z projection
from phenix_icechunk import zproject
zproject("data/plate.icechunk", "mean") # add another method later: one more commit
mip = open_field(plate, "A4", 0, zproject="max") # (t, c, 1, y, x), attrs["zproject"]
tree = open_datatree(plate, zproject="max")
- Projections are real pixels (they can't be virtual). They are stored as a full OME-Zarr HCS
plate under
/projections/z<method>/<row>/<col>/<field>/0(z size 1, z = mid-stack), and listed in rootattrs["phenix"]["projections"]. - Each projection is its own icechunk commit on top of the virtual plate, e.g.
Z max projection of 56 fields after flatfield correction. The plate commit is never touched, so a failed or replaced projection leaves it intact. Re-running a method needsoverwrite=True, and the old version stays in history. - Order:
project(flatfield(raw)). The stored projection already has the correction, soopen_field(..., zproject=...)refuses a conflictingflatfield=argument. - dtype:
max/minof raw planes stayuint16. With flatfield, or formean/median/sum/std, the result isfloat32. For this plate the float32 max projection takes 3.3 GB in the repo and took 40 s to compute.
OME-TIFF export (Bio-Formats / Fiji / QuPath / OMERO)
--ome-tiff DIR on convert, or later from any repo:
from phenix_icechunk import export_ome_tiff
export_ome_tiff("data/plate.icechunk", "data/ome_tiff", flatfield=True, compression="zlib")
export_ome_tiff("data/plate.icechunk", "data/ome_tiff", zproject="max") # -> <plate>_<well>_zmax.ome.tiff
The output is one <plate>_<well>.ome.tiff per well:
- Every field of view is its own series (tile):
XYZCTplanes, 512×512 internal tiles, BigTIFF. - The OME-XML in the first IFD carries the Plate/Well/WellSample layout with stage positions,
channels (names, wavelengths, modes, colours), every Plane (ΔT, exposure, XYZ), StageLabel,
ImagingEnvironment, instrument, and assay-layout/channel MapAnnotations. It is the
same document as
/OME/METADATA.ome.xml, restricted to the well, plusTiffDataper image. - With flatfield, corrected pixels are rounded and clipped back to
uint16.
Checked with Bio-Formats 8.1.1 showinf: the file opens as OME-TIFF with 56 series of
Z=12 × C=4 × 2160², and the plate/well/well-sample positions are parsed. Planes extracted with
bfconvert are bit-identical to the corrected repo planes. The well above (25 GB raw) took
about 100 s to write and is 10.5 GB with zlib.
Low-level (zarr)
img = plate["A/4/0/0"] # zarr.Array, (t, c, z, y, x)
stack = img[0] # numpy
open_plate grants icechunk access to the repo's own file:// container. Opening the
repo with plain icechunk.Repository.open also works, but you have to pass
authorize_virtual_chunk_access yourself.
from phenix_icechunk import read_file, read_table
df = read_table(plate["phenix/images"]) # one row per <Image>, 76 columns
xml = read_file(plate["phenix/files/Images/Index.xml"]) # original bytes
ome = read_file(plate["OME/METADATA.ome.xml"]).decode() # generated OME-XML
plate["phenix"].attrs["index_xml"]["channels"]["1"] # every Map entry for channel 1
What is stored
Every piece of metadata in the export is stored in the repo, both verbatim and parsed:
| Where | What |
|---|---|
/ attrs ome.plate |
OME-Zarr 0.5 plate: rows/columns/wells, acquisition (measurement ID, start/end time, max field count) |
/ attrs phenix |
Summary: plate/measurement IDs, user, instrument, start/end/export times, channels, TIFF layout, converter versions |
/<row>/<col> attrs |
ome.well + phenix: Harmony well ID and the well's assay-layout values (Compound, Concentration, …) |
/<row>/<col>/<field> attrs |
ome.multiscales (scale, z step, stage translation, time step if T>1), omero channel names and colours, phenix: field ID, stage position, per-plane z, acquisition start/end, matching OME-XML image ID |
/phenix attrs |
Index.xml in full: top-level fields, plate block and units, every <Map> entry per channel (FlatfieldProfile parsed from Acapella YAML, SkewcropParameters decoded from base64-gzip, OrientationMatrix, CropArea, binning, camera, exposure, excitation power, …); parsed assay layouts; TIFF tags shared by all files |
/phenix/images/<column> |
Table with one row per <Image> (including non-Ok ones): every Index.xml field (IDs, positions, AbsPositionZ, AbsTime, time offset, …), where the plane lives (zarr_path, t/c/z_index), file size and mtime, and every TIFF tag. The private Harmony tags are flattened: tiff.TiffImageInfo.* (RecordID, ExposureID, absolute XYZ, temperature, CO₂, humidity, regulation states), tiff.tag65520.* (per-plane mean/stddev/min/max), FlexXML. Raw XML/JSON is kept alongside, units go in column attrs |
/phenix/files/<relpath> |
Every non-TIFF file of the export, byte-for-byte, with sha256, mtime and source path in attrs (Images/Index.xml, Assaylayout/*.xml, .ExportDone_IndexAndImages) |
/OME/METADATA.ome.xml |
Generated OME-XML (validates against the 2016-06 XSD): Plate/Well/WellSample/PlateAcquisition, Instrument (microscope, objective, camera), one Image per field with Channels (modes, wavelengths, binning), every Plane (DeltaT, exposure, XYZ), StageLabel, ImagingEnvironment from TIFF diagnostics, and MapAnnotations for channel settings and assay layout |
The plate, well and field metadata validate with ome-zarr-models (v0.5).
Only the pixel strip offsets are not stored as metadata, because they are the virtual chunk refs.
How it works
Index.xmlgives the well/field/plane/channel/timepoint of every TIFF. Each field becomes one(t, c, z, y, x)array with OME-Zarrmultiscales(scale fromImageResolution, z step fromPositionZ, translation from stage position: x = +stageX, y = −stageY).- TIFF headers are scanned in parallel (about 3 ms per file on /nfs) to get strip offsets and file sizes.
- plane chunking (default): one chunk per plane, referencing the whole TIFF file and
decoded by imagecodecs'
imagecodecs_tiffcodec (libtiff). That is one large read per plane, which is what NFS serves fastest. - strip chunking: one chunk per TIFF strip (122 rows here), decoded by the bundled
phenix_tiff_stripcodec, which zero-pads the short last strip. Partial reads touch fewer bytes, but the request count goes up about 18×. - Uncompressed TIFFs with contiguous strips get a plain
bytes-codec chunk per plane. - Each reference stores the TIFF's mtime, so icechunk raises an error rather than return wrong pixels if a source file is rewritten.
The codecs are registered through the zarr.codecs entry point, so any zarr-python 3 reader in
this environment can decode the chunks. Readers elsewhere need imagecodecs (plane mode) or this
package (strip mode).
Benchmark (bench/bench_read.py)
The measurement has 56 fields × 4 channels × 12 z planes of 2160² uint16, LZW-compressed. The table shows one field (448 MB decoded); each method read a different field from a cold cache.
| method | plane repo | strip repo |
|---|---|---|
| tifffile, sequential | 2.2 s | 2.6 s |
| tifffile, 16 threads | 0.66 s | 0.53 s |
icechunk zarr img[0] |
0.39 s | 1.05 s |
| tifffile, 16 threads, 512² ROI | 0.32 s | 0.34 s |
| icechunk zarr, 512² ROI | 0.41 s | 0.53 s |
Strip chunking does not pay off on this NFS mount, because per-request latency dominates. It may help on object storage or for viewers that fetch small tiles.
Notes / limitations
- icechunk 2.3 cannot match a virtual-chunk container whose prefix needs percent-encoding
(for example the space in
Measurement 2), so the container is set at the deepest URL-safe parent directory. - Only one resolution level is exposed; pyramids would have to be materialised.
- Flatfield correction is read-time only (
flatfield=True) and uses theBackgroundprofile. No darkfield/offset is subtracted, because Harmony exports here carry no separate darkfield, and theBackgroundprofile already describes the raw background including the camera offset. - icechunk only accepts zarr keys, so files are stored as
uint8arrays, not plain files. Tools expecting bioformats2raw's plainOME/METADATA.ome.xmlfile needread_file. - All channels must share image size, dtype and TIFF layout. Otherwise conversion stops with an error.
Metadata
Release files for phenix-icechunk 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| phenix_icechunk-0.1.0.tar.gz | 38.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| phenix_icechunk-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 84.8 kB
Release files / phenix_icechunk-0.1.0.tar.gz
| Download URL | phenix_icechunk-0.1.0.tar.gz |
|---|---|
| Size | 38.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b544caba48a8fa2cea3631948f221617c2ed24c11781dc69dad7789cea5cd7a6
|
|
BLAKE2b-256 checksum How to use checksums |
2f50f7c42c10d32f542cd4351d93bdb3209c57478bc3dd648c4d49d5c865f881
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.7
|
Release files / phenix_icechunk-0.1.0-py3-none-any.whl
| Download URL | phenix_icechunk-0.1.0-py3-none-any.whl |
|---|---|
| Size | 46.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2c80767883997b989b1e5d18b370d7b6af4fbaa17ae8449941a90729a70466fc
|
|
BLAKE2b-256 checksum How to use checksums |
3e100d99b9d069427062da41a2057db97fecb3adb3ce2e3287d8d1b6e120b7f0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.7
|
Security for phenix-icechunk 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.