Skip to content

overlays

sleap_io.rendering.overlays

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

These functions draw annotations directly onto numpy image arrays using skia-python for geometry rendering and numpy for mask blending.

Functions:

Name Description
draw_bboxes

Draw bounding boxes on an image.

draw_centroids

Draw centroid markers 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.

Attributes:

Name Type Description
TYPE_CHECKING

Returns True when the argument is true, False otherwise.

__cached__

str(object='') -> str

__doc__

str(object='') -> str

__file__

str(object='') -> str

__name__

str(object='') -> str

__package__

str(object='') -> str

TYPE_CHECKING = False module-attribute

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

__cached__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/rendering/__pycache__/overlays.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__ = 'Overlay drawing functions for ROIs, segmentation masks, and bounding boxes.\n\nThese functions draw annotations directly onto numpy image arrays using\nskia-python for geometry rendering and numpy for mask blending.\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/overlays.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.overlays' 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'.

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_centroids(image, centroids, color=(0, 255, 0), colors=None, marker_size=5.0, alpha=1.0, offset=(0.0, 0.0))

Draw centroid markers on an image.

Draws filled circles at each centroid position using skia-python.

Parameters:

Name Type Description Default
image ndarray

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

required
centroids list[Centroid]

List of Centroid objects to draw.

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-centroid RGB color tuples. If provided, must have the same length as centroids. Overrides color.

None
marker_size float

Radius of the marker circle in pixels.

5.0
alpha float

Opacity for the markers (0.0 to 1.0).

1.0
offset tuple[float, float]

(ox, oy) offset to subtract from centroid coordinates (used for cropped images).

(0.0, 0.0)

Returns:

Type Description
ndarray

The modified image array.

Source code in sleap_io/rendering/overlays.py
def draw_centroids(
    image: np.ndarray,
    centroids: list["Centroid"],
    color: tuple[int, int, int] = (0, 255, 0),
    colors: list[tuple[int, int, int]] | None = None,
    marker_size: float = 5.0,
    alpha: float = 1.0,
    offset: tuple[float, float] = (0.0, 0.0),
) -> np.ndarray:
    """Draw centroid markers on an image.

    Draws filled circles at each centroid position using skia-python.

    Args:
        image: Image array of shape ``(H, W, 3)`` uint8. Modified in-place
            and returned.
        centroids: List of ``Centroid`` objects to draw.
        color: RGB color tuple used when ``colors`` is ``None``.
        colors: Per-centroid RGB color tuples. If provided, must have the
            same length as ``centroids``. Overrides ``color``.
        marker_size: Radius of the marker circle in pixels.
        alpha: Opacity for the markers (0.0 to 1.0).
        offset: ``(ox, oy)`` offset to subtract from centroid coordinates
            (used for cropped images).

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

    import skia

    alpha_int = max(0, min(255, int(alpha * 255)))
    ox, oy = offset

    # 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()

    for i, centroid in enumerate(centroids):
        c = colors[i] if colors is not None else color
        paint = skia.Paint(
            Color=skia.Color(c[0], c[1], c[2], alpha_int),
            AntiAlias=True,
            Style=skia.Paint.kFill_Style,
        )
        cx = float(centroid.x) - ox
        cy = float(centroid.y) - oy
        canvas.drawCircle(cx, cy, float(marker_size), paint)

    surface.flushAndSubmit()
    result = frame_rgba[:, :, :3]
    if image.shape == result.shape:
        image[:] = result
        return image
    return result.copy()

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