feat(lif): extract objective lens metadata into audit + OME-XML#57
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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 yieldNone(so the audit omits theobjectivekey entirely rather than persisting an empty dict)._lif_scene_channelsso both channel identity and objective ride one XML parse. Threaded through to:attrs.zarrmony.per_scene[i].objectiveon the audit — a queryable structured record for downstream analysis.<Instrument><Objective/></Instrument>inOME/METADATA.ome.xmlplus per-image<InstrumentRef/>and<ObjectiveSettings/>— the standards-compliant surface consumed by napari, OMERO, andome_types.from_xml.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.SizeCmismatch: a scene with a garbled channel list still has a valid objective and the audit shouldn't drop it. LIFDRY(Leica's air shorthand) maps to OME'sAirenum; unknown immersion strings map toOtherrather than being dropped.AUDIT_SCHEMA_VERSIONbumped6→7(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 yieldNone. 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 noWorkingDistanceattribute. Two consequences worth flagging vs. the issue text:Magnification="21"attribute onATLConfocalSettingDefinitionis total zoom (objective magnification × zoom factor), NOT the objective's nominal magnification. Recording it would silently write21for a20xobjective. The extractor prefersObjectiveMag/NominalMagnificationand falls back to parsing theNNxembedded inObjectiveName— neverMagnification.WorkingDistanceisn'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 isworking_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.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 (DRY→Air,GLYC→Glycerol, unknown →Other, empty omitted), fail-closed hardening.tests/test_lif_objective_wiring.py(3 tests): each of the three acceptance cases drivesconvert(), asserts both the on-disk audit dict AND the parsed OME-XML instrument/objective/settings.test_lif_per_tile.pyschema pin updated (6 → 7).test_api_lif_wiring.pyupdated to destructure the new 3-tuple and to assert the count-mismatch case still surfaces the (decoupled) objective.Closes #52
🤖 Generated with Claude Code