Leica LIF family
Leica LAS X and LAS AF save microscope acquisitions as .lif project files; LAS X also writes the related LOF, XLIF, XLEF, XLCF and XLLF files (table below). OpenReadout returns every image in the project with its dimensions, pixel data and acquisition metadata. Derived from public files by hex dump and cross-checked against the public documentation and source of liffile (BSD-3-Clause). LOF, XLIF, XLEF, XLCF and XLLF follow liffile’s documented structure and are tested on synthetic files (crates/openreadout-lif/tests/fixtures/, written by make_fixtures.py, cross-read by liffile). XLLF folder lists and XLIF files with TIFF frames are also checked on real LAS X 3.7 exports from two depositors (figshare23522880-lasx-*, figshare30597152-lasx-*). No licensed LOF, XLEF or XLCF file from LAS X is in the test corpus. Provenance: docs/provenance/lif.md.
LIF (“Leica Image File”) is the container written by Leica LAS X / LAS AF. A file is one UTF-16 XML document describing a tree of experiments and images, followed by a sequence of raw memory blocks holding pixel data. Nothing is compressed. The same XML model is used by the rest of the family:
| extension | kind (ContainerKind / XmlKind) |
what it is | how we read it |
|---|---|---|---|
.lif |
Lif |
XML + named memory blocks | directly |
.lifext |
Lifext |
sidecar next to <stem>.lif: more images (8-bit pyramid levels, histograms) keyed by the parent image’s memory block id |
directly; from the .lif it is listed (info --view structure), checked, and referenced in extra.lifext_images |
.lof |
Lof |
one image: payload first, XML last | directly |
.xlif |
Xlif |
XML text, one image; its memory block is stored as frames in other files (.lof, or TIFF/JPEG/PNG/BMP) |
.lof and single-page TIFF frames (validated), JPEG/PNG (same rule, unvalidated); not BMP or multi-page OME/Aivia TIFF |
.xlef |
Xlef |
XML text, experiment root; references .xlif/.xlcf/.lof/.lif files relative to itself |
follows references |
.xlcf |
Xlcf |
XML text, collection (folder) of references | follows references; image names get the collection’s name as prefix |
.xllf |
Xllf |
XML text, folder view | inferred: handled like .xlcf |
Byte layout (all integers little-endian)
File header block (offset 0; LIF, LIFEXT and LOF)
| offset | size | field (our name) | value / meaning |
|---|---|---|---|
| 0 | u32 | block_marker |
always 0x70 |
| 4 | u32 | block_len |
bytes that follow this field in the block header; equals 2 * xml_len + 5 |
| 8 | u8 | text_marker |
always 0x2A |
| 9 | u32 | xml_len |
number of UTF-16 code units in the text |
| 13 | 2·xml_len |
xml_utf16 |
LIF: the XML (<LMSDataContainerHeader ...>); LIFEXT: <LMSDataContainerEnhancedHeader ...>; LOF: the literal text LMS_Object_File (LOF_TEXT) |
Memory blocks (LIF, LIFEXT: repeat until end of file, starting right after the XML)
| offset | size | field | value / meaning |
|---|---|---|---|
| 0 | u32 | block_marker |
0x70 |
| 4 | u32 | block_len |
2 * id_len + 14 (container version 2) or 2 * id_len + 10 (version 1) |
| 8 | u8 | text_marker |
0x2A |
| 9 | u64 (v2) / u32 (v1) | data_len |
size of the payload that follows the header; may be 0 |
| 17 (v2) / 13 (v1) | u8 | text_marker |
0x2A |
| 18 / 14 | u32 | id_len |
UTF-16 code units in the block id |
| 22 / 18 | 2·id_len |
block_id_utf16 |
e.g. MemBlock_86; matches Memory/@MemoryBlockID in the XML |
| header end | data_len |
payload |
raw samples, or attachment bytes |
The container version is LMSDataContainerHeader/@Version in the XML. Every LIF corpus file so far is version 2. A version-1 branch (u32 data_len) is kept for older LAS AF files and marked inferred until a version-1 corpus file confirms it. A LIFEXT header says Version="1" but its blocks use the 64-bit layout (ome-imagesc-110520-AMR1-lifext, first block MemBlock_17, 55 050 240 B).
Truncation check: walk blocks; the last block’s header + data_len must end exactly at the file size.
LOF (single object)
| offset | size | field | value / meaning |
|---|---|---|---|
| 0 | 43 | header block | text LMS_Object_File |
| 43 | u8, u32, u8, u32 | lof_versions |
0x2A, first version, 0x2A, second version |
| 53 | u8, u64 | data_len |
0x2A, payload length |
| 62 | data_len |
payload | the image’s memory block (no id; the XML’s Memory/@MemoryBlockID names it) |
62 + data_len |
13 + 2·n | XML block | 0x70 u32 0x2A u32 + UTF-16 XML. Older files carry a bare <Data><Image>… fragment; we wrap it in an element named after the file stem (liffile documents the same) |
A truncated LOF loses its XML (it comes last), so it cannot be opened; the error is corrupt_file.
XML containers (XLIF, XLEF, XLCF, XLLF)
Plain XML files, UTF-8 (with or without BOM) or UTF-16 (BOM, or <\0?\0 / \0<\0?). Root LMSDataContainerHeader, one Element:
Element @Name├── Data/Experiment | Data/Collection | Data/Image (kind: XLEF | XLCF | XLIF)├── Children/Reference @File @UUID (XLEF/XLCF/XLLF: other files, relative, URL-quoted, may use "\")└── Memory @Size @MemoryBlockID (XLIF) └── Frame|Block|… @File @Offset @Size @UUID (`FrameRef`: bytes [Offset, Offset+Size) of the block live in File)References are resolved relative to the referencing file (resolve_reference, with a case-insensitive fallback per path component), recursion is depth-limited (16) and cycle-safe. A missing reference is a check error (missing_reference), never a failure to open. XLIF frames stored in .lof files become storage segments (each frame = that LOF’s payload). A frame stored in an image file (FrameKind: Tiff, Jpeg, Png) is its decoded image: the decoded samples (little-endian) are bytes Offset … Offset + Size of the memory block (FrameDecode, decoded once on first read); an RGB image three times Size stands for a grey frame (its first sample); any other size is corrupt. LAS X keeps its memory order (blue at BytesInc 0, green 1, red 2 — ChannelTag 3, 2, 1) in the XLIF of an RGB image while the frame file is an ordinary RGB image, so a decoded RGB frame is placed by channel tag (remap): the channel tagged red receives the image’s red samples. Validated on the LAS X 3.7 exports named above (TIFF frames ..\\name.tif, URL-quoted, one folder above the LeicaMetadata/ folder that holds the .xlif/.xllf, matched case-insensitively). BMP and multi-page OME/Aivia TIFF frames make plane reads exit 6 (unsupported_frames in check). Image names: an XLEF’s children keep their own names; a collection prefixes its Element/@Name (liffile uses the same paths).
Observed values (corpus)
| file | block_len |
xml_len |
blocks |
|---|---|---|---|
ome-michael-PR2729-frameOrderCombinedScanTypes.lif |
30725 | 15360 | 2 (MemBlock_72 0 B, MemBlock_86 196608 B) |
aics-s-1-t-4-c-2-z-1.lif |
51121 | 25558 | 2 (MemBlock_296 0 B, MemBlock_312 6031936 B) |
bsst749-2a-ishi-hf-fshr-dmso.lif |
246137 | 123066 | 6 |
ome-imagesc-110520-AMR1.lif |
5663079 | 2831537 | first two: MemBlock_12 0 B, MemBlock_13 10485760 B |
ome-imagesc-110520-AMR1.lifext |
166751 | 83373 | first: MemBlock_17 55050240 B |
zenodo14976703-Convalaria-LambdaScan.lif |
129017 | 64506 | 4 (MemBlock_532 15204352 B) |
XML model (the subset we normalize)
LMSDataContainerHeader @Version (LIFEXT: LMSDataContainerEnhancedHeader)├── ChildrenOf @MemoryBlockID (LIFEXT only: the parent image's block in the .lif; its Elements follow)└── Element @Name @UniqueID ├── Data │ ├── Experiment @Path (project-level; no pixels) │ │ └── TimeStamp @HighInteger @LowInteger (Windows FILETIME halves) │ ├── Image (an image node) │ │ ├── ImageDescription │ │ │ ├── Channels/ChannelDescription @DataType @ChannelTag @Resolution @BytesInc @LUTName @Min @Max │ │ │ └── Dimensions/DimensionDescription @DimID @NumberOfElements @Origin @Length @Unit @BytesInc │ │ ├── TimeStampList @NumberOfTimeStamps (text: hex FILETIMEs, one per frame) │ │ └── Attachment @Name ... (HardwareSetting, ChannelAttachment, TileScanInfo, ImagePyramid, ...) │ └── SingleMoleculeDetection @IsImage="true" (FALCON FLIM/TCSPC element; see below) ├── Memory @Size @MemoryBlockID (which memory block holds this node's payload) └── Children/Element ... (recursion; image name path = names joined with "/")Images are listed in document order (pre-order), FLIM elements included, exactly as liffile lists them. For LIFEXT, every ChildrenOf group is walked (liffile 2026.7.14 follows only the first; oracle/gen.py walks all groups with liffile’s own iterator) and paths start with the group’s memory block id, e.g. MemBlock_16/R 1_pmd_0.
Dimension ids (DimID) and how we expose them
| id | our axis (Axis) |
exposed as |
|---|---|---|
| 1 | x |
X (BytesInc is the stride between neighbouring pixels) |
| 2 | y |
Y (row stride) |
| 3 | z |
Z |
| 4 | t |
T (Length is in seconds) |
| 5 | lambda |
channels: size_c = channels × lambda steps, channel-major (c = channel * n_lambda + lambda_index); each channel is named <LUT name> <wavelength> nm and carries emission_nm, the λ coordinate, which is the start of the detection window (zenodo14976703-Convalaria-LambdaScan: 420, 430, … nm for windows 420–440, 430–450, …); extra.lambda has count and range. Wavelength = Origin + i * Length / (n - 1) (metres). The window itself comes from the hardware setting’s LambdaDefinition/LambdaEmission (LambdaDetectionBegin, LambdaDetectionStepSize, LambdaDetectionBandWidth, LambdaDetectionStepCount) when its count and first window match the λ dimension: emission_band_start_nm/emission_band_end_nm/emission_band_center_nm and emission_range_nm (window i = [begin + i·step, begin + i·step + bandwidth]); without it the band is not reported. liffile keeps λ as its own array axis; bioio-lif 1.5.0 drops it (first plane only) |
| 6 | rotation |
separate images, one per rotation index; name suffix [rotation i], extra.split |
| 7 | xt_slices |
folded into T: t = t0 + n_T * (xt + n_XT * ts) (T innermost, then XT slices, then T slices); extra.t_folded |
| 8 | t_slices |
folded into T, outermost (see 7) |
| 9, 11, other | excitation_lambda, loop, dimN |
separate images, like rotation (first XML axis innermost when several) |
| 10 | mosaic |
stitched: one image per tile scan (see Tile scans) |
Axes 6–9 and 11 are validated only on the synthetic synthetic-dims.lif (cross-read by liffile); no licensed corpus file has them.
Physical step for a spatial axis = Length / (NumberOfElements - 1) in metres (sign may be negative for Z; we report the absolute value in µm). A step of 0, or of 1 cm or more, is reported as unknown: LAS X writes pixel indices labelled m (Length = N - 1) for FLIM result images, and Length = 0 for some Z stacks. For t, the same formula gives seconds between frames.
Channels
Resolution= bits per sample (8, 12, 16, 32, 64). Storage width isceil(Resolution / 8)bytes. When all integer channels of an image share it, it is reported asimages[].extra.bits_significant(12 for 12-bit data inuint16), which sets the saturation level ofstatsandwatch --qc.DataType0 = integer, 1 = float. So (32, 1) →float, (16, 0) →uint16, (8, 0) →uint8, and (16, 1) → IEEE half float, which we widen exactly tofloat(32-bit) on read (NaNs are quieted, as hardware and NumPy do) and flag withextra.stored_pixel_type = "float16". Seen in FLIM result images (Fast Flim, phasor maps).BytesInc= byte offset of this channel’s first sample relative to the pixel origin. Channels are plane-interleaved whenBytesIncequals the plane size, and pixel-interleaved (RGB) when it is 0, 1, 2 × sample width. Channels are ordered byBytesInc(storage order), as liffile orders them.ChannelTag: 0 = gray, 1 = red, 2 = green, 3 = blue (RGB cameras).LUTNameis the display colour; we expose it ascolorwhen it maps to a known name.
Sample address
For channel c (detector channel c / L, lambda c % L), plane (z, t, split), tile m and pixel (x, y):
block_offset + channel[c / L].BytesInc + (c % L) * inc(lambda) + z * inc(z) + fold(t) + split_offset + m * inc(mosaic) + y * inc(y) + x * inc(x)where inc(axis) is that dimension’s BytesInc, a missing axis contributes 0, fold(t) decomposes the folded T index over T / XT slices / T slices and split_offset does the same over the split axes. The block may be one byte range in one file (LIF, LIFEXT, LOF) or several (Storage segments: one per XLIF frame).
Tile scans (DimID 10)
Attachment[@Name="TileScanInfo"] holds @FlipX @FlipY @SwapXY (TileScan) and one Tile @FieldX @FieldY @PosX @PosY @PosZ per tile (TilePosition, stage positions in metres), in the same order as the mosaic axis.
We stitch every tile scan into one plane on read (mosaic.stitched_on_read = true). Placement (layout, PlacementMethod::StagePosition):
- negate
PosXifFlipX, negatePosYifFlipY; then swap the two ifSwapXY; - subtract the minimum over all tiles; divide by the X / Y pixel step in metres; round half up →
x_px,y_px; - canvas = max offset + tile size; tiles are pasted in file order, later tiles overwrite earlier ones where they overlap. No registration, no blending.
This reproduces LAS X’s own merge bit for bit for aics-tiled (165 tiles, FlipX=FlipY=SwapXY=1, 1-pixel overlaps) against aics-merged-tiles (7666 × 5622, all four channels). LAS X …_Merged images in zenodo6606445-Project007 and ome-imagesc-110520-AMR1 are registered by LAS X and do not equal a position-based placement (Project007 Cell 1: 972 × 972 by positions, 981 × 975 merged). When only one flip flag is set together with SwapXY, which image axis it reverses is inferred (flip before swap), not corroborated. bioio-lif 1.5.0 places tiles on the FieldX/FieldY grid with a one-pixel overlap instead (black-box observation).
Fallbacks, in order: when the stage positions are missing, non-finite, all equal while the field indices differ, not in metres, or give a canvas more than 64× the tiles’ area (or over 2³⁴ pixels), tiles go on the FieldX/FieldY grid (same flip/swap rule, abutting; FieldGrid); with no usable tile metadata at all (or a tile count that differs from the mosaic axis), side by side in one row (Row). LIFEXT pyramid levels have a mosaic axis but no TileScanInfo; when the .lif is next to the .lifext, they inherit the parent image’s tile positions (scaled by their own pixel step).
Per-tile access: info → images[].extra.tiles (index, x_px, y_px, field_x, field_y, position_um) and extra.mosaic_placement; info --view structure → one tile entry per tile with its byte offset (c 0, z 0, t 0), stride and fully_visible (no later tile covers it).
FALCON FLIM / TCSPC
Data/SingleMoleculeDetection[@IsImage="true"] elements (FlimInfo): Dataset/RawData/Format (e.g. LMSCOMPRESSED), Dimensions/Dimension/{DimensionIdentifier,Size} (X, Y, Z, C, M, WlEm, WlEx, S, T, L, V), VoxelSizeX/Y/Z (m), LaserPulseFrequency (Hz), ClockPeriod (s), PixelTime (s). Histogram bins per laser period = floor(1 / frequency / clock period) (liffile documents the same; 528 in zenodo13752242-FLIM250523). We list these images with their X/Y/Z/C/T geometry, pixel_type uint16 and extra.flim; reading a plane exits 6 (unsupported_feature) with a hint to read LAS X’s derived child images (Intensity, Fast Flim, phasor maps), which are ordinary images and readable. liffile does not decode the raw data either.
Acquisition metadata
- Start time: first entry of
TimeStampList: its text holds hex FILETIMEs (LAS X); when it has none, itsTimeStampchildren hold the halves (HighInteger << 32 | LowInteger, LAS AF). Checked against liffile on every development LIF: 593 images agree, none differ (2026-09-26). - Objective: first descendant of the image node carrying
@ObjectiveName(LAS X hardware settings):ObjectiveName(trimmed: LAS X pads it with a trailing blank),Magnification,NumericalAperture,Immersion(DRY→Air,OIL→Oil, the shared spelling ofbook/src/guides/metadata.md), plus@Softwareon theHardwareSettingattachment andSystemTypeName/MicroscopeModel. - LAS AF files (
bsst749-*,zenodo3382102-*) haveAttachment[@Name="HardwareSettingList"]instead: the objective is theVariantofFilterSettingRecord[@Attribute="Objective"](magnification = the number beforex,63.0x1.40→ 63), its NAFilterSettingRecord[@Attribute="NumericalAperture"], the system nameScannerSettingRecord[@Identifier="SystemType"](TCS SP5), used as the model when there is noMicroscopeModel. - Acquisition mode (
channels[].acquisition_mode, every channel of the image):Laser Scanning Confocalwhen the hardware setting holds anATLConfocalSettingDefinitionor aScannerSettingRecord[@Identifier="dblPinhole"],Widefieldwhen it holds anATLCameraSettingDefinition(AF 6000LX); absent otherwise. No corpus LIF records the user (UserManagementUserName="UserManagementFeatureInactive"is not a name). - Detection bands:
Attachment[@Name="ChannelAttachment"]/Band/Quantity— first two values are the band start/end in metres. - Tile positions:
Attachment[@Name="TileScanInfo"]/Tile @FieldX @FieldY @PosX @PosY @PosZ(metres).
Vocabulary (every public identifier in openreadout-lif must appear here)
| identifier | meaning |
|---|---|
LifReader, LifFile, LifError |
reader entry points |
ContainerHeader, block_marker, block_len, text_marker, xml_len, xml_utf16, xml_offset, container_version, lof_versions |
file header (XML offset; the two LOF version numbers) |
ContainerKind { Lif, Lifext, Lof }, kind, LOF_TEXT |
which binary member of the family a file is |
MemoryBlock, block_id, data_offset, data_len, header_offset |
memory block table |
ImageNode, name, path, unique_id, memory_block_id, memory_size, channels, dimensions, timestamps, hardware, tiles, tile_scan, flim, parent_block_id, frames |
parsed image (LIFEXT parent block; XLIF frames) |
ChannelDesc, data_type, channel_tag, resolution_bits, bytes_inc, lut_name, min, max, band_nm |
channel |
DimensionDesc, dim_id, axis, count, origin, length, unit, bytes_inc, coordinate |
dimension (coordinate(i) = origin + i · length / (count − 1)) |
Axis { X, Y, Z, T, Lambda, Rotation, XtSlices, TSlices, Mosaic, Other } |
dimension ids |
HardwareInfo, objective_name, magnification, numerical_aperture, immersion, software, system_type_name, microscope_model, acquisition_mode, lambda_windows |
acquisition settings |
LambdaWindows, begin_nm, step_nm, bandwidth_nm, step_count |
λ-scan detection windows (LambdaEmission) |
TileScan, flip_x, flip_y, swap_xy, TilePosition, field_x, field_y, pos_x, pos_y, pos_z |
tile scan orientation flags and positions |
MosaicLayout, layout, width, height, tile_width, tile_height, method, placements, overlapped_by_later, TilePlacement, index, x_px, y_px, PlacementMethod { StagePosition, FieldGrid, Row } |
stitched-plane geometry and per-tile offsets |
FlimInfo, raw_format, raw_dims, voxel_size_m, laser_pulse_frequency_hz, clock_period_s, pixel_time_s, histogram_bins, size |
FALCON FLIM/TCSPC element |
XmlContainer, XmlKind { Xlif, Xlef, Xlcf, Xllf }, references, xml, from_xml, FrameRef, file, offset, uuid, memory_frames, decode_xml_text, normalize_reference, resolve_reference, looks_like_xml_container |
XML containers, their references and XLIF frames |
FrameKind { Tiff, Jpeg, Png }, from_ext, name, FrameDecode, kind, size, remap, index_of_frame, frame_kind |
XLIF frames stored as image files, how they are decoded, and their slots in the file table |
sample_address, plane_layout, PlaneLayout, contiguous, x_inc, y_inc, base_offset |
addressing |
Storage, Segment, segments, single, file_offset, virt_offset, contiguous_len, locate, FileTable, paths, index_of |
where a memory block’s bytes live (one or more byte ranges in one or more files) |
FORMAT_ID, MAGIC, TEXT_MARKER |
constants |
LifDataset, open, path, file_len, first_block_offset, truncated_at, bad_block_at |
opened-file state and where the block walk stopped |
looks_like_lif, looks_like_lifext, looks_like_lof, parse_images, from_dim_id |
signature tests, XML walk, dimension-id mapping |
looks_like_lif, parse_images, from_dim_id |
signature test, XML walk, dimension-id mapping |
fuzz_xml_model, fuzz_sniff |
byte-slice entry points for the cargo-fuzz targets (fuzzing feature only; not a stable API) |
Performance / robustness merge (2026-09-23)
Stitched canvases retain the tile-relative allocation guard with a hard 4 GiB ceiling. XML containers retain the stricter 256 MiB capped read.
How this reader was derived, file by file: provenance log.