Source code for itasc.contact_analysis.frame_interval
"""Resolve a position's frame interval (seconds per frame).
The time twin of :mod:`.pixel_size`. Track dynamics needs the real elapsed time
between frames to turn pixel displacements into µm/s, MSD into µm²/s, and lags
into seconds. Backend-only (no Qt / napari) so the standalone wheel and headless
batch runs can use it. Two sources are consulted, in order:
1. The position's ``itasc_config.json`` — ``metadata.time_interval_s`` — the
key the main pipeline widget already writes (``main_widget.py``).
2. The label TIFF's ImageJ ``finterval`` tag (seconds between frames), the
convention ImageJ/Fiji writes for time-lapse stacks.
Returns ``None`` when neither yields a positive value; callers treat that as
"interval unknown" (the dynamics 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_time_interval_s",
"time_interval_from_config",
"time_interval_from_tiff",
]
[docs]
def resolve_time_interval_s(
position_dir: Path | str | None,
label_path: Path | str | None,
) -> float | None:
"""Seconds/frame for a position, from its config first, then the TIFF tag.
Returns ``None`` when no positive interval can be found.
"""
interval = time_interval_from_config(position_dir)
if interval is not None:
return interval
return time_interval_from_tiff(label_path)
[docs]
def time_interval_from_config(position_dir: Path | str | None) -> float | None:
"""``metadata.time_interval_s`` 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("time_interval_s"))
except (OSError, ValueError, json.JSONDecodeError):
return None
[docs]
def time_interval_from_tiff(label_path: Path | str | None) -> float | None:
"""Seconds/frame from a TIFF's ImageJ ``finterval`` metadata."""
if label_path is None:
return None
path = Path(label_path)
if not path.is_file():
return None
try:
with tifffile.TiffFile(str(path)) as tf:
metadata = tf.imagej_metadata or {}
return _positive_float(metadata.get("finterval"))
except (OSError, ValueError, KeyError, tifffile.TiffFileError):
# An unreadable/empty/corrupt TIFF means "frame interval 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 _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