Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Per-scene objective-lens extraction for Leica LIF conversions. When a LIF
scene's XML carries objective attributes (on
`<ATLConfocalSettingDefinition>` and/or `<Objective>` elements), the audit
record now surfaces them under
`attrs.zarrmony.per_scene[i].objective` with any subset of the keys
`nominal_magnification`, `numerical_aperture`, `immersion`, `model`,
`working_distance_um`. Missing individual fields are omitted from the dict
(never `null` / `0`); scenes with no objective info at all omit the
`objective` key entirely rather than persist an empty dict. (#52)
- Per-scene `OME/METADATA.ome.xml` now emits a top-level
`<Instrument><Objective/></Instrument>` populated from the same LIF
extraction, plus per-image `<InstrumentRef/>` and `<ObjectiveSettings/>`
references so downstream tooling (napari, OMERO, `ome_types.from_xml`)
reads the objective as standards-compliant OME metadata. Non-LIF readers
and LIF scenes with no objective info emit the pre-#52 no-instrument
shape unchanged. Both per-scene and per-tile (`lif_mosaic="per-tile"`)
write paths participate. (#52)

### Changed

- `AUDIT_SCHEMA_VERSION` bumped from `6` → `7` to signal the new optional
`per_scene[i].objective` sub-dict. Reading old stores is unaffected (the
field is additive); consumers pinned to schema `6` should widen their pin.
(#52)

## [0.8.0] - 2026-07-10

### Added
Expand Down
126 changes: 99 additions & 27 deletions src/zarrmony/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,16 @@
select_auto_stitch_cascade,
stage_overlap_discrepancy,
)
from zarrmony.metadata.objective import extract_objective
from zarrmony.naming import resolve_scene_dirnames, sanitize_scene_name
from zarrmony.readers.default import derive_bioio_distribution
from zarrmony.readers.plugin import ReaderPlugin, get_reader
from zarrmony.writers.bf2raw import write_bf2raw_wrapper
from zarrmony.writers.ome_xml import (
attach_objective_to_image,
attach_stage_position_plane,
build_combined_ome_xml,
build_instrument_from_objective,
build_ome_xml_for_scene,
)
from zarrmony.writers.per_scene import write_per_scene_metadata
Expand Down Expand Up @@ -232,16 +235,33 @@ def _scene_channel_count(reader: Any) -> int:
return int(xarr.sizes["C"]) if "C" in xarr.dims else 1


def _lif_scene_channels(reader: Any) -> tuple[list[dict] | None, list[Channel] | None]:
def _lif_scene_channels(
reader: Any,
) -> tuple[list[dict] | None, list[Channel] | None, dict | None]:
"""The LIF-vs-other decision, in one place.

Returns ``(extracted, omero_channels)`` when ``reader`` is a LIF reader whose
current scene yields channel identities AND that identity count matches the
scene's C size; otherwise ``(None, None)``. ``extracted`` is the raw
per-channel identity dicts (handed to :func:`_lif_ome_image` once scene sizes
are known); ``omero_channels`` is the display label/color list for
:func:`write_scene`. Both projections come from the same vetted extraction,
so omero and the OME-XML never disagree.
Returns ``(extracted, omero_channels, objective)``:

* ``extracted`` / ``omero_channels`` — per-channel identity dicts + the
display label/color list. Both are populated only when ``reader`` is a
LIF reader whose current scene yields channel identities AND that
identity count matches the scene's C size; otherwise both are ``None``.
Both projections come from the same vetted extraction, so omero and the
OME-XML never disagree.
* ``objective`` — the LIF objective-lens dict (issue #52), independently
derived from the same scene XML. Populated whenever
:func:`~zarrmony.metadata.objective.extract_objective` returns a
non-empty dict; ``None`` for a non-LIF reader, missing scene XML, or a
scene with no objective info at all. It carries only the keys the LIF
actually surfaced — missing individual fields (magnification, NA,
immersion, model, working distance) are omitted rather than nulled. The
audit records it under ``per_scene[i].objective`` and the per-scene
writer projects it into a top-level ``<Instrument><Objective/></Instrument>``.

Objective extraction is deliberately independent of the channel count
check: a LIF scene with a garbled channel list (mismatched SizeC) still
has a valid objective, and the audit shouldn't drop it just because we
also can't determine the fluorophores. Both extractors are fail-closed.

Locating the scene XML is two-tier (see :func:`find_scene_xml`):

Expand All @@ -251,30 +271,35 @@ def _lif_scene_channels(reader: Any) -> tuple[list[dict] | None, list[Channel] |
2. the document-order ``<Image>`` locator (``.//Image[current_scene_index]``,
per bioio-lif PR #52) — the fallback that makes confocal scenes work.

The count check matters: the omero block and OME-XML ``Pixels/@SizeC`` must
describe the channels the array actually has. If extraction and data
disagree in EITHER direction — too few or too many identities (corruption,
partial metadata) — we decline the projection rather than mislabel the image.

Fail-safe: any failure — not a LIF reader, no/garbage scene XML, an empty or
count-mismatched extraction, or any unexpected reader-surface error — returns
``(None, None)`` so callers fall back cleanly to the existing name-based
path. Metadata never crashes a conversion.
The count check matters for channels: the omero block and OME-XML
``Pixels/@SizeC`` must describe the channels the array actually has. If
extraction and data disagree in EITHER direction — too few or too many
identities (corruption, partial metadata) — we decline the *channel*
projection rather than mislabel the image. Objective extraction has no
such coupling.

Fail-safe: any failure — not a LIF reader, no/garbage scene XML, or any
unexpected reader-surface error — returns ``(None, None, None)`` so
callers fall back cleanly to the existing name-based path. Metadata never
crashes a conversion.
"""
try:
scene_xml = find_scene_xml(reader)
if scene_xml is None:
return None, None
return None, None, None
extracted = extract_channels(scene_xml)
objective = extract_objective(scene_xml)
if not extracted or len(extracted) != _scene_channel_count(reader):
return None, None
# Channel extraction fell through the count check, but the
# objective is decoupled from SizeC — still surface it.
return None, None, objective
omero_channels = [
Channel(label=o["label"], color=o["color"])
for o in channels_to_omero(extracted)
]
return extracted, omero_channels
return extracted, omero_channels, objective
except Exception: # noqa: BLE001 — never break a conversion over metadata
return None, None
return None, None, None


def _lif_ome_image(
Expand All @@ -300,6 +325,34 @@ def _lif_ome_image(
return None


def _instrument_for_objective(
objective: dict | None, image: Image | None, scene_index: int
) -> Any | None:
"""Build the per-image ``<Instrument>`` from ``objective`` and attach refs.

Returns the ``Instrument`` (for the caller to hand to
:func:`build_ome_xml_for_scene`) when ``objective`` is non-empty and
``image`` is present; otherwise ``None`` — the pre-#52 shape. Scoped IDs
include ``scene_index`` so a bf2raw / plate consumer parsing a combined
OME-XML sees distinct instruments per image.

Never raises: objective is metadata, and metadata never breaks a
conversion.
"""
if not objective or image is None:
return None
try:
instrument = build_instrument_from_objective(
objective,
instrument_id=f"Instrument:{scene_index}",
objective_id=f"Objective:{scene_index}:0",
)
attach_objective_to_image(image, instrument=instrument)
return instrument
except Exception: # noqa: BLE001 — never break a conversion over metadata
return None


def _source_xml_filename(input_path: str | Path) -> str:
ext = Path(str(input_path)).suffix.lstrip(".").lower()
return f"raw.{ext}.xml" if ext else "raw.xml"
Expand Down Expand Up @@ -783,8 +836,10 @@ def _convert_per_scene(
# LIF-vs-other decision in one place: for a LIF scene with extractable
# channel identities, drive both omero (label/color) and the canonical
# OME-XML <Channel>s from that identity; otherwise fall through to the
# existing name-based path. (None, None) for everything non-LIF.
lif_extracted, lif_omero_channels = _lif_scene_channels(reader)
# existing name-based path. (None, None, None) for everything non-LIF.
# ``lif_objective`` is decoupled from the channel-count check so a
# scene with a garbled channel list still surfaces its objective.
lif_extracted, lif_omero_channels, lif_objective = _lif_scene_channels(reader)
channels = (
lif_omero_channels
if lif_omero_channels is not None
Expand Down Expand Up @@ -861,7 +916,15 @@ def _convert_per_scene(
)
ome_image = _stub_image(scene_index, scene_name, scene_record)

ome_xml = build_ome_xml_for_scene(ome_image)
# LIF objective (issue #52): audit gets the raw dict; OME-XML gets a
# top-level <Instrument><Objective/></Instrument> plus a per-image
# <ObjectiveSettings/> reference. Both are omitted for non-LIF readers
# and for LIF scenes that surfaced no objective info at all.
if lif_objective:
scene_record["objective"] = lif_objective
instrument = _instrument_for_objective(lif_objective, ome_image, scene_index)

ome_xml = build_ome_xml_for_scene(ome_image, instrument)
write_per_scene_metadata(
store_path,
ome_xml=ome_xml,
Expand Down Expand Up @@ -996,8 +1059,10 @@ def _convert_per_tile_scene(

# Channel projection: as in per-scene mode, the LIF extracted identity wins;
# otherwise fall through to the name-based path. Same channels for every
# tile of the same scene.
lif_extracted, lif_omero_channels = _lif_scene_channels(reader)
# tile of the same scene. Objective is likewise scene-scoped (one
# objective per scene, shared by all tiles) and re-attached to each
# tile's OME-XML below.
lif_extracted, lif_omero_channels, lif_objective = _lif_scene_channels(reader)
channels = (
lif_omero_channels
if lif_omero_channels is not None
Expand Down Expand Up @@ -1078,7 +1143,14 @@ def _convert_per_tile_scene(
position_z_um=pos_z_um,
)

ome_xml = build_ome_xml_for_scene(ome_image)
# LIF objective (issue #52): the scene's objective is shared across
# all tiles; every tile store gets its own audit copy and its own
# <Instrument> so any tile store remains fully self-describing.
if lif_objective:
scene_record["objective"] = lif_objective
instrument = _instrument_for_objective(lif_objective, ome_image, scene_index)

ome_xml = build_ome_xml_for_scene(ome_image, instrument)
write_per_scene_metadata(
store_path,
ome_xml=ome_xml,
Expand Down
13 changes: 10 additions & 3 deletions src/zarrmony/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
recording: zarrmony version, the winning reader plugin (name, distribution,
source, version, match score), the input file's path / size / mtime / optional
SHA256, the conversion config the user passed, started/finished timestamps,
per-scene records returned by ``write_scene``, and any extractor-failure
warnings.
per-scene records returned by ``write_scene`` (which for LIF conversions may
carry an ``objective`` sub-dict with ``nominal_magnification`` /
``numerical_aperture`` / ``immersion`` / ``model`` / ``working_distance_um``),
and any extractor-failure warnings.

Stored as a top-level ``attrs.zarrmony`` (not under ``attrs.ome``) to keep the
spec-defined namespace clean. ``audit_schema_version`` is bumped whenever this
Expand All @@ -25,7 +27,12 @@
from zarrmony._storage import format_bytes, open_root_group, size_on_disk
from zarrmony.readers.plugin import ReaderPlugin

AUDIT_SCHEMA_VERSION = 6
# 7: adds optional ``per_scene[i].objective`` (nominal_magnification /
# numerical_aperture / immersion / model / working_distance_um) from the
# LIF objective-lens extractor. Missing fields are omitted; scenes with no
# objective info omit the ``objective`` key entirely. Purely additive:
# consumers pinned to 6 can widen their pin. (#52)
AUDIT_SCHEMA_VERSION = 7


def _file_forensics(path: str | Path, *, checksum: bool = False) -> dict[str, Any]:
Expand Down
Loading
Loading