Skip to content

TIFF Label Image Format

TIFF files store dense integer label images for instance segmentation. Each pixel value is a non-negative integer: 0 is background, and positive values identify distinct objects. This is the standard output of cell segmentation tools like Cellpose and StarDist.

sleap-io reads and writes these label images as LabelImage objects via the tifffile library.

File Structures

Three layouts are supported:

Layout Description Path argument
Single TIFF One 2D frame "frame.tif"
Multi-page stack One page per frame in a single file "labels.tif"
Directory One .tif/.tiff per frame, sorted alphanumerically "labels_dir/"

Sidecar Metadata

When writing, a JSON sidecar file is created alongside the TIFF at {path}.meta.json. It stores track names and category strings for each label ID so they survive a round trip:

{
  "format": "sleap-io-label-image-meta",
  "version": 3,
  "axes": "YX",
  "objects": {
    "1": {"track": "cell_1", "category": "neuron"},
    "2": {"track": "cell_2", "category": "glia"}
  }
}

axes is "YX" for a single label image and "TYX" for a multi-frame stack; the reader uses it as the authoritative layout hint. Optional scale and offset keys are added when any label image carries a spatial transform.

On read, the sidecar is loaded automatically if present. Without it, tracks are auto-created with the label ID as the name and categories are left empty.

Reading

import sleap_io as sio

# Single TIFF or multi-page stack
label_images = sio.load_label_images("labels.tif")

# Directory of per-frame TIFFs
label_images = sio.load_label_images("labels_dir/")

# With an associated video
video = sio.load_video("experiment.mp4")
label_images = sio.load_label_images("labels.tif", video=video)

# With explicit track/category mappings (overrides sidecar)
from sleap_io import Track
tracks = {1: Track(name="cell_A"), 2: Track(name="cell_B")}
categories = {1: "neuron", 2: "glia"}
label_images = sio.load_label_images(
    "labels.tif", tracks=tracks, categories=categories
)

Each returned LabelImage corresponds to one frame in the stack, ordered by position (0, 1, 2, ...).

Writing

import sleap_io as sio

# Write as a multi-page TIFF stack (default)
sio.save_label_images("output.tif", label_images, stack=True)
# Creates: output.tif + output.tif.meta.json

# Write as per-frame files in a directory
sio.save_label_images("output_dir/", label_images, stack=False)
# Creates: output_dir/0.tif, output_dir/1.tif, ... + output_dir.meta.json (sibling of the dir)

Cellpose Workflow Example

A common workflow is to run Cellpose on microscopy data, convert the output masks to LabelImage objects, and save them as TIFF or SLP:

import numpy as np
import sleap_io as sio
from cellpose import models

# Run Cellpose segmentation
model = models.CellposeModel(model_type="nuclei")
masks, flows, styles = model.eval(images, diameter=25)
masks_stack = np.stack(masks)  # (T, H, W) int32

# Convert to LabelImage objects with consistent tracks across frames
video = sio.Video(filename="experiment.tif")
label_images = sio.PredictedLabelImage.from_stack(
    masks_stack,
    source="cellpose:nuclei",
    create_tracks=True,
    score=1.0,
)

# Save as TIFF stack (with sidecar metadata)
sio.save_label_images("cellpose_masks.tif", label_images)

# Or save as SLP (preserves tracks, categories, and provenance)
labeled_frames = []
for i, li in enumerate(label_images):
    lf = sio.LabeledFrame(video=video, frame_idx=i)
    lf.append(li)  # dispatches to lf.label_images
    labeled_frames.append(lf)
labels = sio.Labels(labeled_frames=labeled_frames, videos=[video])
labels.provenance["segmentation_model"] = "cellpose"
labels.provenance["cellpose_diameter"] = 25
labels.save("cellpose_masks.slp")

lf.append(li) is the idiomatic way to attach a LabelImage to a frame — it routes the annotation onto lf.label_images via the type-dispatched LabeledFrame.append. Constructing LabeledFrame(..., label_images=[li]) directly is still supported.

The from_stack() method ensures that the same Track object is shared across frames for a given label ID, which is essential for consistent tracking and downstream analysis.

TIFF → SLP follow-ups

See also

Formats → COCO Panoptic segmentation — the COCO Panoptic reader/writer uses the same LabelImage model, so the same post-processing helpers apply.

API

sleap_io.io.main.load_label_images(path, video=None, tracks=None, categories=None, pages_as='auto')

Load label images from TIFF file(s) or directory.

Parameters:

Name Type Description Default
path str | Path

Path to a TIFF file (single or multi-page stack) or a directory of per-frame TIFFs.

required
video Video | None

Video to associate with all frames.

None
tracks dict | None

Global {label_id: Track} mapping. If None, auto-creates one Track per unique ID found across all frames. Ignored for class-stacked layouts.

None
categories list[str] | dict[int, str] | None

Category strings.

  • dict[int, str] keyed by label ID (time mode).
  • list[str] positional, one per class (class mode).
  • None to read from sidecar if present.
None
pages_as str

How to interpret multi-page TIFFs.

  • "auto" (default): consult sidecar "axes", then TIFF metadata (OME-XML / ImageJ hyperstack). Falls back to "time" for plain multi-page files with a one-time warning.
  • "time": force each page to be one frame.
  • "classes": force pages to be per-class binary masks for a single frame (N pages -> 1 LabelImage with label IDs 1..N).
'auto'

Returns:

Type Description
list[LabelImage]

List of LabelImage, one per frame, sorted by frame index.

Source code in sleap_io/io/main.py
def load_label_images(
    path: str | Path,
    video: Video | None = None,
    tracks: dict | None = None,
    categories: list[str] | dict[int, str] | None = None,
    pages_as: str = "auto",
) -> list[LabelImage]:
    """Load label images from TIFF file(s) or directory.

    Args:
        path: Path to a TIFF file (single or multi-page stack) or a directory
            of per-frame TIFFs.
        video: Video to associate with all frames.
        tracks: Global ``{label_id: Track}`` mapping. If ``None``, auto-creates
            one Track per unique ID found across all frames. Ignored for
            class-stacked layouts.
        categories: Category strings.

            - ``dict[int, str]`` keyed by label ID (time mode).
            - ``list[str]`` positional, one per class (class mode).
            - ``None`` to read from sidecar if present.

        pages_as: How to interpret multi-page TIFFs.

            - ``"auto"`` (default): consult sidecar ``"axes"``, then TIFF
              metadata (OME-XML / ImageJ hyperstack). Falls back to
              ``"time"`` for plain multi-page files with a one-time warning.
            - ``"time"``: force each page to be one frame.
            - ``"classes"``: force pages to be per-class binary masks for a
              single frame (N pages -> 1 ``LabelImage`` with label IDs 1..N).

    Returns:
        List of ``LabelImage``, one per frame, sorted by frame index.
    """
    from sleap_io.io import tiff

    return tiff.read_label_images(
        path,
        video=video,
        tracks=tracks,
        categories=categories,
        pages_as=pages_as,
    )

sleap_io.io.main.save_label_images(path, label_images, stack=True)

Save label images to TIFF.

Parameters:

Name Type Description Default
path str | Path

Output path. If stack=True, writes a single multi-page TIFF. If stack=False, writes per-frame files to this directory.

required
label_images list[LabelImage]

LabelImage objects to write.

required
stack bool

Write as multi-page TIFF stack (True) or per-frame files in a directory (False).

True
Source code in sleap_io/io/main.py
def save_label_images(
    path: str | Path,
    label_images: list[LabelImage],
    stack: bool = True,
) -> None:
    """Save label images to TIFF.

    Args:
        path: Output path. If ``stack=True``, writes a single multi-page TIFF.
            If ``stack=False``, writes per-frame files to this directory.
        label_images: ``LabelImage`` objects to write.
        stack: Write as multi-page TIFF stack (``True``) or per-frame files in
            a directory (``False``).
    """
    from sleap_io.io import tiff

    tiff.write_label_images(path, label_images, stack=stack)

sleap_io.io.tiff.read_label_images(path, video=None, tracks=None, categories=None, pages_as='auto')

Read label images from TIFF file(s).

Parameters:

Name Type Description Default
path str | Path

One of:

  • Multi-page TIFF stack: one page per frame (or per class, see pages_as).
  • Single TIFF: one frame.
  • Directory of TIFFs: sorted alphanumerically, one per frame.
required
video Video | None

Video to associate with all frames.

None
tracks dict[int, Track] | None

Global label_id -> Track mapping. If None, auto-creates one Track per unique ID found across all frames. Ignored for class-stacked layouts.

None
categories list[str] | dict[int, str] | None

Category strings.

  • dict[int, str] — keyed by label ID (time mode).
  • list[str] — positional, one per class (class mode).
  • None — read from sidecar if present.
None
pages_as str

How to interpret multi-page TIFFs.

  • "auto" (default): consult sidecar "axes", then TIFF metadata (OME-XML / ImageJ hyperstack). Falls back to "time" for plain multi-page TIFFs with a one-time warning.
  • "time": force each page to be one frame (N pages -> N LabelImage objects).
  • "classes": force pages to be per-class binary masks for a single frame (N pages -> 1 LabelImage with label IDs 1..N).
'auto'

Returns:

Type Description
list[LabelImage]

List of LabelImage, one per frame, sorted by frame index.

Raises:

Type Description
ValueError

For unknown pages_as values or unreadable pages (non-2D, negative values, etc.).

Source code in sleap_io/io/tiff.py
def read_label_images(
    path: str | Path,
    video: "Video | None" = None,
    tracks: "dict[int, Track] | None" = None,
    categories: "list[str] | dict[int, str] | None" = None,
    pages_as: str = "auto",
) -> list["LabelImage"]:
    """Read label images from TIFF file(s).

    Args:
        path: One of:

            - Multi-page TIFF stack: one page per frame (or per class, see
              ``pages_as``).
            - Single TIFF: one frame.
            - Directory of TIFFs: sorted alphanumerically, one per frame.

        video: Video to associate with all frames.
        tracks: Global ``label_id -> Track`` mapping. If ``None``, auto-creates
            one ``Track`` per unique ID found across all frames. Ignored for
            class-stacked layouts.
        categories: Category strings.

            - ``dict[int, str]`` — keyed by label ID (time mode).
            - ``list[str]`` — positional, one per class (class mode).
            - ``None`` — read from sidecar if present.

        pages_as: How to interpret multi-page TIFFs.

            - ``"auto"`` (default): consult sidecar ``"axes"``, then TIFF
              metadata (OME-XML / ImageJ hyperstack). Falls back to
              ``"time"`` for plain multi-page TIFFs with a one-time warning.
            - ``"time"``: force each page to be one frame (N pages -> N
              ``LabelImage`` objects).
            - ``"classes"``: force pages to be per-class binary masks for a
              single frame (N pages -> 1 ``LabelImage`` with label IDs 1..N).

    Returns:
        List of ``LabelImage``, one per frame, sorted by frame index.

    Raises:
        ValueError: For unknown ``pages_as`` values or unreadable pages
            (non-2D, negative values, etc.).
    """
    import tifffile

    if pages_as not in ("auto", "time", "classes"):
        raise ValueError(
            f"pages_as must be 'auto', 'time', or 'classes'; got {pages_as!r}."
        )

    path = Path(path)
    sidecar = _read_sidecar(path)

    # Read spatial metadata from sidecar (v2+)
    sidecar_scale: tuple[float, float] = (1.0, 1.0)
    sidecar_offset: tuple[float, float] = (0.0, 0.0)
    if sidecar is not None:
        if "scale" in sidecar:
            s = sidecar["scale"]
            sidecar_scale = (float(s[0]), float(s[1]))
        if "offset" in sidecar:
            o = sidecar["offset"]
            sidecar_offset = (float(o[0]), float(o[1]))

    # --- Directory input ------------------------------------------------
    if path.is_dir():
        tiff_files = sorted(list(path.glob("*.tif")) + list(path.glob("*.tiff")))
        if not tiff_files:
            return []

        frames_data: list[np.ndarray] = []
        for tiff_path in tiff_files:
            data = tifffile.imread(str(tiff_path)).astype(np.int32)
            if data.ndim != 2:
                raise ValueError(
                    f"Expected 2D array from {tiff_path}, got shape {data.shape}"
                )
            frames_data.append(data)

        if pages_as == "classes":
            return _read_single_class_stack(
                frames_data, categories, sidecar, sidecar_scale, sidecar_offset
            )
        return _read_pages_as_time(
            frames_data,
            tracks,
            categories,
            sidecar,
            sidecar_scale,
            sidecar_offset,
        )

    # --- Single file (possibly multi-page) ------------------------------
    # Decide layout. Priority: explicit pages_as -> sidecar axes -> TIFF
    # metadata -> fallback ('time' with warning for plain multi-page).
    sidecar_axes = None
    if sidecar is not None and "axes" in sidecar:
        sidecar_axes = _normalize_axes(sidecar["axes"])

    tiff_axes, n_pages, has_metadata = _infer_tiff_axes(path)

    if pages_as == "time":
        layout = "TYX"
    elif pages_as == "classes":
        layout = "CYX"
    elif sidecar_axes and sidecar_axes != "unknown":
        layout = sidecar_axes
    elif tiff_axes != "unknown":
        layout = tiff_axes
    else:
        layout = "TYX"  # fallback

    # Read series data once for authoritative layouts (OME/ImageJ declare
    # the full shape via series rather than per-page iteration).
    def _iter_pages() -> list[np.ndarray]:
        with tifffile.TiffFile(str(path)) as tif:
            out = []
            for page in tif.pages:
                arr = page.asarray().astype(np.int32)
                if arr.ndim != 2:
                    raise ValueError(
                        f"Expected 2D page in {path}, got shape {arr.shape}"
                    )
                out.append(arr)
            return out

    def _read_series() -> np.ndarray:
        with tifffile.TiffFile(str(path)) as tif:
            return tif.series[0].asarray()

    # --- Dispatch on layout ---------------------------------------------
    if layout in ("YX", "TYX"):
        frames_data = _iter_pages()

        # Warn when we're falling back on an ambiguous plain multi-page,
        # but only if the pages could plausibly be a binary class stack.
        # Multi-valued integer pages rule out the class-stack reading, so
        # the fallback is the only sensible interpretation and suggesting
        # pages_as='classes' would be misleading.
        used_fallback = (
            pages_as == "auto" and sidecar_axes is None and tiff_axes == "unknown"
        )
        if used_fallback and _pages_could_be_class_stack(frames_data):
            dtype_name = "unknown"
            with tifffile.TiffFile(str(path)) as tif:
                if tif.pages:
                    dtype_name = str(tif.pages[0].dtype)
            _warn_ambiguous_pages(path, n_pages, dtype_name)

        return _read_pages_as_time(
            frames_data,
            tracks,
            categories,
            sidecar,
            sidecar_scale,
            sidecar_offset,
        )

    if layout == "CYX":
        pages_data = _iter_pages()
        return _read_single_class_stack(
            pages_data,
            categories,
            sidecar,
            sidecar_scale,
            sidecar_offset,
        )

    if layout == "TCYX":
        # OME/ImageJ declared both T and C. Use the series array which
        # reshapes pages into a coherent (T, C, H, W) block. tifffile drops
        # size-1 axes, so a degenerate T=1 surfaces as layout="CYX" above
        # and doesn't reach here.
        series = _read_series()
        if series.ndim != 4:
            raise ValueError(
                f"Expected 4D (T,C,H,W) series for TCYX, got shape {series.shape}"
            )
        from sleap_io.model.label_image import UserLabelImage

        t_dim, c_dim = series.shape[0], series.shape[1]
        cat_list = _categories_as_list(categories, c_dim)
        if cat_list is None:
            cat_list = _categories_list_from_sidecar(sidecar, c_dim)

        result = []
        for t in range(t_dim):
            pages_t = [series[t, c] for c in range(c_dim)]
            label_ids_t = _infer_label_ids_from_pages(pages_t)
            stack_t = series[t].astype(bool)
            result.append(
                UserLabelImage.from_binary_masks(
                    stack_t,
                    label_ids=label_ids_t,
                    categories=cat_list,
                    scale=sidecar_scale,
                    offset=sidecar_offset,
                )
            )
        return result

    raise ValueError(f"Unhandled TIFF axes layout: {layout!r}")

sleap_io.io.tiff.write_label_images(path, label_images, stack=True)

Write label images to TIFF.

Parameters:

Name Type Description Default
path str | Path

Output path. If stack=True, writes a single multi-page TIFF. If stack=False, writes per-frame files to this directory (named by zero-padded frame index).

required
label_images list[LabelImage]

LabelImage objects to write.

required
stack bool

Write as multi-page TIFF stack (True) or per-frame files in a directory (False).

True
Source code in sleap_io/io/tiff.py
def write_label_images(
    path: str | Path,
    label_images: list["LabelImage"],
    stack: bool = True,
) -> None:
    """Write label images to TIFF.

    Args:
        path: Output path. If stack=True, writes a single multi-page TIFF.
            If stack=False, writes per-frame files to this directory (named
            by zero-padded frame index).
        label_images: LabelImage objects to write.
        stack: Write as multi-page TIFF stack (True) or per-frame files in a
            directory (False).
    """
    import tifffile

    path = Path(path)

    if not label_images:
        return

    if stack:
        path.parent.mkdir(parents=True, exist_ok=True)
        with tifffile.TiffWriter(str(path)) as tw:
            for li in label_images:
                tw.write(li.data)
    else:
        path.mkdir(parents=True, exist_ok=True)
        n_digits = max(1, len(str(len(label_images) - 1)))
        for i, li in enumerate(label_images):
            frame_path = path / f"{str(i).zfill(n_digits)}.tif"
            tifffile.imwrite(str(frame_path), li.data)

    _write_sidecar(path, label_images)