Skip to content

feat(lif): extract objective lens metadata into audit + OME-XML#57

Merged
ferrinm merged 1 commit into
mainfrom
feat/issue-52-lif-objective
Jul 23, 2026
Merged

feat(lif): extract objective lens metadata into audit + OME-XML#57
ferrinm merged 1 commit into
mainfrom
feat/issue-52-lif-objective

Conversation

@ferrinm

@ferrinm ferrinm commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • New stdlib-only extractor zarrmony.metadata.objective.extract_objective(scene_xml) walks <ATLConfocalSettingDefinition> and <Objective> LIF elements and merges. Returns only the keys the LIF actually surfaced (nominal_magnification, numerical_aperture, immersion, model, working_distance_um); missing individual fields are omitted, and scenes with no objective info at all yield None (so the audit omits the objective key entirely rather than persisting an empty dict).
  • Wired into _lif_scene_channels so both channel identity and objective ride one XML parse. Threaded through to:
    • attrs.zarrmony.per_scene[i].objective on the audit — a queryable structured record for downstream analysis.
    • A top-level <Instrument><Objective/></Instrument> in OME/METADATA.ome.xml plus per-image <InstrumentRef/> and <ObjectiveSettings/> — the standards-compliant surface consumed by napari, OMERO, and ome_types.from_xml.
    • Both per-scene and per-tile (lif_mosaic="per-tile") write paths participate. Plate + bf2raw are unchanged for this slice — those paths already skip LIF-specific channel extraction and are out of feat(lif): extract objective lens metadata (magnification, NA, immersion) into audit + OME-XML #52's scope.
  • Objective extraction is deliberately decoupled from the channel-count check that declines omero identity on SizeC mismatch: a scene with a garbled channel list still has a valid objective and the audit shouldn't drop it. LIF DRY (Leica's air shorthand) maps to OME's Air enum; unknown immersion strings map to Other rather than being dropped.
  • AUDIT_SCHEMA_VERSION bumped 67 (additive; consumers pinned to 6 should widen their pin).

Fail-closed hardening mirrors lif_channels / lif_tiles: billion-laughs, XXE, DOCTYPE, oversized/malformed input all yield None. Metadata never crashes a conversion.

Notes on the real LIF XML shape

The captured confocal fixture (tests/fixtures/lif_confocal_7ch.xml) surfaces the objective as attributes on <ATLConfocalSettingDefinition>ObjectiveName, NumericalAperture, Immersion, ObjectivePos, ObjectiveNumber — with no separate <Objective> element and no WorkingDistance attribute. Two consequences worth flagging vs. the issue text:

  • The bare Magnification="21" attribute on ATLConfocalSettingDefinition is total zoom (objective magnification × zoom factor), NOT the objective's nominal magnification. Recording it would silently write 21 for a 20x objective. The extractor prefers ObjectiveMag / NominalMagnification and falls back to parsing the NNx embedded in ObjectiveName — never Magnification.
  • WorkingDistance isn't present in the fixture; the extractor still handles it (_fill_from_attrs), but its unit convention isn't cross-validated. The value is taken verbatim, and the audit key is working_distance_um — consumers with a real WD-bearing LIF should double-check their instrument's convention.

Test plan

  • uv run --extra dev pytest -q → 355 passing (was 352 + 3 new wiring + 20 new extractor - 20 = net +23).
  • uv run --extra dev ruff check src tests → clean.
  • uv run --extra dev black --check src tests → clean.
  • New unit tests in tests/test_lif_objective.py (20 tests): real fixture happy path, complete/partial/absent acceptance triple from feat(lif): extract objective lens metadata (magnification, NA, immersion) into audit + OME-XML #52, <Objective> sibling shape, ATL/<Objective> merge precedence, immersion mapping (DRYAir, GLYCGlycerol, unknown → Other, empty omitted), fail-closed hardening.
  • New end-to-end wiring tests in tests/test_lif_objective_wiring.py (3 tests): each of the three acceptance cases drives convert(), asserts both the on-disk audit dict AND the parsed OME-XML instrument/objective/settings.
  • Existing test_lif_per_tile.py schema pin updated (6 → 7).
  • test_api_lif_wiring.py updated to destructure the new 3-tuple and to assert the count-mismatch case still surfaces the (decoupled) objective.

Closes #52

🤖 Generated with Claude Code

Zarrmony records nothing about the objective a scene was acquired with.
Physical pixel size lands in coordinateTransformations, but downstream
analysis routinely needs "what objective was this taken with" — a fact
LIF stores plainly in scene XML that we were throwing away.

Add stdlib-only ``metadata.objective.extract_objective(scene_xml)`` that
walks ``<ATLConfocalSettingDefinition>`` and ``<Objective>`` and merges,
returning only the keys the LIF actually surfaced (``nominal_magnification``
/ ``numerical_aperture`` / ``immersion`` / ``model`` / ``working_distance_um``).
Missing fields are omitted rather than nulled; scenes with no objective
info yield None so the audit omits the ``objective`` key entirely.
Fail-closed hardening mirrors ``lif_channels`` / ``lif_tiles`` (billion
laughs, XXE, DOCTYPE, oversized/malformed input all yield None).

Wire it into ``_lif_scene_channels`` so both extractions ride one XML parse,
and thread through to (a) ``per_scene[i].objective`` on the audit and
(b) a top-level ``<Instrument><Objective/></Instrument>`` plus per-image
``<InstrumentRef/>`` + ``<ObjectiveSettings/>`` on the OME-XML — the
standards-compliant surface napari / OMERO / ome_types.from_xml read.
Both per-scene and per-tile write paths participate.

Objective extraction is decoupled from the channel-count check that
declines omero identity on SizeC mismatch: a scene with a garbled channel
list still has a valid objective and the audit shouldn't drop it. LIF
"DRY" (Leica's air shorthand) is mapped to OME's ``Air`` enum; unknown
immersion strings map to ``Other`` rather than being dropped, since
"we saw an immersion we don't recognise" is a different signal from
"no immersion metadata".

Bump AUDIT_SCHEMA_VERSION 6 → 7 (purely additive; consumers pinned to 6
should widen their pin).

Closes #52

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ferrinm
ferrinm merged commit d1e4950 into main Jul 23, 2026
5 checks passed
@ferrinm
ferrinm deleted the feat/issue-52-lif-objective branch July 23, 2026 21:58
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.

feat(lif): extract objective lens metadata (magnification, NA, immersion) into audit + OME-XML

1 participant