Source code for itasc.core.lineage
"""Per-track temporal presence ("swimlane") model from a tracked label stack.
This is the data half of the correction *lineage graph*: for each track id it
records the frame ranges where the cell is present, collapsed into contiguous
segments so a gap (track vanishes then returns — a likely ID swap or missed
link) reads as a break between segments. It is deliberately array-only — true
parent/daughter division edges live in the Ultrack database and are a separate,
heavier concern layered on top later.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
[docs]
@dataclass(frozen=True, slots=True)
class TrackSegment:
"""A contiguous run of frames ``[start, end]`` (inclusive) for one track."""
start: int
end: int
@property
def length(self) -> int:
return self.end - self.start + 1
[docs]
@dataclass(frozen=True, slots=True)
class TrackLane:
"""One track id and the frame segments where it is present."""
cell_id: int
segments: tuple[TrackSegment, ...]
@property
def first_frame(self) -> int:
return self.segments[0].start
@property
def last_frame(self) -> int:
return self.segments[-1].end
@property
def n_frames(self) -> int:
return sum(seg.length for seg in self.segments)
@property
def has_gap(self) -> bool:
return len(self.segments) > 1
[docs]
@dataclass(frozen=True, slots=True)
class LineageModel:
"""All track lanes plus the total frame count, for the lineage panel."""
n_frames: int
lanes: tuple[TrackLane, ...]
[docs]
def lane_for(self, cell_id: int) -> TrackLane | None:
for lane in self.lanes:
if lane.cell_id == cell_id:
return lane
return None
def _segments_from_frames(frames: list[int]) -> tuple[TrackSegment, ...]:
"""Collapse a sorted frame list into contiguous inclusive segments."""
segments: list[TrackSegment] = []
start = prev = frames[0]
for t in frames[1:]:
if t == prev + 1:
prev = t
continue
segments.append(TrackSegment(start=start, end=prev))
start = prev = t
segments.append(TrackSegment(start=start, end=prev))
return tuple(segments)
[docs]
def build_lineage(tracked: np.ndarray) -> LineageModel:
"""Build a :class:`LineageModel` from a ``(T, Y, X)`` tracked label stack.
A singleton Z axis (``(T, 1, Y, X)``) is squeezed; a single 2D frame is
treated as one timepoint. Lanes are sorted by track id so a cell keeps its
row across refreshes; correction actions (retrack, extend, relabel, …) that
change a track's id move it to its new sorted position on the next refresh.
"""
arr = np.asarray(tracked)
if arr.ndim == 4 and arr.shape[1] == 1:
arr = arr[:, 0]
if arr.ndim == 2:
arr = arr[np.newaxis]
if arr.ndim != 3:
raise ValueError(f"tracked must be (T, Y, X); got shape {arr.shape}")
n_t = arr.shape[0]
frames_of: dict[int, list[int]] = {}
for t in range(n_t):
for cell_id in np.unique(arr[t]).tolist():
if cell_id == 0:
continue
frames_of.setdefault(int(cell_id), []).append(t)
lanes = [
TrackLane(cell_id=cell_id, segments=_segments_from_frames(frames))
for cell_id, frames in frames_of.items()
]
lanes.sort(key=lambda lane: lane.cell_id)
return LineageModel(n_frames=n_t, lanes=tuple(lanes))
__all__ = ["LineageModel", "TrackLane", "TrackSegment", "build_lineage"]