Skip to content

feat: preserve Leica confocal channel identity (dye, excitation, emission) in OME-Zarr#32

Merged
ferrinm merged 6 commits into
ferrinm:mainfrom
aelefebv:feat/confocal-channel-metadata
Jun 30, 2026
Merged

feat: preserve Leica confocal channel identity (dye, excitation, emission) in OME-Zarr#32
ferrinm merged 6 commits into
ferrinm:mainfrom
aelefebv:feat/confocal-channel-metadata

Conversation

@aelefebv

Copy link
Copy Markdown
Contributor

Problem

zarrmony's promise is "convert any bioimage to OME-Zarr, preserving metadata." For
Leica confocal .lif files that promise was silently broken: every channel's
fluorophore, excitation laser line, emission detection band, and detector were dropped.

Root cause is two gaps in bioio-lif, now filed + fixed upstream
(bioio-devs/bioio-lif#51, PR bioio-devs/bioio-lif#52):

  1. It names confocal channels by the bare ChannelDescription/@LUTName — a display LUT
    color ("Blue", "Gray", "Green", …), not the fluorophore.
  2. It doesn't implement ome_metadata (inherits bioio_base's NotImplementedError), so
    api._try_get_ome_image fell back to a <MetadataOnly/> stub — no OME-XML channel
    metadata at all. (CZI/ND2 plugins do implement it; LIF was the odd one out.)

The real identity is all present in the LIF XML, which we already preserve verbatim at
OME/source/raw.lif.xml. This PR recovers it.

What changed

  • src/zarrmony/metadata/lif_channels.py (new) — a pure, stdlib-only,
    dependency-free extractor:
    • extract_channels(scene_xml) → per-channel {index, dye, fluor, detector, excitation_nm, emission_low_nm, emission_high_nm}. The join is the careful part: the
      real acquisition sequences are the ATLConfocalSettingDefinition children of
      LDM_Block_Sequential/LDM_Block_Sequential_List, indexed by each channel's
      SequentialSettingIndex — which correctly ignores the duplicate …Master block and
      a phantom 620 nm setting
      that would otherwise mislabel channels. Excitation is the
      active laser line paired to the active detector by spectral order; emission is the
      detector's MultiBand window.
    • channels_to_ome_channels(...) / channels_to_omero(...) — project that identity into
      OME-XML <Channel> (Name/Fluor/ExcitationWavelength/EmissionWavelength) and omero
      Dye (NNN nm) display labels + colors.
    • Hardened: rejects DTDs/entity expansion (billion-laughs/XXE), caps input size, and
      fails closed (returns [] / falls back) on any malformed input — never raises,
      never hangs.
  • src/zarrmony/api.py — for LIF the per-scene path now builds a real OME Image
    (with channels) and meaningful omero labels instead of the stub. LIF-only (keyed on
    reader.scene_root), fail-safe (any extraction error reverts to the prior
    name-based path — metadata never breaks a conversion), and channel-count vs SizeC
    guarded
    so it can't mislabel. CZI/ND2/non-LIF paths are untouched.
  • Tests — a committed pruned LIF scene fixture (tests/fixtures/lif_confocal_7ch.xml,
    no multi-GB file needed) + extractor, projection, and api-wiring (fail-safe) suites,
    including the entity-bomb / non-plate-scene_root-raises / count-mismatch edges.
    The same extraction has been ported upstream into bioio-lif's own ome_metadata
    (Confocal channel identity: implement ome_metadata and name channels by fluorophore bioio-devs/bioio-lif#52); once that ships, this LIF-specific layer can be retired in
    favor of it. Until then, zarrmony carries it so conversions preserve the metadata today.

Verification

On a real 7-channel Leica STELLARIS confocal scene (CohortAI37), the converted store now
carries, in order: DAPI (405 nm), ALEXA 594 (590 nm), ALEXA 750 (753 nm),
ALEXA 488 (499 nm), ALEXA 647-R-PE (653 nm), ALEXA 555 (553 nm), ALEXA 700 (696 nm)
— matching the dyes/lasers/emission bands cross-checked against the acquisition metadata.
uv run ruff check / pytest / black --check . / npx prettier --check . all green.

Compatibility / scope

Additive and back-compatible: no change to CZI/ND2/non-LIF behavior, the audit schema, or
existing outputs. Out of scope (deferred): emission-derived omero colors; the bf2raw/plate
LIF paths (per-scene is the default LIF layout); widefield channel enrichment.

aelefebv added 4 commits June 25, 2026 15:34
…tation/emission/detector)

Pure stdlib, hardened (DTD/entity-bomb fail-closed), bioio-free so it can lift into
bioio-lif.ome_metadata. Resolves channels via LDM_Block_Sequential_List, dodging the
phantom 620nm + duplicate settings blocks. Slice 1 of the channel-metadata run.
…mero labels

For Leica LIF, the per-scene path now builds a real OME Image (dye name, excitation
& emission wavelengths) and omero labels like 'ALEXA 594 (590 nm)' instead of a
MetadataOnly stub. LIF-only (keyed on scene_root), fail-safe (any error falls back
to the prior name-based path), channel-count vs SizeC guarded, CZI/ND2 untouched.
Slice 2 of the channel-metadata run.
@aelefebv

Copy link
Copy Markdown
Contributor Author

Note: collapse the in-tree extractor onto bioio-lif once #52 lands

This PR deliberately carries an in-tree, temporary copy of the confocal channel extractor (src/zarrmony/metadata/lif_channels.py) plus bespoke per-scene wiring in api.py (_lif_scene_channels / _lif_ome_image). It exists only because released bioio-lif (1.4.2) does not implement Reader.ome_metadata — it raises NotImplementedError — so zarrmony has to extract channel identity itself for now.

The proper home is bioio-devs/bioio-lif#52 ("implement ome_metadata and name channels by fluorophore"), which contains the same extractor and locates each scene's XML correctly via self.metadata.findall(".//Image")[self.current_scene_index].

Once #52 is merged and released, this PR's interim layer should be removed:

  1. Bump the bioio-lif minimum pin to the release that includes feat(lif): extract objective lens metadata (magnification, NA, immersion) into audit + OME-XML #52.
  2. Delete the in-tree extractor metadata/lif_channels.py and the bespoke wiring (_lif_scene_channels, _lif_ome_image) in api.py.
  3. Build the per-scene OME Image from reader.ome_metadata directly, keeping the existing fail-safe fallback and the channel-count-vs-SizeC guard.
  4. Drop the tests that exercise the in-tree copy; keep one integration test asserting channels still surface via the delegated path.

Interim correctness fix (this branch): the wiring originally located the scene via reader.scene_root, which is bioio-lif's plate row/column locator and raises ValueError on ordinary confocal scenes — silently disabling the whole feature for the confocal .lif files it targets (output fell back to LUT-color labels + a MetadataOnly stub). This branch fixes that by aligning the scene-location with #52's .//Image[current_scene_index] approach, so the two implementations stay consistent until the delete above.

@aelefebv
aelefebv marked this pull request as draft June 26, 2026 21:12
…e scenes

_lif_scene_channels located each scene's settings XML via reader.scene_root,
bioio-lif's plate row/column locator, which raises ValueError for ordinary
non-plate confocal scenes — so the confocal channel-metadata feature was
silently inert (output fell back to display-LUT labels like Blue/Gray and a
MetadataOnly OME-XML stub).

Locate the current scene via reader.metadata.findall(".//Image")[current_scene_index]
(aligned with bioio-lif PR ferrinm#52), keeping reader.scene_root as a plate fast-path.
Every fail-safe is preserved: the channel-count-vs-SizeC guard (both directions)
and a clean (None, None) decline on any unexpected reader-surface error, so
metadata never crashes a conversion.

Tests: confocal-path regression cases folded into test_api_lif_wiring (real
captured fixture, no bioio_lif import / no large file, CI-safe), with the seam's
docstring updated for the new two-tier locator. CHANGELOG: Unreleased entry for
the confocal channel-identity feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aelefebv
aelefebv force-pushed the feat/confocal-channel-metadata branch from 9a620ab to 8bd694a Compare June 26, 2026 22:21
Resolves the CHANGELOG [Unreleased] conflict by keeping both the confocal
channel-identity entry and main's size/OME-NGFF-validation entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aelefebv
aelefebv marked this pull request as ready for review June 26, 2026 22:29
@ferrinm
ferrinm merged commit e40c566 into ferrinm:main Jun 30, 2026
5 checks passed
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.

2 participants