Skip to content

tiff

sleap_io.io.tiff

TIFF I/O for LabelImage data.

Reads and writes integer label images as TIFF files (single, multi-page stacks, or directories of per-frame TIFFs) with optional JSON sidecar metadata for track names and categories.

Pages in a TIFF can represent either time (one frame per page) or classes (one class per page, single frame). The reader distinguishes these layouts via, in priority order: an explicit pages_as argument, a sidecar "axes" hint, TIFF-level metadata (OME-XML, ImageJ hyperstack), and finally a fall-back assumption of pages-as-time for plain multi-page files.

Functions:

Name Description
read_label_images

Read label images from TIFF file(s).

write_label_images

Write label images to TIFF.

Attributes:

Name Type Description
TYPE_CHECKING

Returns True when the argument is true, False otherwise.

__cached__

str(object='') -> str

__doc__

str(object='') -> str

__file__

str(object='') -> str

__name__

str(object='') -> str

__package__

str(object='') -> str

TYPE_CHECKING = False module-attribute

Returns True when the argument is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

__cached__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/io/__pycache__/tiff.cpython-313.pyc' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__doc__ = 'TIFF I/O for LabelImage data.\n\nReads and writes integer label images as TIFF files (single, multi-page stacks,\nor directories of per-frame TIFFs) with optional JSON sidecar metadata for track\nnames and categories.\n\nPages in a TIFF can represent either time (one frame per page) or classes\n(one class per page, single frame). The reader distinguishes these layouts\nvia, in priority order: an explicit ``pages_as`` argument, a sidecar\n``"axes"`` hint, TIFF-level metadata (OME-XML, ImageJ hyperstack), and\nfinally a fall-back assumption of pages-as-time for plain multi-page files.\n' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__file__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/io/tiff.py' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__name__ = 'sleap_io.io.tiff' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__package__ = 'sleap_io.io' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

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}")

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)