Source code for itasc.contact_analysis.pixel_size
"""Resolve a position's physical pixel size (µm per pixel).
Backend-only (no Qt / napari) so the standalone wheel and headless batch runs can
use it. The cell-shape build needs µm/px to turn pixel-unit ``regionprops``
measurements into physical µm / µm². Two sources are consulted, in order:
1. The position's ``itasc_config.json`` — ``metadata.pixel_size_um`` — the key
the main pipeline widget already writes (``main_widget.py``).
2. The segmented label TIFF's resolution tags — ImageJ ``unit`` + ``resolution``
or the baseline ``XResolution`` / ``ResolutionUnit`` tags.
Returns ``None`` when neither yields a positive value; callers treat that as
"pixel size unknown" (the cell-shape build is then blocked until one is set).
"""
from __future__ import annotations
import json
from pathlib import Path
import tifffile
from itasc.core.paths import position_config_path
__all__ = ["resolve_pixel_size_um", "pixel_size_from_config", "pixel_size_from_tiff"]
#: Multipliers turning a length in the tag's unit into µm.
_UNIT_TO_UM = {
"micron": 1.0,
"microns": 1.0,
"um": 1.0,
"µm": 1.0,
"micrometer": 1.0,
"micrometre": 1.0,
"mm": 1_000.0,
"millimeter": 1_000.0,
"cm": 10_000.0,
"centimeter": 10_000.0,
"inch": 25_400.0,
"in": 25_400.0,
}
#: Baseline-TIFF ``ResolutionUnit`` tag values (2 = inch, 3 = centimeter).
_RESOLUTION_UNIT_TO_UM = {2: 25_400.0, 3: 10_000.0}
[docs]
def resolve_pixel_size_um(
position_dir: Path | str | None,
cell_labels_path: Path | str | None,
) -> float | None:
"""µm/px for a position, from its config first, then the label TIFF tags.
Returns ``None`` when no positive pixel size can be found.
"""
size = pixel_size_from_config(position_dir)
if size is not None:
return size
return pixel_size_from_tiff(cell_labels_path)
[docs]
def pixel_size_from_config(position_dir: Path | str | None) -> float | None:
"""``metadata.pixel_size_um`` from ``itasc_config.json`` in *position_dir*."""
if position_dir is None:
return None
config_path = position_config_path(position_dir)
if not config_path.is_file():
return None
try:
with open(config_path) as f:
config = json.load(f)
metadata = config.get("metadata", {})
return _positive_float(metadata.get("pixel_size_um"))
except (OSError, ValueError, json.JSONDecodeError):
return None
[docs]
def pixel_size_from_tiff(cell_labels_path: Path | str | None) -> float | None:
"""µm/px from a TIFF's resolution tags (ImageJ unit, else ResolutionUnit)."""
if cell_labels_path is None:
return None
path = Path(cell_labels_path)
if not path.is_file():
return None
try:
with tifffile.TiffFile(str(path)) as tf:
from_ome = _pixel_size_from_ome(tf)
if from_ome is not None:
return from_ome
from_imagej = _pixel_size_from_imagej(tf)
if from_imagej is not None:
return from_imagej
return _pixel_size_from_baseline(tf)
except (OSError, ValueError, KeyError, tifffile.TiffFileError):
# An unreadable/empty/corrupt TIFF means "pixel size unknown", not a
# crash. tifffile raises TiffFileError (not a KeyError/OSError subclass)
# for a malformed file, so it must be named explicitly.
return None
def _pixel_size_from_ome(tf: tifffile.TiffFile) -> float | None:
"""OME-TIFF stores µm/px directly as ``Pixels/@PhysicalSizeX`` (+ unit)."""
xml = tf.ome_metadata
if not xml:
return None
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(xml)
except ET.ParseError:
return None
for el in root.iter():
if el.tag.endswith("Pixels"):
value = _positive_float(el.get("PhysicalSizeX"))
if value is None:
return None
unit = str(el.get("PhysicalSizeXUnit", "µm")).strip().lower()
to_um = _UNIT_TO_UM.get(unit)
return value * to_um if to_um is not None else None
return None
def _pixel_size_from_imagej(tf: tifffile.TiffFile) -> float | None:
"""ImageJ stores spacing as ``XResolution`` (pixels/unit) plus a ``unit``."""
metadata = tf.imagej_metadata or {}
unit = str(metadata.get("unit", "")).strip().lower()
to_um = _UNIT_TO_UM.get(unit)
if to_um is None:
return None
per_unit = _tag_ratio(tf, "XResolution")
if per_unit is None or per_unit <= 0:
return None
return to_um / per_unit
def _pixel_size_from_baseline(tf: tifffile.TiffFile) -> float | None:
"""Baseline TIFF: ``XResolution`` pixels/unit with a ``ResolutionUnit``."""
page = tf.pages[0]
unit_tag = page.tags.get("ResolutionUnit")
to_um = _RESOLUTION_UNIT_TO_UM.get(int(unit_tag.value)) if unit_tag else None
if to_um is None:
return None
per_unit = _tag_ratio(tf, "XResolution")
if per_unit is None or per_unit <= 0:
return None
return to_um / per_unit
def _tag_ratio(tf: tifffile.TiffFile, name: str) -> float | None:
"""Read a rational TIFF tag (``(num, den)`` or scalar) as a float."""
tag = tf.pages[0].tags.get(name)
if tag is None:
return None
value = tag.value
if isinstance(value, (tuple, list)) and len(value) == 2:
num, den = value
return float(num) / float(den) if den else None
try:
return float(value)
except (TypeError, ValueError):
return None
def _positive_float(value: object) -> float | None:
try:
result = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
return result if result > 0 else None