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
sio.normalize_label_idsrewrites per-frame label IDs so they are globally consistent across a stack — essential when upstream segmentation assigns different IDs in different frames (e.g., Cellpose without tracking). See Regions → Normalizing label IDs.sio.merge_label_imagesconcatenates multiple chunked SLP files (e.g., parallel batch segmentation shards) via zero-decompression HDF5 chunk copies. See Examples → Parallel segmentation pipeline.sio.LabelImageWriterstreamsLabelImageframes one at a time into SLP with constant memory — ideal for TIFF-stack pipelines that don't fit in memory. See Regions → Streaming writes.
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 |
None
|
categories
|
list[str] | dict[int, str] | None
|
Category strings.
|
None
|
pages_as
|
str
|
How to interpret multi-page TIFFs.
|
'auto'
|
Returns:
| Type | Description |
|---|---|
list[LabelImage]
|
List of |
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 |
required |
label_images
|
list[LabelImage]
|
|
required |
stack
|
bool
|
Write as multi-page TIFF stack ( |
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:
|
required |
video
|
Video | None
|
Video to associate with all frames. |
None
|
tracks
|
dict[int, Track] | None
|
Global |
None
|
categories
|
list[str] | dict[int, str] | None
|
Category strings.
|
None
|
pages_as
|
str
|
How to interpret multi-page TIFFs.
|
'auto'
|
Returns:
| Type | Description |
|---|---|
list[LabelImage]
|
List of |
Raises:
| Type | Description |
|---|---|
ValueError
|
For unknown |
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)