1. nima_io: Microscopy Data Reading Tutorial#

This notebook demonstrates reading various microscopy file formats, comparing nima_io (bioio-based) with tifffile, and inspecting OME metadata for all available test data.

1.1. Setup#

%load_ext autoreload
%autoreload 2

from pathlib import Path

import matplotlib.pyplot as plt
import tifffile

import nima_io.read as ir

tdata = Path("../../tests/data")

1.2. 1. Single-scene OME-TIFF#

A simple multi-channel time-series OME-TIFF with known structure: 5 timepoints, 3 channels, 17x13 pixels.

# nima_io: returns xarray.DataArray backed by dask
da = ir.read_image(str(tdata / "im1s1z3c5t_a.ome.tif"))
print(f"dims={da.dims}, shape={da.shape}, dtype={da.dtype}")
da.data
dims=('T', 'C', 'Z', 'Y', 'X'), shape=(5, 3, 1, 17, 13), dtype=uint16
Array Chunk
Bytes 6.47 kiB 442 B
Shape (5, 3, 1, 17, 13) (1, 1, 1, 17, 13)
Dask graph 15 chunks in 54 graph layers
Data type uint16 numpy.ndarray
3 5 13 17 1
# Access OME metadata from attrs
da.attrs["ome_metadata"].images[0].pixels.channels
[Channel(
    id='Channel:0:0',
    light_source_settings={'id': 'LightSource:b3870238-479a-45b4-8627-ddb41680e7be_1', 'attenuation': 0.9, 'wavelength': 482.0},
    detector_settings={'id': 'Detector:ec659ce6-9847-4cab-b64a-43b5e9d88f63', 'gain': 5.0, 'binning': <Binning.ONEBYONE: '1x1'>},
    light_path={'excitation_filters': [{'id': 'Filter:cb860710-71c0-46ee-8903-f20019f4b54e'}, {'id': 'Filter:cc5646d0-d3e9-42b6-a147-ad2bda838adf'}, {'id': 'Filter:058c011a-165c-4b82-9cf1-62af018e70d1'}]},
    samples_per_pixel=1,
    illumination_type='Epifluorescence',
    acquisition_mode='WideField',
 ),
 Channel(
    id='Channel:0:1',
    light_source_settings={'id': 'LightSource:b3870238-479a-45b4-8627-ddb41680e7be_2', 'attenuation': 0.9, 'wavelength': 563.0},
    detector_settings={'id': 'Detector:ec659ce6-9847-4cab-b64a-43b5e9d88f63', 'gain': 5.0, 'binning': <Binning.ONEBYONE: '1x1'>},
    light_path={'excitation_filters': [{'id': 'Filter:cb860710-71c0-46ee-8903-f20019f4b54e'}, {'id': 'Filter:cc5646d0-d3e9-42b6-a147-ad2bda838adf'}, {'id': 'Filter:058c011a-165c-4b82-9cf1-62af018e70d1'}]},
    samples_per_pixel=1,
    illumination_type='Epifluorescence',
    acquisition_mode='WideField',
 ),
 Channel(
    id='Channel:0:2',
    light_source_settings={'id': 'LightSource:b3870238-479a-45b4-8627-ddb41680e7be_4', 'attenuation': 0.9, 'wavelength': 458.0},
    detector_settings={'id': 'Detector:ec659ce6-9847-4cab-b64a-43b5e9d88f63', 'gain': 5.0, 'binning': <Binning.ONEBYONE: '1x1'>},
    light_path={'excitation_filters': [{'id': 'Filter:cb860710-71c0-46ee-8903-f20019f4b54e'}, {'id': 'Filter:cc5646d0-d3e9-42b6-a147-ad2bda838adf'}, {'id': 'Filter:058c011a-165c-4b82-9cf1-62af018e70d1'}]},
    samples_per_pixel=1,
    illumination_type='Epifluorescence',
    acquisition_mode='WideField',
 )]
# Structured metadata (consolidated from OME)
md = da.attrs["metadata"]
print(md)
print()
print(f"Objective: {md.objective[0]}")
print(f"Pixel size: {md.voxel_size[0]}")
print(f"Date: {md.date[0]}")
print()
for ci, ch in enumerate(md.channels[0]):
    print(
        f"Ch[{ci}]: wavelength={ch.wavelength}nm, "
        f"attenuation={ch.attenuation}, "
        f"exposure={ch.exposure}s, "
        f"gain={ch.gain}, "
        f"binning={ch.binning}"
    )
Metadata(S=1, T=[5], C=[3], Z=[1], Y=[17], X=[13]
  bits=[16], obj=['Objective:60XWater:f3efd78d-8646-4017-b655-b9b63e83d037']
  voxel_size=[VoxelSize(x=0.1364024, y=0.1364024, z=1000.0)]
  channels=
[[Channel(λ=482, att=0.9, exp=0.045, gain=5.0, binning=1x1),
  Channel(λ=563, att=0.9, exp=0.125, gain=5.0, binning=1x1),
  Channel(λ=458, att=0.9, exp=0.25, gain=5.0, binning=1x1)]])

Objective: Objective:60XWater:f3efd78d-8646-4017-b655-b9b63e83d037
Pixel size: VoxelSize(x=0.1364024, y=0.1364024, z=1000.0)
Date: 2016-09-01 10:49:21

Ch[0]: wavelength=482nm, attenuation=0.9, exposure=0.045s, gain=5.0, binning=1x1
Ch[1]: wavelength=563nm, attenuation=0.9, exposure=0.125s, gain=5.0, binning=1x1
Ch[2]: wavelength=458nm, attenuation=0.9, exposure=0.25s, gain=5.0, binning=1x1
# tifffile comparison
with tifffile.TiffFile(tdata / "im1s1z3c5t_a.ome.tif") as tif:
    print(f"Series: {len(tif.series)}")
    s = tif.series[0]
    print(f"shape={s.shape}, axes={s.axes}, dtype={s.dtype}")
    print(f"OME: {tif.is_ome}")
Series: 1
shape=(5, 3, 17, 13), axes=TCYX, dtype=uint16
OME: True

1.2.1. OME Metadata#

Access the full OME metadata object via bioio.

from bioio import BioImage

img = BioImage(tdata / "im1s1z3c5t_a.ome.tif")
ome = img.ome_metadata
px = ome.images[0].pixels
print(f"Image: {ome.images[0].name or ome.images[0].id}")
print(
    f"Dims: X={px.size_x}, Y={px.size_y}, C={px.size_c}, T={px.size_t}, Z={px.size_z}"
)
print(f"Pixel sizes: {img.physical_pixel_sizes}")
print("Channels:")
for ci, ch in enumerate(px.channels):
    ls = ch.light_source_settings
    wl = ls.wavelength if ls else None
    print(f"  [{ci}] id={ch.id}, wavelength={wl}")
Image: Image:0
Dims: X=13, Y=17, C=3, T=5, Z=1
Pixel sizes: PhysicalPixelSizes(Z=1000.0, Y=0.1364024, X=0.1364024)
Channels:
  [0] id=Channel:0:0, wavelength=482.0
  [1] id=Channel:0:1, wavelength=563.0
  [2] id=Channel:0:2, wavelength=458.0

1.2.2. Per-channel acquisition settings#

Exposure time and timestamps live in planes (one per T/C/Z combination). Channel-level settings (wavelength, attenuation, binning, gain) are on the Channel object. Combine both for a complete per-channel summary.

# Per-channel acquisition summary
for ci, ch in enumerate(px.channels):
    ls = ch.light_source_settings
    ds = ch.detector_settings
    # Exposure from the first plane of this channel
    plane = next(p for p in px.planes if p.the_c == ci)
    print(
        f"Ch[{ci}]: "
        f"wl={ls.wavelength if ls else None}, "
        f"att={ls.attenuation if ls else None}, "
        f"exposure={plane.exposure_time}, "
        f"binning={ds.binning if ds else None}, "
        f"gain={ds.gain if ds else None}"
    )
Ch[0]: wl=482.0, att=0.9, exposure=0.045, binning=Binning.ONEBYONE, gain=5.0
Ch[1]: wl=563.0, att=0.9, exposure=0.125, binning=Binning.ONEBYONE, gain=5.0
Ch[2]: wl=458.0, att=0.9, exposure=0.25, binning=Binning.ONEBYONE, gain=5.0

1.2.3. Channel naming#

Assign semantic channel names for ratio analysis.

da_named = ir.read_image(
    str(tdata / "im1s1z3c5t_a.ome.tif"),
    channels=["G", "R", "C"],
)
print(f"Channel coords: {list(da_named.coords['C'].values)}")
da_named.sel(C="G", T=0).data
Channel coords: [np.str_('G'), np.str_('R'), np.str_('C')]
Array Chunk
Bytes 442 B 442 B
Shape (1, 17, 13) (1, 17, 13)
Dask graph 1 chunks in 55 graph layers
Data type uint16 numpy.ndarray
13 17 1

1.3. 2. Multi-channel time-series OME-TIFF#

A 7-timepoint, 3-channel image without wavelength metadata.

da_mcts = ir.read_image(str(tdata / "multi-channel-time-series.ome.tif"))
print(f"dims={da_mcts.dims}, shape={da_mcts.shape}, dtype={da_mcts.dtype}")

img_mcts = BioImage(tdata / "multi-channel-time-series.ome.tif")
print(f"Channel names: {img_mcts.channel_names}")
print(f"Pixel sizes: {img_mcts.physical_pixel_sizes}")
print(f"Image name: {img_mcts.ome_metadata.images[0].name}")
dims=('T', 'C', 'Z', 'Y', 'X'), shape=(7, 3, 1, 167, 439), dtype=int8
Channel names: [np.str_('Channel:0:0'), np.str_('Channel:0:1'), np.str_('Channel:0:2')]
Pixel sizes: PhysicalPixelSizes(Z=None, Y=None, X=None)
Image name: multi-channel-time-series

1.4. 3. File sequences with tifffile.TiffSequence#

For sets of related TIFF files, tifffile.TiffSequence stacks them. This is useful when acquisitions split across multiple files.

fp_glob = str(tdata / "im1s1z3c5t_?.ome.tif")

tifs = tifffile.TiffSequence(fp_glob)
d = tifs.asarray()
print(f"Glob matched {len(tifs)} files")
print(f"Stacked shape: {d.shape}")
print("Individual files:")
for f in sorted(tifs):
    print(f"  {Path(f).name}")
Glob matched 2 files
Stacked shape: (2, 5, 3, 17, 13)
Individual files:
  im1s1z3c5t_a.ome.tif
  im1s1z3c5t_b.ome.tif

1.5. 4. Tiled images (FEI multi-scene)#

FEI microscopes save tiled acquisitions as multi-scene OME-TIFFs. Each scene is one tile with stage position metadata.

1.5.1. 4a. Regular tile grid (t4_1.tif)#

# bioio sees each tile as a separate scene
img_tile = BioImage(tdata / "t4_1.tif")
print(f"Scenes: {len(img_tile.scenes)}")
print(f"Per-tile shape: {img_tile.shape}")
print(f"Pixel sizes: {img_tile.physical_pixel_sizes}")

# tifffile comparison
with tifffile.TiffFile(tdata / "t4_1.tif") as tif:
    print(f"\ntifffile series: {len(tif.series)}")
    print(f"Per-series shape: {tif.series[0].shape}, axes={tif.series[0].axes}")
Scenes: 15
Per-tile shape: (3, 4, 1, 256, 512)
Pixel sizes: PhysicalPixelSizes(Z=1000.0, Y=0.1333333, X=0.1333333)

tifffile series: 15
Per-series shape: (3, 4, 256, 512), axes=TCYX
# Stitch into a single DataArray
stitched = ir.stitch_scenes(str(tdata / "t4_1.tif"))
print(f"Stitched: dims={stitched.dims}, shape=T{stitched.sizes['T']}")
print(f"  Y={stitched.sizes['Y']}, X={stitched.sizes['X']}")
stitched.data
Stitched: dims=('T', 'C', 'Z', 'Y', 'X'), shape=T3
  Y=1280, X=1536
Array Chunk
Bytes 45.00 MiB 256.00 kiB
Shape (3, 4, 1, 1280, 1536) (1, 1, 1, 256, 512)
Dask graph 180 chunks in 651 graph layers
Data type uint16 numpy.ndarray
4 3 1536 1280 1
# Tilemap shows scene layout (row, col) -> scene_index
import numpy as np

tilemap = stitched.attrs["tilemap"]
print(f"Tile grid: {tilemap.shape[0]} rows x {tilemap.shape[1]} cols")
print(tilemap)
Tile grid: 5 rows x 3 cols
[[ 0  1  2]
 [ 5  4  3]
 [ 6  7  8]
 [11 10  9]
 [12 13 14]]

1.5.2. 4b. Tile grid with void tiles (tile6_1.tif)#

stitched_void = ir.stitch_scenes(str(tdata / "tile6_1.tif"))
print(f"Stitched: Y={stitched_void.sizes['Y']}, X={stitched_void.sizes['X']}")

tilemap_void = stitched_void.attrs["tilemap"]
print(f"Tile grid ({tilemap_void.shape}):")
print(tilemap_void)
print(f"Void tiles (=-1): {np.sum(tilemap_void == -1)}")
Stitched: Y=2560, X=2560
Tile grid ((5, 5)):
[[-1 -1  6  4 -1]
 [-1  7  5  3  8]
 [13  2 10  9 -1]
 [12  1 11 -1 -1]
 [-1  0 -1 -1 -1]]
Void tiles (=-1): 11
plt.imshow(stitched_void.sel(T=1, Z=0, C=1), cmap="Reds", vmax=1000, vmin=1)
plt.colorbar()
<matplotlib.colorbar.Colorbar at 0x7f621b32f230>
../_images/4d50df9a4bd09a3ddb38e4b15bc89ffbb1a45386eb62e74513f3f1d668c6d407.png

1.5.3. Stage positions#

OME metadata provides physical stage positions for each tile.

ome_tile = BioImage(tdata / "tile6_1.tif").ome_metadata
print(f"{'Scene':>5} {'X pos':>10} {'Y pos':>10}")
for i, im in enumerate(ome_tile.images):
    p = im.pixels.planes[0]
    print(f"{i:5d} {float(p.position_x):10.2f} {float(p.position_y):10.2f}")
Scene      X pos      Y pos
    0      50.63      81.84
    1      50.63      81.73
    2      50.63      81.63
    3      50.83      81.53
    4      50.83      81.43
    5      50.73      81.53
    6      50.73      81.43
    7      50.63      81.53
    8      50.93      81.53
    9      50.83      81.63
   10      50.73      81.63
   11      50.73      81.73
   12      50.52      81.73
   13      50.52      81.63

1.6. 5. TF8 format (.tf8)#

TF8 files are TIFFs with a non-standard extension. nima_io handles this transparently via a temp symlink.

da_tf8 = ir.read_image(str(tdata / "LC26GFP_1.tf8"))
print(f"dims={da_tf8.dims}, shape={da_tf8.shape}, dtype={da_tf8.dtype}")
cjdk: Installing JDK zulu-jre:11.0.31 to /home/runner/.cache/cjdk

Download   0% of  41.2 MiB |             | Elapsed Time: 0:00:00 ETA:  --:--:--
Download  17% of  41.2 MiB |##           | Elapsed Time: 0:00:00 ETA:   0:00:00
Download  41% of  41.2 MiB |#####        | Elapsed Time: 0:00:00 ETA:   0:00:00
Download  63% of  41.2 MiB |########     | Elapsed Time: 0:00:00 ETA:   0:00:00
Download  79% of  41.2 MiB |##########   | Elapsed Time: 0:00:00 ETA:   0:00:00
Download  87% of  41.2 MiB |###########  | Elapsed Time: 0:00:00 ETA:   0:00:00
Download  93% of  41.2 MiB |############ | Elapsed Time: 0:00:00 ETA:   0:00:00
Download 100% of  41.2 MiB |#############| Elapsed Time: 0:00:00 Time:  0:00:00

Extract / |#                                          | 0 Elapsed Time: 0:00:00
Extract - | #                                        | 30 Elapsed Time: 0:00:00
Extract \ |     #                                    | 35 Elapsed Time: 0:00:00
Extract | |      #                                  | 371 Elapsed Time: 0:00:00

jar tool not found at /home/runner/.cache/cjdk/v0/jdks/8bc86269ad2ce872318e873c2d014995f0e08210/zulu11.88.17-ca-jre11.0.31-linux_x64/bin/jar
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:6272fd82-9593-4afe-a826-6d427c2e8771' to 'LightSource:0'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
dims=('T', 'C', 'Z', 'Y', 'X'), shape=(1, 1, 1, 1200, 1600), dtype=uint16

1.7. 6. Large single-scene image (exp2_2.tif)#

81 timepoints, 2 channels (340nm/380nm ratiometric), 1200x1600 pixels.

img_exp = BioImage(tdata / "exp2_2.tif")
print(f"Shape: {img_exp.shape}")
print(f"Pixel sizes: {img_exp.physical_pixel_sizes}")

ome_exp = img_exp.ome_metadata
px_exp = ome_exp.images[0].pixels
for ci, ch in enumerate(px_exp.channels):
    ls = ch.light_source_settings
    wl = ls.wavelength if ls else None
    print(f"  Ch[{ci}]: wavelength={wl}")

# Instrument metadata
if ome_exp.instruments:
    inst = ome_exp.instruments[0]
    if inst.objectives:
        obj = inst.objectives[0]
        print(f"Objective: NA={obj.lens_na}, mag={obj.nominal_magnification}")
    if inst.detectors:
        print(f"Detector: {inst.detectors[0].model}")
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:e9ce7657-3552-460a-bc05-f6fc83991d36' to 'LightSource:1'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:e9ce7657-3552-460a-bc05-f6fc83991d36' to 'LightSource:2'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:e9ce7657-3552-460a-bc05-f6fc83991d36' to 'LightSource:3'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
Shape: (81, 2, 1, 1200, 1600)
Pixel sizes: PhysicalPixelSizes(Z=1000.0, Y=0.74, X=0.74)
  Ch[0]: wavelength=380.0
  Ch[1]: wavelength=340.0
Objective: NA=0.3, mag=10.0
Detector: QImaging Retiga 2000DC
# Lazy read - no data loaded until .values or .compute()
da_exp = ir.read_image(str(tdata / "exp2_2.tif"))
print(f"Lazy DataArray: {da_exp.dims}, {da_exp.shape}")
print(f"Dask chunks: {da_exp.data.chunks}")
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:e9ce7657-3552-460a-bc05-f6fc83991d36' to 'LightSource:4'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:e9ce7657-3552-460a-bc05-f6fc83991d36' to 'LightSource:5'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
/home/runner/work/nima_io/nima_io/.venv/lib/python3.14/site-packages/pydantic/main.py:263: UserWarning: Casting invalid LightSourceID 'Lightsource:e9ce7657-3552-460a-bc05-f6fc83991d36' to 'LightSource:6'
  validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
Lazy DataArray: ('T', 'C', 'Z', 'Y', 'X'), (81, 2, 1, 1200, 1600)
Dask chunks: ((1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), (1, 1), (1,), (1200,), (1600,))

1.8. 7. Leica LIF files#

Multi-scene confocal Z-stacks read via bioio-lif (pure Python, no Java).

img_lif = BioImage(tdata / "2015Aug28_TransHXB2_50min+DMSO.lif")
print(f"Reader: {type(img_lif.reader).__module__}")
print(f"Scenes ({len(img_lif.scenes)}): {img_lif.scenes}")

for si, scene in enumerate(img_lif.scenes):
    img_lif.set_scene(si)
    print(
        f"  {scene}: shape={img_lif.shape}, "
        f"channels={img_lif.channel_names}, "
        f"voxel_z={img_lif.physical_pixel_sizes.Z}"
    )
Reader: bioio_lif.reader
Scenes (5): ('Series001', 'Series004', 'Series007', 'Series009', 'Series011')
  Series001: shape=(1, 3, 41, 512, 512), channels=[np.str_('Green'), np.str_('Gray'), np.str_('Red')], voxel_z=0.2964
  Series004: shape=(1, 3, 40, 512, 512), channels=[np.str_('Green'), np.str_('Gray'), np.str_('Red')], voxel_z=0.2964
  Series007: shape=(1, 3, 43, 512, 512), channels=[np.str_('Green'), np.str_('Gray'), np.str_('Red')], voxel_z=0.2964
  Series009: shape=(1, 3, 39, 512, 512), channels=[np.str_('Green'), np.str_('Gray'), np.str_('Red')], voxel_z=0.2964
  Series011: shape=(1, 3, 37, 512, 512), channels=[np.str_('Green'), np.str_('Gray'), np.str_('Red')], voxel_z=0.2964

1.9. 8. File comparison (diff)#

Compare two files for pixel-level equality.

a = str(tdata / "im1s1z3c5t_a.ome.tif")
b = str(tdata / "im1s1z3c5t_b.ome.tif")
bpix = str(tdata / "im1s1z3c5t_bpix.ome.tif")

print(f"a vs a (identical):  {ir.diff(a, a)}")
print(f"a vs b (same data):  {ir.diff(a, b)}")
print(f"a vs bpix (1px off): {ir.diff(a, bpix)}")
a vs a (identical):  True
a vs b (same data):  True
a vs bpix (1px off): False

1.10. 9. Backend comparison: nima_io vs tifffile#

Key differences between reading with nima_io (bioio) and raw tifffile.

# tifffile: raw arrays, manual dimension handling
with tifffile.TiffFile(tdata / "t4_1.tif") as tif:
    # Each series is a tile - tifffile doesn't auto-stitch
    tf_data = tif.series[0].asarray()
    print(f"tifffile single series: shape={tf_data.shape}, axes={tif.series[0].axes}")

# nima_io: auto-stitched, named dims, lazy
nio_data = ir.stitch_scenes(str(tdata / "t4_1.tif"))
print(f"nima_io stitched:     shape={dict(nio_data.sizes)}")
print(f"  lazy (dask):        {type(nio_data.data).__name__}")
tifffile single series: shape=(3, 4, 256, 512), axes=TCYX
nima_io stitched:     shape={'T': 3, 'C': 4, 'Z': 1, 'Y': 1280, 'X': 1536}
  lazy (dask):        Array
# tifffile: reading OME metadata requires manual XML parsing
with tifffile.TiffFile(tdata / "im1s1z3c5t_a.ome.tif") as tif:
    # tifffile exposes raw OME-XML string
    ome_xml = tif.ome_metadata  # raw XML string
    print(f"tifffile OME-XML: {type(ome_xml).__name__}, {len(ome_xml)} chars")

# nima_io/bioio: parsed OME object with typed attributes
img = BioImage(tdata / "im1s1z3c5t_a.ome.tif")
ome = img.ome_metadata  # ome_types.OME object
print(f"bioio OME: {type(ome).__name__}")
print(f"  images: {len(ome.images)}")
print(f"  instruments: {len(ome.instruments)}")
ch0 = ome.images[0].pixels.channels[0]
wl = ch0.light_source_settings.wavelength
print(f"  channels[0].wavelength: {wl}")
tifffile OME-XML: str, 47251 chars
bioio OME: OME
  images: 1
  instruments: 1
  channels[0].wavelength: 482.0