Skip to content

rendering

sleap_io.rendering

Rendering module for visualizing pose data using skia-python.

This module provides high-performance pose rendering with: - Multiple color schemes (by track, instance, or node type) - Various marker shapes (circle, square, diamond, triangle, cross) - Configurable aesthetics (size, width, alpha) - Custom rendering callbacks for overlays - Video export with optional scaling

Example

import sleap_io as sio labels = sio.load_slp("predictions.slp") sio.render_video(labels, "output.mp4") sio.render_image(labels.labeled_frames[0], "frame.png")

Modules:

Name Description
callbacks

Callback context classes for custom rendering.

colors

Color palette utilities for pose rendering.

core

Core rendering functions for pose visualization.

overlays

Overlay drawing functions for ROIs, segmentation masks, and bounding boxes.

shapes

Marker shape drawing functions for pose rendering.

Classes:

Name Description
InstanceContext

Context passed to per-instance callbacks.

RenderContext

Context passed to pre/post render callbacks.

Functions:

Name Description
draw_bboxes

Draw bounding boxes on an image.

draw_label_image

Draw an integer label image as a colored overlay on an image.

draw_masks

Draw segmentation masks as colored overlays on an image.

draw_rois

Draw ROI geometries on an image.

draw_trails

Draw motion trails as fading polylines on an image.

get_palette

Get n colors from a named palette as RGB tuples.

render_image

Render single frame with pose and/or segmentation overlays.

render_video

Render video with pose overlays.

resolve_color

Resolve a flexible color specification to an RGB tuple.

Attributes:

Name Type Description
ColorSpec

Represent a PEP 604 union type

NAMED_COLORS

dict() -> new empty dictionary

ColorSpec = tuple[int, int, int] | tuple[float, float, float] | int | float | str module-attribute

Represent a PEP 604 union type

E.g. for int | str

NAMED_COLORS = {'black': (0, 0, 0), 'white': (255, 255, 255), 'red': (255, 0, 0), 'green': (0, 255, 0), 'blue': (0, 0, 255), 'yellow': (255, 255, 0), 'cyan': (0, 255, 255), 'magenta': (255, 0, 255), 'gray': (128, 128, 128), 'grey': (128, 128, 128), 'orange': (255, 165, 0), 'purple': (128, 0, 128), 'pink': (255, 192, 203), 'brown': (139, 69, 19)} module-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__all__ = ['render_video', 'render_image', 'get_palette', 'resolve_color', 'ColorSpec', 'NAMED_COLORS', 'RenderContext', 'InstanceContext', 'draw_rois', 'draw_masks', 'draw_bboxes', 'draw_label_image', 'draw_trails'] module-attribute

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

__cached__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/rendering/__pycache__/__init__.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__ = 'Rendering module for visualizing pose data using skia-python.\n\nThis module provides high-performance pose rendering with:\n- Multiple color schemes (by track, instance, or node type)\n- Various marker shapes (circle, square, diamond, triangle, cross)\n- Configurable aesthetics (size, width, alpha)\n- Custom rendering callbacks for overlays\n- Video export with optional scaling\n\nExample:\n >>> import sleap_io as sio\n >>> labels = sio.load_slp("predictions.slp")\n >>> sio.render_video(labels, "output.mp4")\n >>> sio.render_image(labels.labeled_frames[0], "frame.png")\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/rendering/__init__.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.rendering' 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.rendering' 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'.

__path__ = ['/home/runner/work/sleap-io/sleap-io/sleap_io/rendering'] module-attribute

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

InstanceContext

Context passed to per-instance callbacks.

This context provides access to the Skia canvas and instance-level metadata for drawing custom overlays after each instance is rendered.

Attributes:

Name Type Description
canvas

Skia canvas for drawing.

instance_idx

Index of this instance within the frame.

points

(n_nodes, 2) array of keypoint coordinates.

track_id

Track ID if assigned, else None.

track_name

Track name string if available.

confidence

Instance confidence score if available.

skeleton_edges

Edge connectivity as list of (src, dst) tuples.

node_names

List of node name strings.

scale

Current scale factor for rendering.

offset

Current offset (x, y) for cropped/zoomed views.

Methods:

Name Description
__eq__

Method generated by attrs for class InstanceContext.

__init__

Method generated by attrs for class InstanceContext.

__repr__

Method generated by attrs for class InstanceContext.

get_bbox

Get bounding box of valid points.

get_centroid

Get centroid of valid points.

world_to_canvas

Transform world coordinates to canvas coordinates.

Source code in sleap_io/rendering/callbacks.py
@define
class InstanceContext:
    """Context passed to per-instance callbacks.

    This context provides access to the Skia canvas and instance-level metadata
    for drawing custom overlays after each instance is rendered.

    Attributes:
        canvas: Skia canvas for drawing.
        instance_idx: Index of this instance within the frame.
        points: (n_nodes, 2) array of keypoint coordinates.
        track_id: Track ID if assigned, else None.
        track_name: Track name string if available.
        confidence: Instance confidence score if available.
        skeleton_edges: Edge connectivity as list of (src, dst) tuples.
        node_names: List of node name strings.
        scale: Current scale factor for rendering.
        offset: Current offset (x, y) for cropped/zoomed views.
    """

    canvas: "skia.Canvas"
    instance_idx: int
    points: np.ndarray
    skeleton_edges: list[tuple[int, int]]
    node_names: list[str]
    track_id: int | None = None
    track_name: str | None = None
    confidence: float | None = None
    scale: float = 1.0
    offset: tuple[float, float] = (0.0, 0.0)

    def world_to_canvas(self, x: float, y: float) -> tuple[float, float]:
        """Transform world coordinates to canvas coordinates.

        Args:
            x: X coordinate in world/frame space.
            y: Y coordinate in world/frame space.

        Returns:
            (x, y) coordinates in canvas space.
        """
        return (
            (x - self.offset[0]) * self.scale,
            (y - self.offset[1]) * self.scale,
        )

    def get_centroid(self) -> tuple[float, float] | None:
        """Get centroid of valid points.

        Returns:
            (x, y) mean of valid (non-NaN) points, or None if all invalid.
        """
        valid_mask = np.isfinite(self.points).all(axis=1)
        valid_points = self.points[valid_mask]
        if len(valid_points) == 0:
            return None
        mean_pt = valid_points.mean(axis=0)
        return (float(mean_pt[0]), float(mean_pt[1]))

    def get_bbox(self) -> tuple[float, float, float, float] | None:
        """Get bounding box of valid points.

        Returns:
            (x1, y1, x2, y2) bounding box, or None if no valid points.
        """
        valid_mask = np.isfinite(self.points).all(axis=1)
        valid_points = self.points[valid_mask]
        if len(valid_points) == 0:
            return None
        return (
            float(valid_points[:, 0].min()),
            float(valid_points[:, 1].min()),
            float(valid_points[:, 0].max()),
            float(valid_points[:, 1].max()),
        )

__annotations__ = {'canvas': "'skia.Canvas'", 'instance_idx': 'int', 'points': 'np.ndarray', 'skeleton_edges': 'list[tuple[int, int]]', 'node_names': 'list[str]', 'track_id': 'int | None', 'track_name': 'str | None', 'confidence': 'float | None', 'scale': 'float', 'offset': 'tuple[float, float]'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = False class-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.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.UNHASHABLE: 'unhashable'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'Context passed to per-instance callbacks.\n\nThis context provides access to the Skia canvas and instance-level metadata\nfor drawing custom overlays after each instance is rendered.\n\nAttributes:\n canvas: Skia canvas for drawing.\n instance_idx: Index of this instance within the frame.\n points: (n_nodes, 2) array of keypoint coordinates.\n track_id: Track ID if assigned, else None.\n track_name: Track name string if available.\n confidence: Instance confidence score if available.\n skeleton_edges: Edge connectivity as list of (src, dst) tuples.\n node_names: List of node name strings.\n scale: Current scale factor for rendering.\n offset: Current offset (x, y) for cropped/zoomed views.\n' class-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'.

__firstlineno__ = 61 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('canvas', 'instance_idx', 'points', 'skeleton_edges', 'node_names', 'track_id', 'track_name', 'confidence', 'scale', 'offset') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.rendering.callbacks' class-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'.

__slots__ = ('canvas', 'instance_idx', 'points', 'skeleton_edges', 'node_names', 'track_id', 'track_name', 'confidence', 'scale', 'offset', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = () class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

__eq__(other)

Method generated by attrs for class InstanceContext.

Source code in sleap_io/rendering/callbacks.py
@define
class RenderContext:
    """Context passed to pre/post render callbacks.

    This context provides access to the Skia canvas and frame-level metadata
    for drawing custom overlays before or after pose rendering.

    Attributes:
        canvas: Skia canvas for drawing.
        frame_idx: Current frame index.
        frame_size: (width, height) tuple of original frame dimensions.
        instances: List of instances in this frame.
        skeleton_edges: Edge connectivity as list of (src, dst) tuples.

__init__(canvas, instance_idx, points, skeleton_edges, node_names, track_id=None, track_name=None, confidence=None, scale=1.0, offset=(0.0, 0.0))

Method generated by attrs for class InstanceContext.

Source code in sleap_io/rendering/callbacks.py
    node_names: List of node name strings.
    scale: Current scale factor for rendering.
    offset: Current offset (x, y) for cropped/zoomed views.
"""

canvas: "skia.Canvas"
frame_idx: int
frame_size: tuple[int, int]
instances: list
skeleton_edges: list[tuple[int, int]]
node_names: list[str]

__repr__()

Method generated by attrs for class InstanceContext.

Source code in sleap_io/rendering/callbacks.py
"""Callback context classes for custom rendering.

This module provides context objects that are passed to user-defined callbacks
during rendering, giving access to the Skia canvas and rendering metadata.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
from attrs import define

if TYPE_CHECKING:
    import skia

get_bbox()

Get bounding box of valid points.

Returns:

Type Description
tuple[float, float, float, float] | None

(x1, y1, x2, y2) bounding box, or None if no valid points.

Source code in sleap_io/rendering/callbacks.py
def get_bbox(self) -> tuple[float, float, float, float] | None:
    """Get bounding box of valid points.

    Returns:
        (x1, y1, x2, y2) bounding box, or None if no valid points.
    """
    valid_mask = np.isfinite(self.points).all(axis=1)
    valid_points = self.points[valid_mask]
    if len(valid_points) == 0:
        return None
    return (
        float(valid_points[:, 0].min()),
        float(valid_points[:, 1].min()),
        float(valid_points[:, 0].max()),
        float(valid_points[:, 1].max()),
    )

get_centroid()

Get centroid of valid points.

Returns:

Type Description
tuple[float, float] | None

(x, y) mean of valid (non-NaN) points, or None if all invalid.

Source code in sleap_io/rendering/callbacks.py
def get_centroid(self) -> tuple[float, float] | None:
    """Get centroid of valid points.

    Returns:
        (x, y) mean of valid (non-NaN) points, or None if all invalid.
    """
    valid_mask = np.isfinite(self.points).all(axis=1)
    valid_points = self.points[valid_mask]
    if len(valid_points) == 0:
        return None
    mean_pt = valid_points.mean(axis=0)
    return (float(mean_pt[0]), float(mean_pt[1]))

world_to_canvas(x, y)

Transform world coordinates to canvas coordinates.

Parameters:

Name Type Description Default
x float

X coordinate in world/frame space.

required
y float

Y coordinate in world/frame space.

required

Returns:

Type Description
tuple[float, float]

(x, y) coordinates in canvas space.

Source code in sleap_io/rendering/callbacks.py
def world_to_canvas(self, x: float, y: float) -> tuple[float, float]:
    """Transform world coordinates to canvas coordinates.

    Args:
        x: X coordinate in world/frame space.
        y: Y coordinate in world/frame space.

    Returns:
        (x, y) coordinates in canvas space.
    """
    return (
        (x - self.offset[0]) * self.scale,
        (y - self.offset[1]) * self.scale,
    )

RenderContext

Context passed to pre/post render callbacks.

This context provides access to the Skia canvas and frame-level metadata for drawing custom overlays before or after pose rendering.

Attributes:

Name Type Description
canvas

Skia canvas for drawing.

frame_idx

Current frame index.

frame_size

(width, height) tuple of original frame dimensions.

instances

List of instances in this frame.

skeleton_edges

Edge connectivity as list of (src, dst) tuples.

node_names

List of node name strings.

scale

Current scale factor for rendering.

offset

Current offset (x, y) for cropped/zoomed views.

Methods:

Name Description
__eq__

Method generated by attrs for class RenderContext.

__init__

Method generated by attrs for class RenderContext.

__repr__

Method generated by attrs for class RenderContext.

world_to_canvas

Transform world coordinates to canvas coordinates.

Source code in sleap_io/rendering/callbacks.py
@define
class RenderContext:
    """Context passed to pre/post render callbacks.

    This context provides access to the Skia canvas and frame-level metadata
    for drawing custom overlays before or after pose rendering.

    Attributes:
        canvas: Skia canvas for drawing.
        frame_idx: Current frame index.
        frame_size: (width, height) tuple of original frame dimensions.
        instances: List of instances in this frame.
        skeleton_edges: Edge connectivity as list of (src, dst) tuples.
        node_names: List of node name strings.
        scale: Current scale factor for rendering.
        offset: Current offset (x, y) for cropped/zoomed views.
    """

    canvas: "skia.Canvas"
    frame_idx: int
    frame_size: tuple[int, int]
    instances: list
    skeleton_edges: list[tuple[int, int]]
    node_names: list[str]
    scale: float = 1.0
    offset: tuple[float, float] = (0.0, 0.0)

    def world_to_canvas(self, x: float, y: float) -> tuple[float, float]:
        """Transform world coordinates to canvas coordinates.

        Args:
            x: X coordinate in world/frame space.
            y: Y coordinate in world/frame space.

        Returns:
            (x, y) coordinates in canvas space.
        """
        return (
            (x - self.offset[0]) * self.scale,
            (y - self.offset[1]) * self.scale,
        )

__annotations__ = {'canvas': "'skia.Canvas'", 'frame_idx': 'int', 'frame_size': 'tuple[int, int]', 'instances': 'list', 'skeleton_edges': 'list[tuple[int, int]]', 'node_names': 'list[str]', 'scale': 'float', 'offset': 'tuple[float, float]'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = False class-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.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.UNHASHABLE: 'unhashable'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'Context passed to pre/post render callbacks.\n\nThis context provides access to the Skia canvas and frame-level metadata\nfor drawing custom overlays before or after pose rendering.\n\nAttributes:\n canvas: Skia canvas for drawing.\n frame_idx: Current frame index.\n frame_size: (width, height) tuple of original frame dimensions.\n instances: List of instances in this frame.\n skeleton_edges: Edge connectivity as list of (src, dst) tuples.\n node_names: List of node name strings.\n scale: Current scale factor for rendering.\n offset: Current offset (x, y) for cropped/zoomed views.\n' class-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'.

__firstlineno__ = 18 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('canvas', 'frame_idx', 'frame_size', 'instances', 'skeleton_edges', 'node_names', 'scale', 'offset') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.rendering.callbacks' class-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'.

__slots__ = ('canvas', 'frame_idx', 'frame_size', 'instances', 'skeleton_edges', 'node_names', 'scale', 'offset', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = () class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

__eq__(other)

Method generated by attrs for class RenderContext.

Source code in sleap_io/rendering/callbacks.py
@define
class RenderContext:
    """Context passed to pre/post render callbacks.

    This context provides access to the Skia canvas and frame-level metadata
    for drawing custom overlays before or after pose rendering.

    Attributes:
        canvas: Skia canvas for drawing.
        frame_idx: Current frame index.
        frame_size: (width, height) tuple of original frame dimensions.

__init__(canvas, frame_idx, frame_size, instances, skeleton_edges, node_names, scale=1.0, offset=(0.0, 0.0))

Method generated by attrs for class RenderContext.

Source code in sleap_io/rendering/callbacks.py
    instances: List of instances in this frame.
    skeleton_edges: Edge connectivity as list of (src, dst) tuples.
    node_names: List of node name strings.
    scale: Current scale factor for rendering.
    offset: Current offset (x, y) for cropped/zoomed views.
"""

canvas: "skia.Canvas"
frame_idx: int

__repr__()

Method generated by attrs for class RenderContext.

Source code in sleap_io/rendering/callbacks.py
"""Callback context classes for custom rendering.

This module provides context objects that are passed to user-defined callbacks
during rendering, giving access to the Skia canvas and rendering metadata.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
from attrs import define

if TYPE_CHECKING:
    import skia

world_to_canvas(x, y)

Transform world coordinates to canvas coordinates.

Parameters:

Name Type Description Default
x float

X coordinate in world/frame space.

required
y float

Y coordinate in world/frame space.

required

Returns:

Type Description
tuple[float, float]

(x, y) coordinates in canvas space.

Source code in sleap_io/rendering/callbacks.py
def world_to_canvas(self, x: float, y: float) -> tuple[float, float]:
    """Transform world coordinates to canvas coordinates.

    Args:
        x: X coordinate in world/frame space.
        y: Y coordinate in world/frame space.

    Returns:
        (x, y) coordinates in canvas space.
    """
    return (
        (x - self.offset[0]) * self.scale,
        (y - self.offset[1]) * self.scale,
    )

draw_bboxes(image, bboxes, color=(0, 255, 0), colors=None, line_width=2, fill_alpha=0.0, font=None)

Draw bounding boxes on an image.

Draws bounding boxes as closed paths using skia-python. Both axis-aligned and rotated bounding boxes are handled uniformly via corner points. For PredictedBoundingBox instances, the confidence score is drawn as text near the top-left corner.

Parameters:

Name Type Description Default
image ndarray

Image array of shape (H, W, 3) uint8. A 2-D grayscale (H, W) (or (H, W, 1)) image is accepted and promoted to RGB. Modified in-place and returned.

required
bboxes list[BoundingBox]

List of BoundingBox objects to draw.

required
color tuple[int, int, int]

RGB color tuple for the bounding box outlines. Used when colors is None.

(0, 255, 0)
colors list[tuple[int, int, int]] | None

Per-bbox RGB color tuples. If provided, must have the same length as bboxes. Overrides color.

None
line_width int

Width of the outline in pixels.

2
fill_alpha float

If > 0, fill the bounding box interior with this opacity (0.0 to 1.0).

0.0
font str | None

Font family name for score text (e.g., "Arial"). If None, uses the system default typeface.

None

Returns:

Type Description
ndarray

The modified image array.

Source code in sleap_io/rendering/overlays.py
def draw_bboxes(
    image: np.ndarray,
    bboxes: list["BoundingBox"],
    color: tuple[int, int, int] = (0, 255, 0),
    colors: list[tuple[int, int, int]] | None = None,
    line_width: int = 2,
    fill_alpha: float = 0.0,
    font: str | None = None,
) -> np.ndarray:
    """Draw bounding boxes on an image.

    Draws bounding boxes as closed paths using skia-python. Both axis-aligned
    and rotated bounding boxes are handled uniformly via corner points. For
    ``PredictedBoundingBox`` instances, the confidence score is drawn as text
    near the top-left corner.

    Args:
        image: Image array of shape (H, W, 3) uint8. A 2-D grayscale
            ``(H, W)`` (or ``(H, W, 1)``) image is accepted and promoted to
            RGB. Modified in-place and returned.
        bboxes: List of BoundingBox objects to draw.
        color: RGB color tuple for the bounding box outlines. Used when
            ``colors`` is ``None``.
        colors: Per-bbox RGB color tuples. If provided, must have the same
            length as ``bboxes``. Overrides ``color``.
        line_width: Width of the outline in pixels.
        fill_alpha: If > 0, fill the bounding box interior with this opacity
            (0.0 to 1.0).
        font: Font family name for score text (e.g., ``"Arial"``). If
            ``None``, uses the system default typeface.

    Returns:
        The modified image array.
    """
    if not bboxes:
        return image

    import skia

    from sleap_io.model.bbox import PredictedBoundingBox

    # Ensure RGB before padding to RGBA.
    image = _ensure_rgb(image)

    # Pad to RGBA for skia surface
    frame_rgba = np.dstack([image, np.full(image.shape[:2], 255, dtype=np.uint8)])
    surface = skia.Surface(frame_rgba, colorType=skia.kRGBA_8888_ColorType)
    canvas = surface.getCanvas()

    if colors is None:
        # Single color for all bboxes
        stroke_paint = skia.Paint(
            Color=skia.Color(*color),
            AntiAlias=False,
            Style=skia.Paint.kStroke_Style,
            StrokeWidth=float(line_width),
            StrokeCap=skia.Paint.kSquare_Cap,
        )
        fill_paint = None
        if fill_alpha > 0:
            fill_paint = skia.Paint(
                Color=skia.Color4f(
                    color[0] / 255.0,
                    color[1] / 255.0,
                    color[2] / 255.0,
                    fill_alpha,
                ).toColor(),
                AntiAlias=False,
                Style=skia.Paint.kFill_Style,
            )
        for bbox in bboxes:
            corners = bbox.corners

            # Build a closed path from the 4 corners
            path = skia.Path()
            path.moveTo(float(corners[0][0]), float(corners[0][1]))
            for j in range(1, len(corners)):
                path.lineTo(float(corners[j][0]), float(corners[j][1]))
            path.close()

            # Draw fill if requested
            if fill_paint is not None:
                canvas.drawPath(path, fill_paint)

            # Draw stroke
            canvas.drawPath(path, stroke_paint)

            # Score text for predicted bboxes
            if isinstance(bbox, PredictedBoundingBox):
                text_x = float(corners[0][0])
                text_y = float(corners[0][1]) - 5
                typeface = skia.Typeface(font if font else "sans-serif")
                skia_font = skia.Font(typeface, 12)
                text_paint = skia.Paint(Color=skia.Color(*color), AntiAlias=True)
                canvas.drawString(
                    f"{bbox.score:.2f}", text_x, text_y, skia_font, text_paint
                )
    else:
        # Per-bbox colors
        for i, bbox in enumerate(bboxes):
            c = colors[i]
            stroke_paint = skia.Paint(
                Color=skia.Color(*c),
                AntiAlias=False,
                Style=skia.Paint.kStroke_Style,
                StrokeWidth=float(line_width),
                StrokeCap=skia.Paint.kSquare_Cap,
            )
            fill_paint = None
            if fill_alpha > 0:
                fill_paint = skia.Paint(
                    Color=skia.Color4f(
                        c[0] / 255.0,
                        c[1] / 255.0,
                        c[2] / 255.0,
                        fill_alpha,
                    ).toColor(),
                    AntiAlias=False,
                    Style=skia.Paint.kFill_Style,
                )

            corners = bbox.corners

            # Build a closed path from the 4 corners
            path = skia.Path()
            path.moveTo(float(corners[0][0]), float(corners[0][1]))
            for j in range(1, len(corners)):
                path.lineTo(float(corners[j][0]), float(corners[j][1]))
            path.close()

            # Draw fill if requested
            if fill_paint is not None:
                canvas.drawPath(path, fill_paint)

            # Draw stroke
            canvas.drawPath(path, stroke_paint)

            # Score text for predicted bboxes
            if isinstance(bbox, PredictedBoundingBox):
                text_x = float(corners[0][0])
                text_y = float(corners[0][1]) - 5
                typeface = skia.Typeface(font if font else "sans-serif")
                skia_font = skia.Font(typeface, 12)
                text_paint = skia.Paint(Color=skia.Color(*c), AntiAlias=True)
                canvas.drawString(
                    f"{bbox.score:.2f}", text_x, text_y, skia_font, text_paint
                )

    # Copy RGB channels back to the input image
    image[:] = frame_rgba[:, :, :3]

    return image

draw_label_image(image, labels, alpha=0.3, palette='distinct', outline=False, outline_width=1, outline_color=None, scale=(1.0, 1.0), offset=(0.0, 0.0))

Draw an integer label image as a colored overlay on an image.

This is an efficient rendering path for segmentation masks stored as integer label images (e.g., from instance or panoptic segmentation) where each pixel value represents a different object ID (0 = background).

Parameters:

Name Type Description Default
image ndarray

Image array of shape (H, W, 3) uint8. A 2-D grayscale (H, W) (or (H, W, 1)) image is accepted and promoted to RGB. Modified in-place and returned.

required
labels ndarray

Integer label array of shape (H, W) where 0 is background and positive values are object IDs.

required
alpha float

Opacity of the mask overlay (0.0 to 1.0).

0.3
palette str

Color palette name for assigning colors to label IDs. See :func:~sleap_io.rendering.colors.get_palette for options.

'distinct'
outline bool

If True, draw outlines around each labeled region using numpy edge detection.

False
outline_width int

Width of the outline in pixels (only used if outline=True).

1
outline_color tuple[int, int, int] | None

RGB color for outlines. If None, uses a darkened version of each region's fill color.

None
scale tuple[float, float]

Resolution ratio (sx, sy) for coordinate mapping.

(1.0, 1.0)
offset tuple[float, float]

Origin (x, y) in image pixel coordinates.

(0.0, 0.0)

Returns:

Type Description
ndarray

The modified image array.

Source code in sleap_io/rendering/overlays.py
def draw_label_image(
    image: np.ndarray,
    labels: np.ndarray,
    alpha: float = 0.3,
    palette: str = "distinct",
    outline: bool = False,
    outline_width: int = 1,
    outline_color: tuple[int, int, int] | None = None,
    scale: tuple[float, float] = (1.0, 1.0),
    offset: tuple[float, float] = (0.0, 0.0),
) -> np.ndarray:
    """Draw an integer label image as a colored overlay on an image.

    This is an efficient rendering path for segmentation masks stored as
    integer label images (e.g., from instance or panoptic segmentation) where
    each pixel value represents a different object ID (0 = background).

    Args:
        image: Image array of shape ``(H, W, 3)`` uint8. A 2-D grayscale
            ``(H, W)`` (or ``(H, W, 1)``) image is accepted and promoted to
            RGB. Modified in-place and returned.
        labels: Integer label array of shape ``(H, W)`` where 0 is background
            and positive values are object IDs.
        alpha: Opacity of the mask overlay (0.0 to 1.0).
        palette: Color palette name for assigning colors to label IDs. See
            :func:`~sleap_io.rendering.colors.get_palette` for options.
        outline: If ``True``, draw outlines around each labeled region using
            numpy edge detection.
        outline_width: Width of the outline in pixels (only used if
            ``outline=True``).
        outline_color: RGB color for outlines. If ``None``, uses a darkened
            version of each region's fill color.
        scale: Resolution ratio ``(sx, sy)`` for coordinate mapping.
        offset: Origin ``(x, y)`` in image pixel coordinates.

    Returns:
        The modified image array.
    """
    from sleap_io.rendering.colors import get_palette

    # Ensure RGB so grayscale images can be blended with colored overlays.
    image = _ensure_rgb(image)

    # Get unique non-background labels
    unique_ids = np.unique(labels)
    unique_ids = unique_ids[unique_ids > 0]

    if len(unique_ids) == 0:
        return image

    # Build color lookup table (LUT): label_id -> RGB
    max_id = int(unique_ids.max())
    palette_colors = get_palette(palette, max_id + 1)

    # Create a LUT array: shape (max_id + 1, 3)
    lut = np.zeros((max_id + 1, 3), dtype=np.float32)
    for label_id in unique_ids:
        lut[label_id] = palette_colors[int(label_id) % len(palette_colors)]

    img_h, img_w = image.shape[:2]
    has_transform = scale != (1.0, 1.0) or offset != (0.0, 0.0)

    if has_transform:
        from sleap_io.model.mask import _resize_nearest

        sx, sy = scale
        lab_h, lab_w = labels.shape[:2]
        target_h = int(lab_h / sy)
        target_w = int(lab_w / sx)
        resized_labels = _resize_nearest(labels, target_h, target_w)

        ox, oy = offset
        y0 = max(0, int(oy))
        x0 = max(0, int(ox))
        y1 = min(img_h, int(oy) + target_h)
        x1 = min(img_w, int(ox) + target_w)

        if y1 <= y0 or x1 <= x0:
            return image

        my0 = y0 - int(oy)
        mx0 = x0 - int(ox)
        region = image[y0:y1, x0:x1]
        label_region = resized_labels[my0 : my0 + (y1 - y0), mx0 : mx0 + (x1 - x0)]
        draw_h = y1 - y0
        draw_w = x1 - x0
    else:
        lab_h, lab_w = labels.shape[:2]
        draw_h = min(lab_h, img_h)
        draw_w = min(lab_w, img_w)
        region = image[:draw_h, :draw_w]
        label_region = labels[:draw_h, :draw_w]

    # Vectorized blending: apply colored overlay where labels > 0
    fg_mask = label_region > 0
    if np.any(fg_mask):
        # Clamp label values for LUT indexing
        safe_labels = np.clip(label_region, 0, max_id)
        overlay_colors = lut[safe_labels]  # (H, W, 3)

        region_float = region.astype(np.float32)
        region_float[fg_mask] = (
            region_float[fg_mask] * (1 - alpha) + overlay_colors[fg_mask] * alpha
        )
        region[:] = region_float.astype(np.uint8)

    # Draw outlines if requested
    if outline:
        if has_transform:
            # For transformed labels, draw outlines on the placed region
            _draw_label_outlines(
                image, labels, draw_h, draw_w, outline_width, outline_color, lut
            )
        else:
            _draw_label_outlines(
                image, labels, draw_h, draw_w, outline_width, outline_color, lut
            )

    return image

draw_masks(image, masks, color=(255, 0, 0), colors=None, alpha=0.3)

Draw segmentation masks as colored overlays on an image.

Parameters:

Name Type Description Default
image ndarray

Image array of shape (H, W, 3) uint8. A 2-D grayscale (H, W) (or (H, W, 1)) image is accepted and promoted to RGB. Modified in-place and returned.

required
masks list[SegmentationMask]

List of SegmentationMask objects to draw.

required
color tuple[int, int, int]

RGB color tuple for the mask overlay. Used when colors is None.

(255, 0, 0)
colors list[tuple[int, int, int]] | None

Per-mask RGB color tuples. If provided, must have the same length as masks. Overrides color.

None
alpha float

Opacity of the mask overlay (0.0 to 1.0).

0.3

Returns:

Type Description
ndarray

The modified image array.

Source code in sleap_io/rendering/overlays.py
def draw_masks(
    image: np.ndarray,
    masks: list["SegmentationMask"],
    color: tuple[int, int, int] = (255, 0, 0),
    colors: list[tuple[int, int, int]] | None = None,
    alpha: float = 0.3,
) -> np.ndarray:
    """Draw segmentation masks as colored overlays on an image.

    Args:
        image: Image array of shape (H, W, 3) uint8. A 2-D grayscale
            ``(H, W)`` (or ``(H, W, 1)``) image is accepted and promoted to
            RGB. Modified in-place and returned.
        masks: List of SegmentationMask objects to draw.
        color: RGB color tuple for the mask overlay. Used when ``colors`` is
            ``None``.
        colors: Per-mask RGB color tuples. If provided, must have the same
            length as ``masks``. Overrides ``color``.
        alpha: Opacity of the mask overlay (0.0 to 1.0).

    Returns:
        The modified image array.
    """
    # Ensure RGB so grayscale images can be blended with colored overlays.
    image = _ensure_rgb(image)

    for i, mask in enumerate(masks):
        mask_color = colors[i] if colors is not None else color
        mask_data = mask.data
        img_h, img_w = image.shape[:2]

        if mask.has_spatial_transform:
            from sleap_io.model.mask import _resize_nearest

            # Compute image-space placement
            target_h, target_w = mask.image_extent
            resized = _resize_nearest(mask_data, target_h, target_w)

            ox, oy = mask.offset
            y0 = max(0, int(oy))
            x0 = max(0, int(ox))
            y1 = min(img_h, int(oy) + target_h)
            x1 = min(img_w, int(ox) + target_w)

            if y1 <= y0 or x1 <= x0:
                continue

            # Slice within the resized mask (handles negative offset)
            my0 = y0 - int(oy)
            mx0 = x0 - int(ox)
            region = image[y0:y1, x0:x1]
            mask_region = resized[my0 : my0 + (y1 - y0), mx0 : mx0 + (x1 - x0)]
        else:
            h, w = mask_data.shape
            draw_h = min(h, img_h)
            draw_w = min(w, img_w)
            region = image[:draw_h, :draw_w]
            mask_region = mask_data[:draw_h, :draw_w]

        # Blend color into masked pixels
        overlay = np.array(mask_color, dtype=np.float32)
        region[mask_region] = (
            region[mask_region] * (1 - alpha) + overlay * alpha
        ).astype(np.uint8)

    return image

draw_rois(image, rois, color=(0, 255, 0), colors=None, line_width=2, fill_alpha=0.0)

Draw ROI geometries on an image.

Draws the boundary of each ROI's geometry using skia-python. Supports Polygon, MultiPolygon, Point, MultiPoint, LineString, MultiLineString, and GeometryCollection geometries.

Parameters:

Name Type Description Default
image ndarray

Image array of shape (H, W, 3) uint8. A 2-D grayscale (H, W) (or (H, W, 1)) image is accepted and promoted to RGB. Modified in-place and returned.

required
rois list[ROI]

List of ROI objects to draw.

required
color tuple[int, int, int]

RGB color tuple for the ROI outlines. Used when colors is None.

(0, 255, 0)
colors list[tuple[int, int, int]] | None

Per-ROI RGB color tuples. If provided, must have the same length as rois. Overrides color.

None
line_width int

Width of the outline in pixels.

2
fill_alpha float

If > 0, fill the ROI interior with this opacity (0.0 to 1.0).

0.0

Returns:

Type Description
ndarray

The modified image array.

Source code in sleap_io/rendering/overlays.py
def draw_rois(
    image: np.ndarray,
    rois: list["ROI"],
    color: tuple[int, int, int] = (0, 255, 0),
    colors: list[tuple[int, int, int]] | None = None,
    line_width: int = 2,
    fill_alpha: float = 0.0,
) -> np.ndarray:
    """Draw ROI geometries on an image.

    Draws the boundary of each ROI's geometry using skia-python. Supports
    ``Polygon``, ``MultiPolygon``, ``Point``, ``MultiPoint``, ``LineString``,
    ``MultiLineString``, and ``GeometryCollection`` geometries.

    Args:
        image: Image array of shape (H, W, 3) uint8. A 2-D grayscale
            ``(H, W)`` (or ``(H, W, 1)``) image is accepted and promoted to
            RGB. Modified in-place and returned.
        rois: List of ROI objects to draw.
        color: RGB color tuple for the ROI outlines. Used when ``colors`` is
            ``None``.
        colors: Per-ROI RGB color tuples. If provided, must have the same
            length as ``rois``. Overrides ``color``.
        line_width: Width of the outline in pixels.
        fill_alpha: If > 0, fill the ROI interior with this opacity (0.0 to
            1.0).

    Returns:
        The modified image array.
    """
    if not rois:
        return image

    import skia

    # Ensure RGB before padding to RGBA.
    image = _ensure_rgb(image)

    # Pad to RGBA for skia surface
    frame_rgba = np.dstack([image, np.full(image.shape[:2], 255, dtype=np.uint8)])
    surface = skia.Surface(frame_rgba, colorType=skia.kRGBA_8888_ColorType)
    canvas = surface.getCanvas()

    if colors is None:
        # Single color for all ROIs
        stroke_paint = skia.Paint(
            Color=skia.Color(*color),
            AntiAlias=False,
            Style=skia.Paint.kStroke_Style,
            StrokeWidth=float(line_width),
            StrokeCap=skia.Paint.kSquare_Cap,
        )
        fill_paint = None
        if fill_alpha > 0:
            fill_paint = skia.Paint(
                Color=skia.Color4f(
                    color[0] / 255.0,
                    color[1] / 255.0,
                    color[2] / 255.0,
                    fill_alpha,
                ).toColor(),
                AntiAlias=False,
                Style=skia.Paint.kFill_Style,
            )
        for roi in rois:
            _draw_geometry(canvas, roi.geometry, stroke_paint, fill_paint)
    else:
        # Per-ROI colors
        for i, roi in enumerate(rois):
            c = colors[i]
            stroke_paint = skia.Paint(
                Color=skia.Color(*c),
                AntiAlias=False,
                Style=skia.Paint.kStroke_Style,
                StrokeWidth=float(line_width),
                StrokeCap=skia.Paint.kSquare_Cap,
            )
            fill_paint = None
            if fill_alpha > 0:
                fill_paint = skia.Paint(
                    Color=skia.Color4f(
                        c[0] / 255.0,
                        c[1] / 255.0,
                        c[2] / 255.0,
                        fill_alpha,
                    ).toColor(),
                    AntiAlias=False,
                    Style=skia.Paint.kFill_Style,
                )
            _draw_geometry(canvas, roi.geometry, stroke_paint, fill_paint)

    # Copy RGB channels back to the input image
    image[:] = frame_rgba[:, :, :3]

    return image

draw_trails(image, trails, color=(0, 255, 0), colors=None, line_width=2.0, alpha_fade=True, alpha=1.0, offset=(0.0, 0.0))

Draw motion trails as fading polylines on an image.

Each trail is a polyline tracing a node or centroid position across past frames. Segments are drawn individually so opacity can fade from faint (oldest) to opaque (newest).

All segments are rasterized into a separate transparent buffer with the kSrc blend mode, so overlapping joints (and crossing trails) take the newest segment's alpha instead of accumulating it. That buffer is then composited onto the image in a single pass, touching only the pixels the trails actually cover.

Parameters:

Name Type Description Default
image ndarray

Image array of shape (H, W, 3) uint8. Modified in-place when possible and returned.

required
trails list[ndarray]

List of trails, where each trail is an (N, 2) float array of (x, y) coordinates ordered oldest to newest. Non-finite rows (NaN) break the polyline so missing detections leave gaps.

required
color tuple[int, int, int]

RGB color tuple used when colors is None.

(0, 255, 0)
colors list[tuple[int, int, int]] | None

Per-trail RGB color tuples. If provided, must have the same length as trails. Overrides color.

None
line_width float

Width of the trail line in pixels.

2.0
alpha_fade bool

If True, fade opacity from faint at the oldest segment to fully opaque at the newest. If False, all segments use alpha.

True
alpha float

Global opacity multiplier (0.0 to 1.0).

1.0
offset tuple[float, float]

(ox, oy) offset subtracted from coordinates (used for cropped images).

(0.0, 0.0)

Returns:

Type Description
ndarray

The image array with trails drawn.

Raises:

Type Description
ValueError

If colors is provided and its length does not match trails.

Source code in sleap_io/rendering/overlays.py
def draw_trails(
    image: np.ndarray,
    trails: list[np.ndarray],
    color: tuple[int, int, int] = (0, 255, 0),
    colors: list[tuple[int, int, int]] | None = None,
    line_width: float = 2.0,
    alpha_fade: bool = True,
    alpha: float = 1.0,
    offset: tuple[float, float] = (0.0, 0.0),
) -> np.ndarray:
    """Draw motion trails as fading polylines on an image.

    Each trail is a polyline tracing a node or centroid position across past
    frames. Segments are drawn individually so opacity can fade from faint
    (oldest) to opaque (newest).

    All segments are rasterized into a separate transparent buffer with the
    ``kSrc`` blend mode, so overlapping joints (and crossing trails) take the
    newest segment's alpha instead of accumulating it. That buffer is then
    composited onto the image in a single pass, touching only the pixels the
    trails actually cover.

    Args:
        image: Image array of shape ``(H, W, 3)`` uint8. Modified in-place when
            possible and returned.
        trails: List of trails, where each trail is an ``(N, 2)`` float array of
            ``(x, y)`` coordinates ordered oldest to newest. Non-finite rows
            (NaN) break the polyline so missing detections leave gaps.
        color: RGB color tuple used when ``colors`` is ``None``.
        colors: Per-trail RGB color tuples. If provided, must have the same
            length as ``trails``. Overrides ``color``.
        line_width: Width of the trail line in pixels.
        alpha_fade: If ``True``, fade opacity from faint at the oldest segment to
            fully opaque at the newest. If ``False``, all segments use ``alpha``.
        alpha: Global opacity multiplier (0.0 to 1.0).
        offset: ``(ox, oy)`` offset subtracted from coordinates (used for
            cropped images).

    Returns:
        The image array with trails drawn.

    Raises:
        ValueError: If ``colors`` is provided and its length does not match
            ``trails``.
    """
    if not trails:
        return image

    if colors is not None and len(colors) != len(trails):
        raise ValueError(
            f"colors has length {len(colors)} but there are {len(trails)} "
            "trails; they must be the same length."
        )

    import skia

    ox, oy = offset

    # Ensure RGB so trail pixels can be composited back.
    image = _ensure_rgb(image)

    # Rasterize the trails into a separate transparent RGBA buffer. Drawing into
    # a dedicated buffer (rather than the frame) lets the kSrc blend mode below
    # replace overlapping joints instead of accumulating their alpha.
    h, w = image.shape[:2]
    trail_rgba = np.zeros((h, w, 4), dtype=np.uint8)
    surface = skia.Surface(trail_rgba, colorType=skia.kRGBA_8888_ColorType)
    canvas = surface.getCanvas()

    for i, trail in enumerate(trails):
        c = colors[i] if colors is not None else color
        n_points = len(trail)
        if n_points < 2:
            # A single point has no segment to draw.
            continue

        # Each segment gets its own paint so opacity can fade per segment
        # (a single skia.Path supports only one Paint).
        n_segments = n_points - 1
        for k in range(n_segments):
            x0, y0 = trail[k]
            x1, y1 = trail[k + 1]
            if not (
                np.isfinite(x0)
                and np.isfinite(y0)
                and np.isfinite(x1)
                and np.isfinite(y1)
            ):
                # Skip segments touching a missing (NaN) position.
                continue

            if alpha_fade:
                # Newest segment (k = n_segments - 1) is fully opaque; oldest
                # stays faintly visible rather than fully transparent.
                seg_frac = max((k + 1) / n_segments, 0.05)
            else:
                seg_frac = 1.0
            seg_alpha = max(0, min(255, int(seg_frac * alpha * 255)))

            paint = skia.Paint(
                Color=skia.Color(c[0], c[1], c[2], seg_alpha),
                AntiAlias=True,
                Style=skia.Paint.kStroke_Style,
                StrokeWidth=float(line_width),
                StrokeCap=skia.Paint.kRound_Cap,
                BlendMode=skia.BlendMode.kSrc,
            )
            canvas.drawLine(
                float(x0) - ox,
                float(y0) - oy,
                float(x1) - ox,
                float(y1) - oy,
                paint,
            )

    surface.flushAndSubmit()

    # Composite only the pixels the trails actually covered (typically a small
    # fraction of the frame). The buffer is unpremultiplied, so each covered
    # pixel blends once as ``out = dst * (1 - a) + src * a``.
    ys, xs = np.nonzero(trail_rgba[:, :, 3])
    if len(ys):
        a = trail_rgba[ys, xs, 3, None].astype(np.float32) / 255.0
        src = trail_rgba[ys, xs, :3].astype(np.float32)
        dst = image[ys, xs].astype(np.float32)
        image[ys, xs] = (dst * (1.0 - a) + src * a).astype(np.uint8)
    return image

get_palette(name, n_colors)

Get n colors from a named palette as RGB tuples.

Parameters:

Name Type Description Default
name Literal | str

Palette name. Built-in options: 'standard', 'distinct', 'rainbow', 'warm', 'cool', 'pastel', 'seaborn', 'tableau10', 'viridis'. With colorcet installed: 'glasbey', 'glasbey_hv', 'glasbey_cool', 'glasbey_warm'.

required
n_colors int

Number of colors needed.

required

Returns:

Type Description
list[tuple[int, int, int]]

List of (R, G, B) tuples.

Raises:

Type Description
ValueError

If palette name is not recognized.

Source code in sleap_io/rendering/colors.py
def get_palette(name: PaletteName | str, n_colors: int) -> list[tuple[int, int, int]]:
    """Get n colors from a named palette as RGB tuples.

    Args:
        name: Palette name. Built-in options: 'standard', 'distinct', 'rainbow',
            'warm', 'cool', 'pastel', 'seaborn', 'tableau10', 'viridis'.
            With colorcet installed: 'glasbey', 'glasbey_hv', 'glasbey_cool',
            'glasbey_warm'.
        n_colors: Number of colors needed.

    Returns:
        List of (R, G, B) tuples.

    Raises:
        ValueError: If palette name is not recognized.
    """
    # Try built-in palettes first
    if name in PALETTES:
        palette = PALETTES[name]
        return _extend_palette(palette, n_colors)

    # Try colorcet palettes
    import colorcet as cc

    if name in cc.palette:
        hex_colors = cc.palette[name]
        rgb_colors = [_hex_to_rgb(c) for c in hex_colors]
        return _extend_palette(rgb_colors, n_colors)

    # Unknown palette - raise error with available options
    raise ValueError(
        f"Unknown palette: {name}. "
        f"Available: {list(PALETTES.keys())} (built-in), "
        "or any colorcet palette (e.g., glasbey, glasbey_hv, fire, rainbow4)"
    )

render_image(source=None, save_path=None, *, lf_ind=None, video=None, frame_idx=None, image=None, overlay=None, overlay_alpha=0.3, overlay_palette='distinct', overlay_outline=False, overlay_outline_width=1, overlay_outline_color=None, crop=None, color_by='auto', palette='standard', marker_shape='circle', marker_size=4.0, line_width=2.0, alpha=1.0, show_nodes=True, show_edges=True, show_centroids=True, centroid_marker_size=5.0, scale=1.0, show_trails=False, trail_length=10, trail_node='centroid', trail_width=2.0, trail_alpha_fade=True, trail_alpha=1.0, trail_color=None, background='video', pre_render_callback=None, post_render_callback=None, per_instance_callback=None)

Render single frame with pose and/or segmentation overlays.

Parameters:

Name Type Description Default
source Labels | LabeledFrame | list[Instance | PredictedInstance] | None

LabeledFrame, Labels (with frame specifier), list of instances, or None. When None, image must be provided and only segmentation overlays are rendered (no poses).

None
save_path str | Path | None

Output image path (PNG/JPEG). If None, only returns array.

None
lf_ind int | None

LabeledFrame index within Labels.labeled_frames (when source is Labels).

None
video Video | int | None

Video object or video index (used with frame_idx when source is Labels).

None
frame_idx int | None

Video frame index (0-based, used with video when source is Labels).

None
image ndarray | None

Override image array (H, W) or (H, W, C) uint8. Fetched from LabeledFrame if not provided.

None
overlay ndarray | LabelImage | SegmentationMask | ROI | BoundingBox | list[SegmentationMask] | list[ROI] | list[BoundingBox] | None

Annotation data to render on the image before poses. Accepts:

  • np.ndarray: Integer label image (H, W) where 0 is background and positive values are object IDs.
  • SegmentationMask, ROI, or BoundingBox: A single annotation object (treated like a one-element list).
  • list[SegmentationMask]: Binary segmentation masks.
  • list[ROI]: Vector geometries (polygons, points, etc.).
  • list[BoundingBox]: Bounding boxes.

Applied before pose rendering so poses draw on top.

None
overlay_alpha float

Opacity for the segmentation overlay (0.0 to 1.0).

0.3
overlay_palette Literal | str

Color palette for segmentation overlay.

'distinct'
overlay_outline bool

Whether to draw outlines around segmented regions.

False
overlay_outline_width int

Outline width in pixels.

1
overlay_outline_color tuple[int, int, int] | None

RGB outline color, or None for auto-darkened.

None
crop CropSpec

Crop specification. Bounds are (x1, y1, x2, y2) where (x1, y1) is the top-left corner and (x2, y2) is the bottom-right (exclusive). Origin (0, 0) is at the image top-left. Can be:

  • Pixel coordinates (int tuple): (100, 100, 300, 300) crops from pixel (100, 100) to (300, 300).
  • Normalized coordinates (float tuple in [0.0, 1.0]): (0.25, 0.25, 0.75, 0.75) crops the center 50% of the frame. Detection is type-based: all values must be float and in range.
  • None: No cropping (default).
None
color_by Literal

Color scheme - 'track', 'instance', 'node', 'identity', 'category', or 'auto'.

'auto'
palette Literal | str

Color palette name.

'standard'
marker_shape Literal

Node marker shape.

'circle'
marker_size float

Node marker radius in pixels.

4.0
line_width float

Edge line width in pixels.

2.0
alpha float

Global transparency (0.0-1.0).

1.0
show_nodes bool

Whether to draw node markers.

True
show_edges bool

Whether to draw skeleton edges.

True
show_centroids bool

Whether to draw centroid markers from Labels.centroids. Centroids are colored by track.

True
centroid_marker_size float

Radius of centroid markers in pixels.

5.0
scale float

Output scale factor. Applied after cropping.

1.0
show_trails bool

Whether to draw motion trails tracing node or centroid positions over past frames. Only takes effect when source is a Labels object (trails need temporal context); ignored otherwise.

False
trail_length int

Number of past frames behind the current frame to include in each trail.

10
trail_node str | list[str]

Which point to trail. One of "centroid" (default), a node name, or a list of node names (one trail per node).

'centroid'
trail_width float

Trail line width in pixels.

2.0
trail_alpha_fade bool

If True, fade trails from faint (oldest) to opaque (newest).

True
trail_alpha float

Global opacity multiplier for trails (0.0 to 1.0). Combines with trail_alpha_fade.

1.0
trail_color ColorSpec | None

Uniform color for all trails. If None (default), trails are colored to match the poses (by track or instance). Accepts any color spec (RGB tuple, named color, hex, or palette index).

None
background Literal['video'] | ColorSpec

Background control. Can be: - "video": Load video frame (default). Raises error if unavailable. - Any color spec: Use solid color background, skip video loading entirely. Supports RGB tuples (255, 128, 0), float tuples (1.0, 0.5, 0.0), grayscale 128 or 0.5, named colors "black", hex "#ff8000", or palette index "tableau10[2]".

'video'
pre_render_callback Callable[[RenderContext], None] | None

Called before poses are drawn.

None
post_render_callback Callable[[RenderContext], None] | None

Called after poses are drawn.

None
per_instance_callback Callable[[InstanceContext], None] | None

Called after each instance is drawn.

None

Returns:

Type Description
ndarray

Rendered numpy array (H, W, 3) uint8.

Raises:

Type Description
ValueError

If background="video" and video unavailable.

Examples:

Render a single labeled frame:

>>> import sleap_io as sio
>>> labels = sio.load_slp("predictions.slp")
>>> lf = labels.labeled_frames[0]
>>> img = sio.render_image(lf)

Render with solid color background (no video required):

>>> img = sio.render_image(lf, background="black")
>>> img = sio.render_image(lf, background=(40, 40, 40))
>>> img = sio.render_image(lf, background="#404040")
>>> img = sio.render_image(lf, background=0.25)

Crop to a region (pixel coordinates):

>>> img = sio.render_image(lf, crop=(100, 100, 300, 300))

Normalized crop (center 50% of frame):

>>> img = sio.render_image(lf, crop=(0.25, 0.25, 0.75, 0.75))

Render and save to file:

>>> sio.render_image(labels, lf_ind=0, save_path="frame.png")
>>> sio.render_image(labels, video=0, frame_idx=42, save_path="frame.png")

Overlay a segmentation mask on a raw image (no poses):

>>> img = sio.render_image(image=frame, overlay=label_mask)

Overlay segmentation on a labeled frame (poses draw on top):

>>> img = sio.render_image(lf, overlay=label_mask, overlay_alpha=0.4)
Source code in sleap_io/rendering/core.py
def render_image(
    source: "Labels | LabeledFrame | list[Instance | PredictedInstance] | None" = None,
    save_path: str | Path | None = None,
    *,
    # Frame specification (for Labels input)
    lf_ind: int | None = None,
    video: "Video | int | None" = None,
    frame_idx: int | None = None,
    # Image override
    image: np.ndarray | None = None,
    # Annotation overlay
    overlay: (
        "np.ndarray | LabelImage"
        " | SegmentationMask | ROI | BoundingBox"
        " | list[SegmentationMask] | list[ROI] | list[BoundingBox]"
        " | None"
    ) = None,
    overlay_alpha: float = 0.3,
    overlay_palette: PaletteName | str = "distinct",
    overlay_outline: bool = False,
    overlay_outline_width: int = 1,
    overlay_outline_color: tuple[int, int, int] | None = None,
    # Cropping
    crop: CropSpec = None,
    # Appearance
    color_by: ColorScheme = "auto",
    palette: PaletteName | str = "standard",
    marker_shape: MarkerShape = "circle",
    marker_size: float = 4.0,
    line_width: float = 2.0,
    alpha: float = 1.0,
    show_nodes: bool = True,
    show_edges: bool = True,
    show_centroids: bool = True,
    centroid_marker_size: float = 5.0,
    scale: float = 1.0,
    # Motion trails
    show_trails: bool = False,
    trail_length: int = 10,
    trail_node: str | list[str] = "centroid",
    trail_width: float = 2.0,
    trail_alpha_fade: bool = True,
    trail_alpha: float = 1.0,
    trail_color: ColorSpec | None = None,
    # Background control
    background: Literal["video"] | ColorSpec = "video",
    # Callbacks
    pre_render_callback: Callable[[RenderContext], None] | None = None,
    post_render_callback: Callable[[RenderContext], None] | None = None,
    per_instance_callback: Callable[[InstanceContext], None] | None = None,
) -> np.ndarray:
    """Render single frame with pose and/or segmentation overlays.

    Args:
        source: LabeledFrame, Labels (with frame specifier), list of instances,
            or ``None``. When ``None``, ``image`` must be provided and only
            segmentation overlays are rendered (no poses).
        save_path: Output image path (PNG/JPEG). If None, only returns array.
        lf_ind: LabeledFrame index within Labels.labeled_frames (when source is Labels).
        video: Video object or video index (used with frame_idx when source is Labels).
        frame_idx: Video frame index (0-based, used with video when source is Labels).
        image: Override image array (H, W) or (H, W, C) uint8. Fetched from
            LabeledFrame if not provided.
        overlay: Annotation data to render on the image before poses. Accepts:

            - ``np.ndarray``: Integer label image ``(H, W)`` where 0 is
              background and positive values are object IDs.
            - ``SegmentationMask``, ``ROI``, or ``BoundingBox``: A single
              annotation object (treated like a one-element list).
            - ``list[SegmentationMask]``: Binary segmentation masks.
            - ``list[ROI]``: Vector geometries (polygons, points, etc.).
            - ``list[BoundingBox]``: Bounding boxes.

            Applied before pose rendering so poses draw on top.
        overlay_alpha: Opacity for the segmentation overlay (0.0 to 1.0).
        overlay_palette: Color palette for segmentation overlay.
        overlay_outline: Whether to draw outlines around segmented regions.
        overlay_outline_width: Outline width in pixels.
        overlay_outline_color: RGB outline color, or ``None`` for auto-darkened.
        crop: Crop specification. Bounds are (x1, y1, x2, y2) where (x1, y1) is
            the top-left corner and (x2, y2) is the bottom-right (exclusive).
            Origin (0, 0) is at the image top-left. Can be:

            - **Pixel coordinates** (int tuple): ``(100, 100, 300, 300)`` crops
              from pixel (100, 100) to (300, 300).
            - **Normalized coordinates** (float tuple in [0.0, 1.0]):
              ``(0.25, 0.25, 0.75, 0.75)`` crops the center 50% of the frame.
              Detection is type-based: all values must be ``float`` and in range.
            - ``None``: No cropping (default).
        color_by: Color scheme - 'track', 'instance', 'node', 'identity',
            'category', or 'auto'.
        palette: Color palette name.
        marker_shape: Node marker shape.
        marker_size: Node marker radius in pixels.
        line_width: Edge line width in pixels.
        alpha: Global transparency (0.0-1.0).
        show_nodes: Whether to draw node markers.
        show_edges: Whether to draw skeleton edges.
        show_centroids: Whether to draw centroid markers from
            ``Labels.centroids``. Centroids are colored by track.
        centroid_marker_size: Radius of centroid markers in pixels.
        scale: Output scale factor. Applied after cropping.
        show_trails: Whether to draw motion trails tracing node or centroid
            positions over past frames. Only takes effect when ``source`` is a
            ``Labels`` object (trails need temporal context); ignored otherwise.
        trail_length: Number of past frames behind the current frame to include
            in each trail.
        trail_node: Which point to trail. One of ``"centroid"`` (default), a
            node name, or a list of node names (one trail per node).
        trail_width: Trail line width in pixels.
        trail_alpha_fade: If ``True``, fade trails from faint (oldest) to opaque
            (newest).
        trail_alpha: Global opacity multiplier for trails (0.0 to 1.0). Combines
            with ``trail_alpha_fade``.
        trail_color: Uniform color for all trails. If ``None`` (default), trails
            are colored to match the poses (by track or instance). Accepts any
            color spec (RGB tuple, named color, hex, or palette index).
        background: Background control. Can be:
            - ``"video"``: Load video frame (default). Raises error if unavailable.
            - Any color spec: Use solid color background, skip video loading entirely.
              Supports RGB tuples ``(255, 128, 0)``, float tuples ``(1.0, 0.5, 0.0)``,
              grayscale ``128`` or ``0.5``, named colors ``"black"``, hex ``"#ff8000"``,
              or palette index ``"tableau10[2]"``.
        pre_render_callback: Called before poses are drawn.
        post_render_callback: Called after poses are drawn.
        per_instance_callback: Called after each instance is drawn.

    Returns:
        Rendered numpy array (H, W, 3) uint8.

    Raises:
        ValueError: If background="video" and video unavailable.

    Examples:
        Render a single labeled frame:

        >>> import sleap_io as sio
        >>> labels = sio.load_slp("predictions.slp")
        >>> lf = labels.labeled_frames[0]
        >>> img = sio.render_image(lf)

        Render with solid color background (no video required):

        >>> img = sio.render_image(lf, background="black")
        >>> img = sio.render_image(lf, background=(40, 40, 40))
        >>> img = sio.render_image(lf, background="#404040")
        >>> img = sio.render_image(lf, background=0.25)

        Crop to a region (pixel coordinates):

        >>> img = sio.render_image(lf, crop=(100, 100, 300, 300))

        Normalized crop (center 50% of frame):

        >>> img = sio.render_image(lf, crop=(0.25, 0.25, 0.75, 0.75))

        Render and save to file:

        >>> sio.render_image(labels, lf_ind=0, save_path="frame.png")
        >>> sio.render_image(labels, video=0, frame_idx=42, save_path="frame.png")

        Overlay a segmentation mask on a raw image (no poses):

        >>> img = sio.render_image(image=frame, overlay=label_mask)

        Overlay segmentation on a labeled frame (poses draw on top):

        >>> img = sio.render_image(lf, overlay=label_mask, overlay_alpha=0.4)
    """
    import skia  # noqa: F401

    from sleap_io.model.instance import Instance, PredictedInstance
    from sleap_io.model.labeled_frame import LabeledFrame
    from sleap_io.model.labels import Labels

    # Handle background parameter
    use_video = background == "video"
    background_color: tuple[int, int, int] | None = None
    if not use_video:
        background_color = resolve_color(background)

    # Resolve source to LabeledFrame or instances
    if isinstance(source, Labels):
        lf = None
        has_centroids = show_centroids and bool(source.centroids)
        if video is not None and frame_idx is not None:
            # Render by video + frame_idx
            target_video = source.videos[video] if isinstance(video, int) else video
            lf_list = source.find(target_video, frame_idx)
            if lf_list:
                lf = lf_list[0]
            elif not has_centroids:
                raise ValueError(
                    f"No labeled frame found for video {target_video} "
                    f"at frame {frame_idx}"
                )
        elif lf_ind is not None:
            # Render by labeled frame index
            lf = source.labeled_frames[lf_ind]
        elif source.labeled_frames:
            # Default to first labeled frame
            lf = source.labeled_frames[0]
        elif not has_centroids:
            raise ValueError("No labeled frames to render")

        if lf is not None:
            instances = list(lf.instances)
            if instances:
                skeleton = instances[0].skeleton
            elif source.skeletons:
                # No instances but skeletons exist: use the first skeleton for
                # pose-rendering metadata (e.g. trail node resolution).
                skeleton = source.skeletons[0]
            else:
                # No instances and no skeletons — segmentation/overlay-only
                # frame (e.g. bottom-up mask tracking). Fall through with empty
                # pose state so the background and any overlay (masks, label
                # images, ROIs, bboxes) still render instead of crashing.
                # Mirrors the LabeledFrame branch below.
                skeleton = None
            edge_inds = skeleton.edge_inds if skeleton is not None else []
            node_names = (
                [n.name for n in skeleton.nodes] if skeleton is not None else []
            )
            fidx_for_callback = lf.frame_idx
        else:
            # Centroid-only / spatial-only mode: no labeled frames.
            instances = []
            skeleton = None
            edge_inds = []
            node_names = []
            fidx_for_callback = frame_idx if frame_idx is not None else 0

        # Get track info using O(1) lookup map
        n_tracks = len(source.tracks)
        has_tracks = n_tracks > 0
        img_track_idx_map = {id(t): i for i, t in enumerate(source.tracks)}
        track_indices = []
        for inst in instances:
            tidx = img_track_idx_map.get(id(inst.track)) if inst.track else None
            track_indices.append(tidx if tidx is not None else 0)

        # Convert instances to point arrays (needed for both image size and rendering)
        instances_points = [inst.numpy() for inst in instances]

        # Get image if not provided
        if image is None:
            video_obj = (
                lf.video
                if lf is not None
                else (source.videos[0] if source.videos else None)
            )
            if background_color is not None:
                # Solid color background - skip video loading entirely
                if (
                    video_obj is not None
                    and hasattr(video_obj, "shape")
                    and video_obj.shape is not None
                ):
                    h, w = video_obj.shape[1:3]
                else:
                    # Estimate from points or default
                    if instances_points:
                        h, w = _estimate_frame_size(instances_points)
                    else:
                        h, w = 512, 512
                image = _create_blank_frame(h, w, background_color)[:, :, :3]
            else:
                # Load video frame
                try:
                    if lf is not None:
                        image = lf.image
                    elif video_obj is not None and frame_idx is not None:
                        image = video_obj[frame_idx]
                    else:
                        image = None
                    if image is None:
                        raise ValueError("No image available")
                except Exception:
                    raise ValueError(
                        "Video unavailable. Specify a background color to render "
                        "without video, e.g., background='black' or "
                        "background=(40, 40, 40)."
                    )

    elif isinstance(source, LabeledFrame):
        lf = source
        instances = list(lf.instances)
        if instances:
            skeleton = instances[0].skeleton
            edge_inds = skeleton.edge_inds
            node_names = [n.name for n in skeleton.nodes]
        else:
            # No instances — segmentation/overlay-only mode. Fall through with
            # empty pose-rendering state so the video frame and any overlay
            # (masks, label images, ROIs, bboxes) still render. Mirrors the
            # centroid-only path in the `Labels` branch above.
            edge_inds = []
            node_names = []
        fidx_for_callback = lf.frame_idx
        track_indices = None
        n_tracks = 0
        has_tracks = False

        # Convert instances to point arrays (needed for both image size and rendering)
        instances_points = [inst.numpy() for inst in instances]

        # Get image if not provided
        if image is None:
            if background_color is not None:
                # Solid color background - skip video loading entirely
                video_obj = lf.video
                if hasattr(video_obj, "shape") and video_obj.shape is not None:
                    h, w = video_obj.shape[1:3]
                else:
                    # Estimate from points
                    h, w = _estimate_frame_size(instances_points)
                image = _create_blank_frame(h, w, background_color)[:, :, :3]
            else:
                # Load video frame
                try:
                    image = lf.image
                    if image is None:
                        raise ValueError("No image available")
                except Exception:
                    raise ValueError(
                        "Video unavailable. Specify a background color to render "
                        "without video, e.g., background='black' or "
                        "background=(40, 40, 40)."
                    )

    elif isinstance(source, list) and all(
        isinstance(x, (Instance, PredictedInstance)) for x in source
    ):
        instances = source
        if not instances:
            raise ValueError("Empty instances list")
        skeleton = instances[0].skeleton
        edge_inds = skeleton.edge_inds
        node_names = [n.name for n in skeleton.nodes]
        fidx_for_callback = 0
        track_indices = None
        n_tracks = 0
        has_tracks = False

        # Convert instances to point arrays
        instances_points = [inst.numpy() for inst in instances]

        if image is None:
            raise ValueError(
                "image parameter required when source is list of instances"
            )

    elif source is None:
        # No poses — overlay-only or image-only mode
        if image is None:
            raise ValueError("image parameter required when source is None")
        instances = []
        instances_points = []
        edge_inds = []
        node_names = []
        fidx_for_callback = 0
        track_indices = None
        n_tracks = 0
        has_tracks = False

    else:
        raise TypeError(
            f"source must be Labels, LabeledFrame, list of instances, "
            f"or None, got {type(source)}"
        )

    # Auto-use the frame's segmentation masks as overlay when no explicit
    # overlay is given and the frame carries masks. Mirrors render_video's
    # auto-overlay behavior so a segmentation-only frame still draws its masks.
    # Only masks are auto-resolved here (not label_images): label_image overlays
    # are a Labels/render_video-level concept and _apply_overlay does not accept a
    # list[LabelImage] in the single-frame path. An explicit overlay always wins.
    if (
        overlay is None
        and isinstance(source, (Labels, LabeledFrame))
        and lf is not None
        and lf.masks
    ):
        overlay = list(lf.masks)

    # Determine color scheme up front so track-colored overlays (masks/ROIs/
    # bboxes) can match the pose/centroid/trail track colors. Consumed below by
    # both the overlay block and the pose render_frame call.
    resolved_scheme = determine_color_scheme(
        has_tracks=has_tracks,
        is_single_image=True,
        scheme=color_by,
    )

    # Apply cropping if specified
    render_image_data = image
    render_points = instances_points
    crop_offset: tuple[float, float] = (0.0, 0.0)
    if crop is not None:
        h, w = image.shape[:2]
        # Resolve normalized or pixel coordinates
        crop_bounds = _resolve_crop(crop, (h, w))
        crop_offset = (float(crop_bounds[0]), float(crop_bounds[1]))

        render_image_data, render_points, _ = _apply_crop(
            image, instances_points, crop_bounds
        )

    # Apply annotation overlay before pose rendering
    if overlay is not None:
        # Ensure image is RGB for color blending
        if render_image_data.ndim == 2:
            render_image_data = np.stack([render_image_data] * 3, axis=-1)
        elif render_image_data.ndim == 3 and render_image_data.shape[2] == 1:
            render_image_data = np.repeat(render_image_data, 3, axis=2)
        # Crop overlay to match cropped image region
        render_overlay = overlay
        if crop is not None and isinstance(overlay, np.ndarray):
            x1, y1, x2, y2 = crop_bounds
            oh, ow = overlay.shape[:2]
            render_overlay = overlay[max(0, y1) : min(oh, y2), max(0, x1) : min(ow, x2)]
        # Color overlay elements (masks/ROIs/bboxes) by track identity when
        # color_by resolves to "track", matching poses/centroids/trails (same
        # `palette`). Otherwise fall through to positional `overlay_palette`
        # coloring. Gated on a Labels source with tracks (only that branch builds
        # the track index map; `has_tracks` mirrors render_video so track-less
        # labels stay positional). Untracked elements fall back to the first
        # color.
        overlay_colors = None
        if (
            resolved_scheme == "track"
            and isinstance(source, Labels)
            and has_tracks
            and isinstance(render_overlay, list)
            and render_overlay
            and not _is_label_image(render_overlay[0])
        ):
            ov_pal = get_palette(palette, max(len(source.tracks), 1))
            overlay_colors = []
            for el in render_overlay:
                t = getattr(el, "track", None)
                tidx = img_track_idx_map.get(id(t)) if t is not None else None
                overlay_colors.append(
                    ov_pal[tidx % len(ov_pal)] if tidx is not None else ov_pal[0]
                )
        _apply_overlay(
            render_image_data,
            render_overlay,
            alpha=overlay_alpha,
            palette=overlay_palette,
            outline=overlay_outline,
            outline_width=overlay_outline_width,
            outline_color=overlay_outline_color,
            colors=overlay_colors,
        )

    # Draw motion trails behind the poses and centroids. Trails need temporal
    # context, so they are only drawn when the source is a Labels object. They
    # are drawn even when the current frame has no instances, since past frames
    # may still contribute (matching render_video).
    if (
        show_trails
        and isinstance(source, Labels)
        and trail_length > 0
        and skeleton is not None
    ):
        from sleap_io.rendering.overlays import draw_trails as _draw_trails

        trail_targets = _resolve_trail_node(trail_node, skeleton)
        frame_idx_to_lf = {lframe.frame_idx: lframe for lframe in source.find(lf.video)}
        n_trail_colors = _n_trail_palette_colors(
            has_tracks, n_tracks, frame_idx_to_lf.values()
        )
        trail_palette = get_palette(palette, n_trail_colors)
        trails, trail_colors = _compute_trails(
            fidx=fidx_for_callback,
            frame_idx_to_lf=frame_idx_to_lf,
            trail_length=trail_length,
            trail_targets=trail_targets,
            track_idx_map=img_track_idx_map,
            palette_colors=trail_palette,
            has_tracks=has_tracks,
        )
        if trails:
            # A uniform trail_color overrides the per-track palette colors.
            trail_draw_kwargs: dict = {}
            if trail_color is not None:
                trail_draw_kwargs["color"] = resolve_color(trail_color)
            else:
                trail_draw_kwargs["colors"] = trail_colors
            # trail_width is NOT pre-scaled: the trail is drawn here, then the
            # whole image is upscaled once by `scale` inside render_frame, so
            # the final width matches pose edges (line_width * scale).
            render_image_data = _draw_trails(
                render_image_data,
                trails,
                line_width=trail_width,
                alpha_fade=trail_alpha_fade,
                alpha=trail_alpha,
                offset=crop_offset,
                **trail_draw_kwargs,
            )

    # Draw centroids on the image.
    if show_centroids and isinstance(source, Labels) and source.centroids:
        from sleap_io.rendering.overlays import draw_centroids as _draw_centroids

        render_fidx = fidx_for_callback
        if lf is not None:
            # Scope to the rendered frame's own video. A centroid on a
            # *different* video that happens to share this frame index must not
            # bleed in, so read this frame's centroids directly (mirroring
            # render_video's per-frame `lf.centroids`).
            frame_centroids = list(lf.centroids)
        else:
            # No labeled frame resolved — only reachable via an explicit
            # video+frame_idx that matched no frame, so `video` is a concrete
            # spec. Scope centroids to it so a *different* video's centroid that
            # shares this frame index can't bleed in (mirrors render_video's
            # get_centroids(video=target_video)).
            target_video = source.videos[video] if isinstance(video, int) else video
            frame_centroids = source.get_centroids(
                video=target_video, frame_idx=render_fidx
            )
        if frame_centroids:
            if render_image_data.ndim == 2:
                render_image_data = np.stack([render_image_data] * 3, axis=-1)
            centroid_pal = get_palette(palette, max(len(source.tracks), 1))
            c_colors = []
            for c in frame_centroids:
                tidx = img_track_idx_map.get(id(c.track)) if c.track else None
                if tidx is not None:
                    c_colors.append(centroid_pal[tidx % len(centroid_pal)])
                else:
                    c_colors.append(centroid_pal[0] if centroid_pal else (0, 255, 0))
            # centroid_marker_size is NOT pre-scaled: the centroids are drawn
            # here, then the whole image is upscaled once by `scale` inside
            # render_frame, so the final radius matches pose nodes
            # (marker_size * scale).
            render_image_data = _draw_centroids(
                render_image_data,
                frame_centroids,
                colors=c_colors,
                marker_size=centroid_marker_size,
                offset=crop_offset,
            )

    # Short-circuit: overlay-only mode (no poses to render)
    if source is None:
        # Scale if needed
        if scale != 1.0:
            render_image_data = _scale_frame(
                _prepare_frame_rgba(render_image_data), scale
            )[:, :, :3]

        if save_path is not None:
            _save_image(render_image_data, save_path)

        return render_image_data

    # Build instance metadata for callbacks
    instance_metadata = []
    for inst in instances:
        meta = {}
        if hasattr(inst, "track") and inst.track is not None:
            meta["track_name"] = inst.track.name
        if hasattr(inst, "score"):
            meta["confidence"] = inst.score
        instance_metadata.append(meta)

    # Compute per-instance identity indices when the resolved scheme is
    # "identity", mirroring the track-index plumbing.
    identity_indices = None
    n_identities = 0
    if resolved_scheme == "identity":
        catalog = source.identities if isinstance(source, Labels) else None
        identity_indices, n_identities = _compute_identity_coloring(instances, catalog)

    # Compute per-instance category indices when the resolved scheme is
    # "category", mirroring the identity plumbing.
    category_indices = None
    n_categories = 0
    if resolved_scheme == "category":
        catalog = source.categories if isinstance(source, Labels) else None
        category_indices, n_categories = _compute_category_coloring(instances, catalog)

    # Render
    rendered = render_frame(
        frame=render_image_data,
        instances_points=render_points,
        edge_inds=edge_inds,
        node_names=node_names,
        color_by=resolved_scheme,
        palette=palette,
        marker_shape=marker_shape,
        marker_size=marker_size,
        line_width=line_width,
        alpha=alpha,
        show_nodes=show_nodes,
        show_edges=show_edges,
        scale=scale,
        track_indices=track_indices,
        n_tracks=n_tracks,
        identity_indices=identity_indices,
        n_identities=n_identities,
        category_indices=category_indices,
        n_categories=n_categories,
        pre_render_callback=pre_render_callback,
        post_render_callback=post_render_callback,
        per_instance_callback=per_instance_callback,
        frame_idx=fidx_for_callback,
        instance_metadata=instance_metadata,
        crop_offset=crop_offset,
    )

    # Save if save_path provided
    if save_path is not None:
        _save_image(rendered, save_path)

    return rendered

render_video(source, save_path=None, *, video=None, frame_inds=None, start=None, end=None, include_unlabeled=None, overlay=None, overlay_alpha=0.3, overlay_palette='distinct', overlay_outline=False, overlay_outline_width=1, overlay_outline_color=None, crop=None, preset=None, scale=1.0, color_by='auto', palette='standard', marker_shape='circle', marker_size=4.0, line_width=2.0, alpha=1.0, show_nodes=True, show_edges=True, show_centroids=True, centroid_marker_size=5.0, show_trails=False, trail_length=10, trail_node='centroid', trail_width=2.0, trail_alpha_fade=True, trail_alpha=1.0, trail_color=None, fps=None, codec='libx264', crf=25, x264_preset='superfast', background='video', pre_render_callback=None, post_render_callback=None, per_instance_callback=None, progress_callback=None, show_progress=True)

Render video with pose overlays.

Parameters:

Name Type Description Default
source Labels | list[LabeledFrame]

Labels object or list of LabeledFrames to render.

required
save_path str | Path | None

Output video path. If None, returns list of rendered arrays.

None
video Video | int | None

Video to render from (default: first video in Labels).

None
frame_inds list[int] | None

Specific frame indices to render.

None
start int | None

Start frame index (inclusive).

None
end int | None

End frame index (exclusive).

None
include_unlabeled bool | None

If True, render all frames in range even if they have no LabeledFrame (just shows video frame without poses). Default None, which resolves to False unless auto-overlay detection enables it (when label_images exist for the target video and no explicit overlay is given).

None
overlay ndarray | list[LabelImage] | list[SegmentationMask] | list[ROI] | list[BoundingBox] | Callable[[int], ndarray] | None

Per-frame annotation overlay. Accepts:

  • np.ndarray: 3-D array (T, H, W) of integer label images indexed by frame number, or 2-D (H, W) for a static overlay.
  • list[SegmentationMask | ROI | BoundingBox]: Indexed by position — the item at list index i is applied to frame i. One overlay per frame.
  • Callable[[int], np.ndarray]: Called with the frame index, returns an (H, W) label image for that frame.
None
overlay_alpha float

Opacity for the annotation overlay (0.0 to 1.0).

0.3
overlay_palette Literal | str

Color palette for overlay coloring.

'distinct'
overlay_outline bool

Draw outlines around segmented regions (label images).

False
overlay_outline_width int

Outline width in pixels.

1
overlay_outline_color tuple[int, int, int] | None

RGB outline color, or None for auto-darkened.

None
crop CropSpec

Static crop applied uniformly to all frames. Bounds are (x1, y1, x2, y2) where (x1, y1) is the top-left corner and (x2, y2) is the bottom-right (exclusive). Supports:

  • Pixel coordinates (int tuple): (100, 100, 300, 300)
  • Normalized coordinates (float tuple in [0.0, 1.0]): (0.25, 0.25, 0.75, 0.75) crops the center 50%.
  • None: No cropping (default).
None
preset Literal['preview', 'draft', 'final'] | None

Quality preset ('preview'=0.25x, 'draft'=0.5x, 'final'=1.0x).

None
scale float

Scale factor (overrides preset if both provided).

1.0
color_by Literal

Color scheme - 'track', 'instance', 'node', 'identity', 'category', or 'auto'.

'auto'
palette Literal | str

Color palette name.

'standard'
marker_shape Literal

Node marker shape.

'circle'
marker_size float

Node marker radius in pixels.

4.0
line_width float

Edge line width in pixels.

2.0
alpha float

Global transparency (0.0-1.0).

1.0
show_nodes bool

Whether to draw node markers.

True
show_edges bool

Whether to draw skeleton edges.

True
show_centroids bool

Whether to draw centroid markers from Labels.centroids. Centroids are colored by track.

True
centroid_marker_size float

Radius of centroid markers in pixels.

5.0
show_trails bool

Whether to draw motion trails tracing node or centroid positions over past frames.

False
trail_length int

Number of past frames behind each frame to include in the trail.

10
trail_node str | list[str]

Which point to trail. One of "centroid" (default), a node name, or a list of node names (one trail per node).

'centroid'
trail_width float

Trail line width in pixels.

2.0
trail_alpha_fade bool

If True, fade trails from faint (oldest) to opaque (newest).

True
trail_alpha float

Global opacity multiplier for trails (0.0 to 1.0). Combines with trail_alpha_fade.

1.0
trail_color ColorSpec | None

Uniform color for all trails. If None (default), trails are colored to match the poses (by track or instance). Accepts any color spec (RGB tuple, named color, hex, or palette index).

None
fps float | None

Output frame rate (default: source video fps).

None
codec str

Video codec for encoding.

'libx264'
crf int

Constant rate factor for quality (2-32, lower=better). Default 25.

25
x264_preset str

H.264 encoding preset (ultrafast, superfast, fast, medium, slow).

'superfast'
background Literal['video'] | ColorSpec

Background control. Can be: - "video": Load video frame (default). Raises error if unavailable. - Any color spec: Use solid color background, skip video loading entirely. Supports RGB tuples (255, 128, 0), float tuples (1.0, 0.5, 0.0), grayscale 128 or 0.5, named colors "black", hex "#ff8000", or palette index "tableau10[2]".

'video'
pre_render_callback Callable[[RenderContext], None] | None

Called before each frame's poses are drawn.

None
post_render_callback Callable[[RenderContext], None] | None

Called after each frame's poses are drawn.

None
per_instance_callback Callable[[InstanceContext], None] | None

Called after each instance is drawn.

None
progress_callback Callable[[int, int], bool] | None

Called with (current, total), return False to cancel.

None
show_progress bool

Show tqdm progress bar.

True

Returns:

Type Description
Video | list[ndarray]

If save_path provided: Video object pointing to output file. If save_path is None: List of rendered numpy arrays (H, W, 3) uint8.

Raises:

Type Description
ValueError

If background="video" and video unavailable.

Examples:

Render full video with pose overlays:

>>> import sleap_io as sio
>>> labels = sio.load_slp("predictions.slp")
>>> sio.render_video(labels, "output.mp4")

Fast preview at reduced resolution:

>>> sio.render_video(labels, "preview.mp4", preset="preview")

Get rendered frames as numpy arrays:

>>> frames = sio.render_video(labels)
Source code in sleap_io/rendering/core.py
def render_video(
    source: "Labels | list[LabeledFrame]",
    save_path: str | Path | None = None,
    *,
    # Video selection
    video: "Video | int | None" = None,
    # Frame selection
    frame_inds: list[int] | None = None,
    start: int | None = None,
    end: int | None = None,
    include_unlabeled: bool | None = None,
    # Annotation overlay
    overlay: (
        "np.ndarray"
        " | list[LabelImage] | list[SegmentationMask] | list[ROI] | list[BoundingBox]"
        " | Callable[[int], np.ndarray] | None"
    ) = None,
    overlay_alpha: float = 0.3,
    overlay_palette: PaletteName | str = "distinct",
    overlay_outline: bool = False,
    overlay_outline_width: int = 1,
    overlay_outline_color: tuple[int, int, int] | None = None,
    # Cropping
    crop: CropSpec = None,
    # Quality/scale
    preset: Literal["preview", "draft", "final"] | None = None,
    scale: float = 1.0,
    # Appearance
    color_by: ColorScheme = "auto",
    palette: PaletteName | str = "standard",
    marker_shape: MarkerShape = "circle",
    marker_size: float = 4.0,
    line_width: float = 2.0,
    alpha: float = 1.0,
    show_nodes: bool = True,
    show_edges: bool = True,
    show_centroids: bool = True,
    centroid_marker_size: float = 5.0,
    # Motion trails
    show_trails: bool = False,
    trail_length: int = 10,
    trail_node: str | list[str] = "centroid",
    trail_width: float = 2.0,
    trail_alpha_fade: bool = True,
    trail_alpha: float = 1.0,
    trail_color: ColorSpec | None = None,
    # Video encoding
    fps: float | None = None,
    codec: str = "libx264",
    crf: int = 25,
    x264_preset: str = "superfast",
    # Background control
    background: Literal["video"] | ColorSpec = "video",
    # Callbacks
    pre_render_callback: Callable[[RenderContext], None] | None = None,
    post_render_callback: Callable[[RenderContext], None] | None = None,
    per_instance_callback: Callable[[InstanceContext], None] | None = None,
    # Progress
    progress_callback: Callable[[int, int], bool] | None = None,
    show_progress: bool = True,
) -> "Video | list[np.ndarray]":
    """Render video with pose overlays.

    Args:
        source: Labels object or list of LabeledFrames to render.
        save_path: Output video path. If None, returns list of rendered arrays.
        video: Video to render from (default: first video in Labels).
        frame_inds: Specific frame indices to render.
        start: Start frame index (inclusive).
        end: End frame index (exclusive).
        include_unlabeled: If True, render all frames in range even if they have
            no LabeledFrame (just shows video frame without poses). Default None,
            which resolves to False unless auto-overlay detection enables it (when
            label_images exist for the target video and no explicit overlay is given).
        overlay: Per-frame annotation overlay. Accepts:

            - ``np.ndarray``: 3-D array ``(T, H, W)`` of integer label images
              indexed by frame number, or 2-D ``(H, W)`` for a static overlay.
            - ``list[SegmentationMask | ROI | BoundingBox]``: Indexed by
              position — the item at list index ``i`` is applied to frame
              ``i``. One overlay per frame.
            - ``Callable[[int], np.ndarray]``: Called with the frame index,
              returns an ``(H, W)`` label image for that frame.
        overlay_alpha: Opacity for the annotation overlay (0.0 to 1.0).
        overlay_palette: Color palette for overlay coloring.
        overlay_outline: Draw outlines around segmented regions (label images).
        overlay_outline_width: Outline width in pixels.
        overlay_outline_color: RGB outline color, or ``None`` for auto-darkened.
        crop: Static crop applied uniformly to all frames. Bounds are
            (x1, y1, x2, y2) where (x1, y1) is the top-left corner and (x2, y2)
            is the bottom-right (exclusive). Supports:

            - **Pixel coordinates** (int tuple): ``(100, 100, 300, 300)``
            - **Normalized coordinates** (float tuple in [0.0, 1.0]):
              ``(0.25, 0.25, 0.75, 0.75)`` crops the center 50%.
            - ``None``: No cropping (default).
        preset: Quality preset ('preview'=0.25x, 'draft'=0.5x, 'final'=1.0x).
        scale: Scale factor (overrides preset if both provided).
        color_by: Color scheme - 'track', 'instance', 'node', 'identity',
            'category', or 'auto'.
        palette: Color palette name.
        marker_shape: Node marker shape.
        marker_size: Node marker radius in pixels.
        line_width: Edge line width in pixels.
        alpha: Global transparency (0.0-1.0).
        show_nodes: Whether to draw node markers.
        show_edges: Whether to draw skeleton edges.
        show_centroids: Whether to draw centroid markers from
            ``Labels.centroids``. Centroids are colored by track.
        centroid_marker_size: Radius of centroid markers in pixels.
        show_trails: Whether to draw motion trails tracing node or centroid
            positions over past frames.
        trail_length: Number of past frames behind each frame to include in the
            trail.
        trail_node: Which point to trail. One of ``"centroid"`` (default), a
            node name, or a list of node names (one trail per node).
        trail_width: Trail line width in pixels.
        trail_alpha_fade: If ``True``, fade trails from faint (oldest) to opaque
            (newest).
        trail_alpha: Global opacity multiplier for trails (0.0 to 1.0). Combines
            with ``trail_alpha_fade``.
        trail_color: Uniform color for all trails. If ``None`` (default), trails
            are colored to match the poses (by track or instance). Accepts any
            color spec (RGB tuple, named color, hex, or palette index).
        fps: Output frame rate (default: source video fps).
        codec: Video codec for encoding.
        crf: Constant rate factor for quality (2-32, lower=better). Default 25.
        x264_preset: H.264 encoding preset (ultrafast, superfast, fast, medium, slow).
        background: Background control. Can be:
            - ``"video"``: Load video frame (default). Raises error if unavailable.
            - Any color spec: Use solid color background, skip video loading entirely.
              Supports RGB tuples ``(255, 128, 0)``, float tuples ``(1.0, 0.5, 0.0)``,
              grayscale ``128`` or ``0.5``, named colors ``"black"``, hex ``"#ff8000"``,
              or palette index ``"tableau10[2]"``.
        pre_render_callback: Called before each frame's poses are drawn.
        post_render_callback: Called after each frame's poses are drawn.
        per_instance_callback: Called after each instance is drawn.
        progress_callback: Called with (current, total), return False to cancel.
        show_progress: Show tqdm progress bar.

    Returns:
        If save_path provided: Video object pointing to output file.
        If save_path is None: List of rendered numpy arrays (H, W, 3) uint8.

    Raises:
        ValueError: If background="video" and video unavailable.

    Examples:
        Render full video with pose overlays:

        >>> import sleap_io as sio
        >>> labels = sio.load_slp("predictions.slp")
        >>> sio.render_video(labels, "output.mp4")

        Fast preview at reduced resolution:

        >>> sio.render_video(labels, "preview.mp4", preset="preview")

        Get rendered frames as numpy arrays:

        >>> frames = sio.render_video(labels)
    """
    import skia  # noqa: F401

    from sleap_io.model.labeled_frame import LabeledFrame
    from sleap_io.model.labels import Labels
    from sleap_io.model.video import Video as VideoModel

    # Handle background parameter
    use_video = background == "video"
    background_color: tuple[int, int, int] | None = None
    if not use_video:
        background_color = resolve_color(background)

    # Handle preset
    if preset is not None and preset in PRESETS:
        scale = PRESETS[preset]["scale"]

    # Whether this video has centroids to render (populated below).
    _has_video_centroids: bool = False

    # Resolve source
    if isinstance(source, Labels):
        labels = source

        # Resolve video
        if video is None:
            if not labels.videos:
                raise ValueError("Labels has no videos")
            target_video = labels.videos[0]
        elif isinstance(video, int):
            target_video = labels.videos[video]
        else:
            target_video = video

        # Get labeled frames for this video
        labeled_frames = labels.find(target_video)

        # Sort by frame index
        labeled_frames = sorted(labeled_frames, key=lambda lf: lf.frame_idx)

        # Check for spatial annotations (label_images, masks, bboxes, rois,
        # centroids) that can be rendered even without labeled frames (poses).
        has_spatial = bool(
            labels.get_label_images(video=target_video)
            or labels.get_masks(video=target_video)
            or labels.get_bboxes(video=target_video)
            or labels.get_rois(video=target_video)
            or labels.get_centroids(video=target_video)
        )

        if not labeled_frames and not has_spatial:
            raise ValueError(f"No labeled frames found for video {target_video}")

        # Get skeleton info (not required when rendering only spatial
        # annotations)
        skeleton = labels.skeletons[0] if labels.skeletons else None
        if skeleton is None and labeled_frames:
            for lf in labeled_frames:
                for inst in lf.instances:
                    skeleton = inst.skeleton
                    break
                if skeleton:
                    break

        if skeleton is not None:
            edge_inds = skeleton.edge_inds
            node_names = [n.name for n in skeleton.nodes]
        else:
            # Only raise if frames have instances (which need a skeleton)
            has_instances = any(len(lf.instances) > 0 for lf in labeled_frames)
            if has_instances:
                raise ValueError("No skeleton found in labels")
            edge_inds = []
            node_names = []

        n_tracks = len(labels.tracks)
        has_tracks = n_tracks > 0

        # Auto-use label_images as overlay when no explicit overlay is
        # provided and the file has label images for this video.
        if overlay is None and labels.label_images:
            video_label_images = labels.get_label_images(video=target_video)
            if video_label_images:
                overlay = video_label_images
                if include_unlabeled is None:
                    include_unlabeled = True

        # Auto-use segmentation masks as overlay when no explicit overlay (and
        # no label images) resolved. Masks live on specific frames at arbitrary
        # frame indices, so resolve them per-frame via a callable rather than a
        # positional list. label_images take precedence (resolved above).
        if overlay is None and labels.masks:
            video_masks = labels.get_masks(video=target_video)
            if video_masks:
                _auto_labels = labels
                _auto_video = target_video

                def overlay(fidx: int) -> list["SegmentationMask"]:
                    """Resolve segmentation masks for a single frame index."""
                    return _auto_labels.get_masks(video=_auto_video, frame_idx=fidx)

                if include_unlabeled is None:
                    include_unlabeled = True

        # Check if centroids exist for this video (per-frame access via lf).
        if show_centroids and labels.centroids:
            _has_video_centroids = bool(labels.get_centroids(video=target_video))
            if _has_video_centroids and include_unlabeled is None:
                include_unlabeled = True

        # Resolve None to default after auto-overlay logic
        if include_unlabeled is None:
            include_unlabeled = False

    elif isinstance(source, list) and all(isinstance(x, LabeledFrame) for x in source):
        labeled_frames = source
        if not labeled_frames:
            raise ValueError("Empty labeled frames list")

        target_video = labeled_frames[0].video
        skeleton = None
        for lf in labeled_frames:
            for inst in lf.instances:
                skeleton = inst.skeleton
                break
            if skeleton:
                break

        if skeleton is None:
            raise ValueError("No skeleton found in labeled frames")

        edge_inds = skeleton.edge_inds
        node_names = [n.name for n in skeleton.nodes]
        n_tracks = 0
        has_tracks = False
        labels = None

    else:
        raise TypeError(
            f"source must be Labels or list of LabeledFrame, got {type(source)}"
        )

    # Create frame index mapping
    frame_idx_to_lf = {lf.frame_idx: lf for lf in labeled_frames}

    # Get video frame count for include_unlabeled mode
    n_video_frames = None
    if include_unlabeled:
        if hasattr(target_video, "shape") and target_video.shape is not None:
            n_video_frames = target_video.shape[0]

    # Determine frame indices to render
    if frame_inds is not None:
        render_indices = frame_inds
    elif start is not None or end is not None:
        labeled_indices = sorted(frame_idx_to_lf.keys())
        if include_unlabeled and n_video_frames is not None:
            # Render all frames in range, not just labeled ones
            start_idx = start if start is not None else 0
            end_idx = end if end is not None else n_video_frames
            render_indices = list(range(start_idx, end_idx))
        else:
            # Only render labeled frames in range
            start_idx = start if start is not None else min(labeled_indices, default=0)
            end_idx = end if end is not None else max(labeled_indices, default=0) + 1
            render_indices = [i for i in labeled_indices if start_idx <= i < end_idx]
    else:
        if include_unlabeled and n_video_frames is not None:
            # Render entire video
            render_indices = list(range(n_video_frames))
        else:
            # Only render labeled frames
            render_indices = sorted(frame_idx_to_lf.keys())

    if not render_indices and isinstance(overlay, list) and overlay:
        # Derive frame indices from overlay list (use list indices as frame indices)
        render_indices = list(range(len(overlay)))

    if not render_indices and _has_video_centroids:
        # Derive frame indices from frames that have centroids
        render_indices = sorted(lf.frame_idx for lf in labeled_frames if lf.centroids)

    if not render_indices:
        raise ValueError("No frames to render")

    # Determine FPS
    if fps is None:
        # Try to get from video
        if hasattr(target_video, "backend") and target_video.backend is not None:
            try:
                fps = target_video.backend.fps
            except Exception:
                fps = 30.0
        else:
            fps = 30.0

    # Determine color scheme
    resolved_scheme = determine_color_scheme(
        has_tracks=has_tracks,
        is_single_image=False,
        scheme=color_by,
    )

    # Resolve crop bounds once (before the loop)
    # We need the video shape to resolve normalized coordinates
    crop_bounds: tuple[int, int, int, int] | None = None
    crop_offset: tuple[float, float] = (0.0, 0.0)
    if crop is not None:
        if hasattr(target_video, "shape") and target_video.shape is not None:
            h, w = target_video.shape[1:3]
        else:
            # Fallback: try to get from first frame
            h, w = 480, 640  # reasonable default
        crop_bounds = _resolve_crop(crop, (h, w))
        crop_offset = (float(crop_bounds[0]), float(crop_bounds[1]))

    # Setup progress
    if show_progress:
        try:
            from tqdm import tqdm

            iterator = tqdm(render_indices, desc="Rendering", unit="frame")
        except ImportError:
            iterator = render_indices
    else:
        iterator = render_indices

    # Setup video writer for streaming output (memory optimization)
    # When save_path is provided, write frames directly instead of accumulating
    writer = None
    if save_path is not None:
        from sleap_io.io.video_writing import VideoWriter

        save_path_ = Path(save_path)
        save_path_.parent.mkdir(parents=True, exist_ok=True)
        writer = VideoWriter(
            filename=save_path_,
            fps=fps,
            codec=codec,
            crf=crf,
            preset=x264_preset,
        )

    # Build centroid color palette (by track).
    _centroid_palette: list[tuple[int, int, int]] = []
    if _has_video_centroids and labels is not None:
        _centroid_palette = get_palette(palette, max(len(labels.tracks), 1))

    # Set up motion trails (drawn behind poses). Trails trace instances, and
    # every instance carries a skeleton, so a missing skeleton means there are
    # no instances to trail.
    _do_trails = show_trails and trail_length > 0 and skeleton is not None
    _trail_targets: list[int | None] = []
    _trail_palette: list[tuple[int, int, int]] = []
    _trail_pts_cache: dict[int, np.ndarray] = {}
    if _do_trails:
        _trail_targets = _resolve_trail_node(trail_node, skeleton)
        _trail_palette = get_palette(
            palette, _n_trail_palette_colors(has_tracks, n_tracks, labeled_frames)
        )
    # A uniform trail_color overrides the per-track palette colors.
    _trail_color_resolved = (
        resolve_color(trail_color) if trail_color is not None else None
    )

    # Pre-process overlay: determine type for per-frame dispatch
    _overlay_is_3d = (
        overlay is not None and isinstance(overlay, np.ndarray) and overlay.ndim == 3
    )
    _overlay_is_2d = (
        overlay is not None and isinstance(overlay, np.ndarray) and overlay.ndim == 2
    )
    _overlay_is_callable = callable(overlay) if overlay is not None else False
    _overlay_is_list = (
        overlay is not None and isinstance(overlay, list) and len(overlay) > 0
    )
    _overlay_is_label_image_list = _overlay_is_list and _is_label_image(overlay[0])

    def _get_frame_overlay(fidx: int):
        """Resolve overlay data for a single frame."""
        if overlay is None:
            return None
        if _overlay_is_3d:
            if fidx < overlay.shape[0]:
                return overlay[fidx]
            return None
        if _overlay_is_2d:
            return overlay
        if _overlay_is_callable:
            return overlay(fidx)
        if _overlay_is_label_image_list:
            # Match by list index (overlays ordered by frame sequence)
            if fidx < len(overlay):
                return overlay[fidx]
            return None
        if _overlay_is_list:
            # Match by list index
            if fidx < len(overlay):
                return [overlay[fidx]]
            return []
        return None

    def _apply_frame_overlay(image: np.ndarray, fidx: int) -> np.ndarray:
        """Resolve and apply overlay for a single frame.

        Returns the (possibly new) image array — the caller must use the
        returned value since grayscale-to-RGB conversion creates a new array.
        """
        frame_overlay = _get_frame_overlay(fidx)
        if frame_overlay is None:
            return image
        if image.ndim == 2:
            image = np.stack([image] * 3, axis=-1)
        elif image.ndim == 3 and image.shape[-1] == 1:
            image = np.repeat(image, 3, axis=-1)
        # Crop overlay to match cropped image region
        cropped_overlay = frame_overlay
        if crop_bounds is not None:
            if isinstance(frame_overlay, np.ndarray):
                x1, y1, x2, y2 = crop_bounds
                oh, ow = frame_overlay.shape[:2]
                cropped_overlay = frame_overlay[
                    max(0, y1) : min(oh, y2), max(0, x1) : min(ow, x2)
                ]
            elif _is_label_image(frame_overlay):
                from sleap_io.model.label_image import PredictedLabelImage

                x1, y1, x2, y2 = crop_bounds
                oh, ow = frame_overlay.data.shape[:2]
                cropped_data = frame_overlay.data[
                    max(0, y1) : min(oh, y2), max(0, x1) : min(ow, x2)
                ]
                kwargs = dict(
                    data=cropped_data,
                    objects=frame_overlay.objects,
                )
                if isinstance(frame_overlay, PredictedLabelImage):
                    kwargs["score"] = frame_overlay.score
                    kwargs["score_map"] = frame_overlay.score_map
                cropped_overlay = type(frame_overlay)(**kwargs)
        # Color overlay elements (masks/ROIs/bboxes) by track identity when
        # color_by resolves to "track", matching poses/centroids/trails (same
        # `palette`). Otherwise fall through to positional `overlay_palette`.
        # Untracked elements fall back to the first palette color.
        overlay_colors = None
        if (
            resolved_scheme == "track"
            and _overlay_palette_by_track
            and isinstance(cropped_overlay, list)
            and cropped_overlay
            and not _is_label_image(cropped_overlay[0])
        ):
            overlay_colors = []
            for el in cropped_overlay:
                t = getattr(el, "track", None)
                tidx = _track_idx_map.get(id(t)) if t is not None else None
                overlay_colors.append(
                    _overlay_palette_by_track[tidx % len(_overlay_palette_by_track)]
                    if tidx is not None
                    else _overlay_palette_by_track[0]
                )
        _apply_overlay(
            image,
            cropped_overlay,
            alpha=overlay_alpha,
            palette=overlay_palette,
            outline=overlay_outline,
            outline_width=overlay_outline_width,
            outline_color=overlay_outline_color,
            colors=overlay_colors,
        )
        return image

    # Pre-build track index map for O(1) track color lookups.
    _track_idx_map: dict[int, int] = {}
    if labels is not None and has_tracks:
        _track_idx_map = {id(t): i for i, t in enumerate(labels.tracks)}

    # Identity catalog for stable identity coloring across frames (mirrors the
    # track index map). Only built when coloring by identity.
    _identity_catalog: list | None = None
    if resolved_scheme == "identity":
        _identity_catalog = list(labels.identities) if labels is not None else []

    # Category catalog for stable category coloring across frames (mirrors the
    # identity index map). Only built when coloring by category.
    _category_catalog: list | None = None
    if resolved_scheme == "category":
        _category_catalog = list(labels.categories) if labels is not None else []

    # Track-keyed palette for overlay (mask/ROI/bbox) coloring under
    # color_by="track", matching centroids/poses/trails (same `palette`).
    _overlay_palette_by_track: list[tuple[int, int, int]] = []
    if labels is not None and has_tracks:
        _overlay_palette_by_track = get_palette(palette, max(len(labels.tracks), 1))

    def _draw_frame_centroids(
        image: np.ndarray, fidx: int, crop_off: tuple[float, float] = (0.0, 0.0)
    ) -> np.ndarray:
        """Draw centroids for a single frame onto the image."""
        if not _has_video_centroids:
            return image

        # Use frame-level centroid access instead of linear scan.
        lf = frame_idx_to_lf.get(fidx)
        frame_centroids = lf.centroids if lf is not None else []
        if not frame_centroids:
            return image

        from sleap_io.rendering.overlays import draw_centroids

        # Ensure RGB.
        if image.ndim == 2:
            image = np.stack([image] * 3, axis=-1)
        elif image.ndim == 3 and image.shape[-1] == 1:
            image = np.repeat(image, 3, axis=-1)

        # Assign colors by track using pre-built index map.
        centroid_colors = []
        for c in frame_centroids:
            if c.track is not None and id(c.track) in _track_idx_map:
                tidx = _track_idx_map[id(c.track)]
                centroid_colors.append(_centroid_palette[tidx % len(_centroid_palette)])
            else:
                centroid_colors.append(
                    _centroid_palette[0] if _centroid_palette else (0, 255, 0)
                )

        # centroid_marker_size is NOT pre-scaled: the centroids are drawn here,
        # then the whole image is upscaled once by `scale` inside render_frame,
        # so the final radius matches pose nodes (marker_size * scale).
        draw_centroids(
            image,
            frame_centroids,
            colors=centroid_colors,
            marker_size=centroid_marker_size,
            offset=crop_off,
        )
        return image

    def _draw_frame_trails(
        image: np.ndarray, fidx: int, crop_off: tuple[float, float] = (0.0, 0.0)
    ) -> np.ndarray:
        """Draw motion trails for a single frame onto the image."""
        if not _do_trails:
            return image

        trails, trail_colors = _compute_trails(
            fidx=fidx,
            frame_idx_to_lf=frame_idx_to_lf,
            trail_length=trail_length,
            trail_targets=_trail_targets,
            track_idx_map=_track_idx_map,
            palette_colors=_trail_palette,
            has_tracks=has_tracks,
            pts_cache=_trail_pts_cache,
        )
        if not trails:
            return image

        from sleap_io.rendering.overlays import draw_trails

        # A uniform trail_color overrides the per-track palette colors.
        if _trail_color_resolved is not None:
            color_kwargs: dict = {"color": _trail_color_resolved}
        else:
            color_kwargs = {"colors": trail_colors}

        # trail_width is NOT pre-scaled: the trail is drawn here, then the whole
        # image is upscaled once by `scale` inside render_frame, so the final
        # width matches pose edges (line_width * scale).
        return draw_trails(
            image,
            trails,
            line_width=trail_width,
            alpha_fade=trail_alpha_fade,
            alpha=trail_alpha,
            offset=crop_off,
            **color_kwargs,
        )

    # Only accumulate frames if returning as list (no save_path)
    rendered_frames: list[np.ndarray] = []
    total_frames = len(render_indices)

    try:
        for i, fidx in enumerate(iterator):
            # Check for cancellation
            if progress_callback is not None:
                if progress_callback(i, total_frames) is False:
                    break

            lf = frame_idx_to_lf.get(fidx)

            # Handle frames without LabeledFrame
            if lf is None:
                if not include_unlabeled:
                    continue
                # Render just the video frame without poses
                if background_color is not None:
                    # Solid color background - skip video loading entirely
                    if (
                        hasattr(target_video, "shape")
                        and target_video.shape is not None
                    ):
                        h, w = target_video.shape[1:3]
                    else:
                        # No video metadata and no points - use minimum default
                        h, w = 64, 64
                    image = _create_blank_frame(h, w, background_color)[:, :, :3]
                else:
                    try:
                        image = target_video[fidx]
                    except Exception:
                        image = None
                    if image is None:
                        raise ValueError(
                            f"Video unavailable at frame {fidx}. "
                            "Specify a background color to render without video."
                        )

                # Ensure RGB for overlay compositing
                if image.ndim == 2:
                    image = np.stack([image] * 3, axis=-1)
                elif image.ndim == 3 and image.shape[2] == 1:
                    image = np.concatenate([image] * 3, axis=-1)

                # Apply cropping if specified
                render_image_data = image
                if crop_bounds is not None:
                    render_image_data, _, _ = _apply_crop(image, [], crop_bounds)

                # Apply overlay
                render_image_data = _apply_frame_overlay(render_image_data, fidx)

                # Draw centroids
                crop_off = (
                    (float(crop_bounds[0]), float(crop_bounds[1]))
                    if crop_bounds is not None
                    else (0.0, 0.0)
                )
                render_image_data = _draw_frame_centroids(
                    render_image_data, fidx, crop_off
                )

                # Draw motion trails
                render_image_data = _draw_frame_trails(
                    render_image_data, fidx, crop_off
                )

                # Render frame without poses
                rendered = render_frame(
                    frame=render_image_data,
                    instances_points=[],
                    edge_inds=edge_inds,
                    node_names=node_names,
                    color_by=resolved_scheme,
                    palette=palette,
                    marker_shape=marker_shape,
                    marker_size=marker_size,
                    line_width=line_width,
                    alpha=alpha,
                    show_nodes=show_nodes,
                    show_edges=show_edges,
                    scale=scale,
                    track_indices=None,
                    n_tracks=n_tracks,
                    n_identities=(
                        len(_identity_catalog) if _identity_catalog is not None else 0
                    ),
                    n_categories=(
                        len(_category_catalog) if _category_catalog is not None else 0
                    ),
                    pre_render_callback=pre_render_callback,
                    post_render_callback=post_render_callback,
                    per_instance_callback=None,
                    frame_idx=fidx,
                    instance_metadata=[],
                    crop_offset=crop_offset,
                )

                # Stream to file or accumulate for return
                if writer is not None:
                    writer(rendered)
                else:
                    rendered_frames.append(rendered)
                continue

            instances = list(lf.instances)
            instances_points = [inst.numpy() for inst in instances]

            # Get track indices using pre-built map
            track_indices = None
            if labels is not None and has_tracks:
                track_indices = []
                for inst in instances:
                    tidx = _track_idx_map.get(id(inst.track)) if inst.track else None
                    track_indices.append(tidx if tidx is not None else 0)

            # Get identity indices when coloring by identity
            identity_indices = None
            n_identities = 0
            if resolved_scheme == "identity":
                identity_indices, n_identities = _compute_identity_coloring(
                    instances, _identity_catalog
                )

            # Get category indices when coloring by category
            category_indices = None
            n_categories = 0
            if resolved_scheme == "category":
                category_indices, n_categories = _compute_category_coloring(
                    instances, _category_catalog
                )

            # Build instance metadata
            instance_metadata = []
            for inst in instances:
                meta = {}
                if hasattr(inst, "track") and inst.track is not None:
                    meta["track_name"] = inst.track.name
                if hasattr(inst, "score"):
                    meta["confidence"] = inst.score
                instance_metadata.append(meta)

            # Get image
            if background_color is not None:
                # Solid color background - skip video loading entirely
                if hasattr(target_video, "shape") and target_video.shape is not None:
                    h, w = target_video.shape[1:3]
                else:
                    # Estimate from points
                    h, w = _estimate_frame_size(instances_points)
                image = _create_blank_frame(h, w, background_color)[:, :, :3]
            else:
                try:
                    image = lf.image
                except Exception:
                    image = None
                if image is None:
                    raise ValueError(
                        f"Video unavailable at frame {fidx}. "
                        "Specify a background color to render without video."
                    )

            # Apply cropping if specified
            render_image_data = image
            render_points = instances_points
            if crop_bounds is not None:
                render_image_data, render_points, _ = _apply_crop(
                    image, instances_points, crop_bounds
                )

            # Apply overlay
            render_image_data = _apply_frame_overlay(render_image_data, fidx)

            # Draw centroids
            crop_off = (
                (float(crop_bounds[0]), float(crop_bounds[1]))
                if crop_bounds is not None
                else (0.0, 0.0)
            )
            render_image_data = _draw_frame_centroids(render_image_data, fidx, crop_off)

            # Draw motion trails
            render_image_data = _draw_frame_trails(render_image_data, fidx, crop_off)

            # Render frame
            rendered = render_frame(
                frame=render_image_data,
                instances_points=render_points,
                edge_inds=edge_inds,
                node_names=node_names,
                color_by=resolved_scheme,
                palette=palette,
                marker_shape=marker_shape,
                marker_size=marker_size,
                line_width=line_width,
                alpha=alpha,
                show_nodes=show_nodes,
                show_edges=show_edges,
                scale=scale,
                track_indices=track_indices,
                n_tracks=n_tracks,
                identity_indices=identity_indices,
                n_identities=n_identities,
                category_indices=category_indices,
                n_categories=n_categories,
                pre_render_callback=pre_render_callback,
                post_render_callback=post_render_callback,
                per_instance_callback=per_instance_callback,
                frame_idx=fidx,
                instance_metadata=instance_metadata,
                crop_offset=crop_offset,
            )

            # Stream to file or accumulate for return
            if writer is not None:
                writer(rendered)
            else:
                rendered_frames.append(rendered)

    finally:
        # Ensure writer is closed even if an exception occurs
        if writer is not None:
            writer.close()

    # Return Video object or frame list
    if save_path is not None:
        return VideoModel.from_filename(str(save_path_))

    return rendered_frames

resolve_color(color)

Resolve a flexible color specification to an RGB tuple.

Parameters:

Name Type Description Default
color ColorSpec

Color specification in various formats: - RGB int tuple: (255, 128, 0) - RGB float tuple: (1.0, 0.5, 0.0) - values in 0.0-1.0 range - Grayscale int: 128(128, 128, 128) - Grayscale float: 0.5(127, 127, 127) - Named color: "black", "white", "red", etc. - Hex color: "#ff8000" or "#f80" - Palette index: "tableau10[2]", "glasbey[5]"

required

Returns:

Type Description
tuple[int, int, int]

RGB tuple of integers in 0-255 range.

Raises:

Type Description
ValueError

If color specification is invalid.

Examples:

>>> resolve_color((255, 128, 0))
(255, 128, 0)
>>> resolve_color((1.0, 0.5, 0.0))
(255, 127, 0)
>>> resolve_color("red")
(255, 0, 0)
>>> resolve_color("#ff8000")
(255, 128, 0)
>>> resolve_color("#f80")
(255, 136, 0)
>>> resolve_color("tableau10[2]")
(44, 160, 44)
>>> resolve_color(128)
(128, 128, 128)
>>> resolve_color(0.5)
(127, 127, 127)
Source code in sleap_io/rendering/colors.py
def resolve_color(color: ColorSpec) -> tuple[int, int, int]:
    """Resolve a flexible color specification to an RGB tuple.

    Args:
        color: Color specification in various formats:
            - RGB int tuple: ``(255, 128, 0)``
            - RGB float tuple: ``(1.0, 0.5, 0.0)`` - values in 0.0-1.0 range
            - Grayscale int: ``128`` → ``(128, 128, 128)``
            - Grayscale float: ``0.5`` → ``(127, 127, 127)``
            - Named color: ``"black"``, ``"white"``, ``"red"``, etc.
            - Hex color: ``"#ff8000"`` or ``"#f80"``
            - Palette index: ``"tableau10[2]"``, ``"glasbey[5]"``

    Returns:
        RGB tuple of integers in 0-255 range.

    Raises:
        ValueError: If color specification is invalid.

    Examples:
        >>> resolve_color((255, 128, 0))
        (255, 128, 0)

        >>> resolve_color((1.0, 0.5, 0.0))
        (255, 127, 0)

        >>> resolve_color("red")
        (255, 0, 0)

        >>> resolve_color("#ff8000")
        (255, 128, 0)

        >>> resolve_color("#f80")
        (255, 136, 0)

        >>> resolve_color("tableau10[2]")
        (44, 160, 44)

        >>> resolve_color(128)
        (128, 128, 128)

        >>> resolve_color(0.5)
        (127, 127, 127)
    """
    # Grayscale int
    if isinstance(color, int):
        value = max(0, min(255, color))
        return (value, value, value)

    # Grayscale float (0.0-1.0 range)
    if isinstance(color, float):
        value = int(max(0.0, min(1.0, color)) * 255)
        return (value, value, value)

    # Tuple (RGB)
    if isinstance(color, tuple):
        if len(color) != 3:
            raise ValueError(f"RGB tuple must have 3 elements, got {len(color)}")

        r, g, b = color

        # Detect float vs int by Python type
        if isinstance(r, float) or isinstance(g, float) or isinstance(b, float):
            # Float tuple: 0.0-1.0 range
            r_int = int(max(0.0, min(1.0, float(r))) * 255)
            g_int = int(max(0.0, min(1.0, float(g))) * 255)
            b_int = int(max(0.0, min(1.0, float(b))) * 255)
            return (r_int, g_int, b_int)
        else:
            # Int tuple: 0-255 range
            r_int = max(0, min(255, int(r)))
            g_int = max(0, min(255, int(g)))
            b_int = max(0, min(255, int(b)))
            return (r_int, g_int, b_int)

    # String
    if isinstance(color, str):
        color_lower = color.lower().strip()

        # Named color
        if color_lower in NAMED_COLORS:
            return NAMED_COLORS[color_lower]

        # Hex color
        if color.startswith("#"):
            hex_part = color[1:]
            if len(hex_part) == 3 or len(hex_part) == 6:
                return _hex_to_rgb(color)
            raise ValueError(f"Invalid hex color: {color}")

        # Palette index: "palette_name[index]"
        palette_match = re.match(r"^(\w+)\[(\d+)\]$", color)
        if palette_match:
            palette_name = palette_match.group(1)
            index = int(palette_match.group(2))

            # Get palette colors
            try:
                palette_colors = get_palette(palette_name, index + 1)
                if index < len(palette_colors):
                    return palette_colors[index]
            except ValueError:
                pass

            raise ValueError(f"Invalid palette index: {color}")

        raise ValueError(
            f"Unknown color: {color!r}. Valid named colors: {list(NAMED_COLORS.keys())}"
        )

    raise TypeError(f"Invalid color type: {type(color).__name__}")