Source code for acia.segm.tiff_metadata

"""Best-effort calibration reader for OME-TIFF / ImageJ-hyperstack metadata.

This is the inverse of :mod:`acia.segm.tiff_export`'s calibration tags: it reads
back the ``PhysicalSizeX``/``TimeIncrement`` (OME) or ``unit``/``finterval``
(ImageJ) tags that module writes, so acia's own OME/ImageJ TIFF exports round
-trip through it, and any scope-native OME-TIFF or ImageJ hyperstack benefits
too. Reads headers only -- never the pixel array. Missing calibration is
simply omitted, never fabricated (same discipline as the writer side).
"""

from __future__ import annotations

import os
from dataclasses import dataclass

import fsspec
import numpy as np
import tifffile

from acia import Q_
from acia.config import resolve_storage_options


[docs] @dataclass(frozen=True) class TiffCalibration: """Best-effort physical calibration read from a TIFF's own metadata.""" pixel_size: object | None # pint Quantity (length/px) or None frame_interval: object | None # pint Quantity (time) or None timepoints: object | None # pint Quantity array (time, per-frame) or None source: str | None # "ome" | "imagej" | None (where the values came from)
_NO_CALIBRATION = TiffCalibration( pixel_size=None, frame_interval=None, timepoints=None, source=None )
[docs] def read_tiff_calibration( path: str | os.PathLike, storage_options: dict | None = None ) -> TiffCalibration: """Read pixel size / frame timing from a TIFF's OME-XML or ImageJ metadata. Reads only the file's headers/metadata (no pixel array). OME-XML is tried first; if the file is not an OME-TIFF (or nothing usable is found there), ImageJ hyperstack tags are tried next. If neither is present, every field is ``None``. Args: path: Path to the ``.tif``/``.tiff`` file. May be a plain local path or any fsspec-supported URL (e.g. ``smb://``). storage_options: extra fsspec storage options (e.g. credentials), resolved the same way as :class:`~acia.segm.local.LocalSequenceSource`. Returns: TiffCalibration: best-effort calibration; missing values are ``None``. """ path = os.fspath(path) opts = resolve_storage_options(path, storage_options) with fsspec.open(path, mode="rb", **opts) as f, tifffile.TiffFile(f) as tf: if tf.is_ome: cal = _read_ome_calibration(tf) if cal is not None: return cal if tf.is_imagej: cal = _read_imagej_calibration(tf) if cal is not None: return cal return _NO_CALIBRATION
def _to_quantity(value, unit: str | None): """Best-effort ``Q_(value, unit)``; ``None`` if the value/unit is unusable.""" if value is None or unit is None: return None try: return Q_(float(value), unit) except Exception: # noqa: BLE001 - calibration is best-effort, never fatal return None def _first(value): """OME dicts collapse repeated elements to a bare dict; multiple ones to a list. Normalize both to "the first entry" for the single-image case.""" if isinstance(value, list): return value[0] if value else None return value def _read_ome_calibration(tf) -> TiffCalibration | None: """Parse OME-XML for ``PhysicalSizeX``/``TimeIncrement`` (+ per-plane ``DeltaT``).""" try: meta = tifffile.xml2dict(tf.ome_metadata) image = _first(meta["OME"]["Image"]) pixels = image["Pixels"] except Exception: # noqa: BLE001 - unparsable/unexpected OME-XML shape return None pixel_size = _to_quantity( pixels.get("PhysicalSizeX"), pixels.get("PhysicalSizeXUnit") ) frame_interval = _to_quantity( pixels.get("TimeIncrement"), pixels.get("TimeIncrementUnit") ) timepoints = _read_ome_plane_timepoints(pixels) if pixel_size is None and frame_interval is None and timepoints is None: return None return TiffCalibration( pixel_size=pixel_size, frame_interval=frame_interval, timepoints=timepoints, source="ome", ) def _read_ome_plane_timepoints(pixels): """Best-effort per-frame ``DeltaT`` array from ``<Plane>`` elements, else ``None``.""" planes = pixels.get("Plane") if not planes: return None if isinstance(planes, dict): planes = [planes] size_t = pixels.get("SizeT") by_frame: dict[int, float] = {} unit = None for plane in planes: delta_t = plane.get("DeltaT") the_t = plane.get("TheT") if delta_t is None or the_t is None: continue # channel/z planes repeat the same T -- keep the first DeltaT seen per frame by_frame.setdefault(int(the_t), float(delta_t)) unit = unit or plane.get("DeltaTUnit") if not by_frame or unit is None: return None n = int(size_t) if size_t is not None else max(by_frame) + 1 if len(by_frame) != n or any(t not in by_frame for t in range(n)): return None # incomplete -- don't fabricate the missing frames try: return Q_(np.array([by_frame[t] for t in range(n)]), unit) except Exception: # noqa: BLE001 - calibration is best-effort, never fatal return None def _read_imagej_calibration(tf) -> TiffCalibration | None: """Parse ImageJ hyperstack ``unit``/``finterval`` + resolution tags.""" ij_meta = tf.imagej_metadata or {} unit = ij_meta.get("unit") pixel_size = None try: tags = tf.pages[0].tags num, den = tags["XResolution"].value if num > 0 and den > 0: pixels_per_unit = num / den if pixels_per_unit > 0: pixel_size = _to_quantity(1.0 / pixels_per_unit, unit) except Exception: # noqa: BLE001 - resolution tag is optional/best-effort pass frame_interval = _to_quantity(ij_meta.get("finterval"), "second") if pixel_size is None and frame_interval is None: return None return TiffCalibration( pixel_size=pixel_size, frame_interval=frame_interval, timepoints=None, source="imagej", )