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
|
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
|
(0, 255, 0)
|
colors
|
list[tuple[int, int, int]] | None
|
Per-bbox RGB color tuples. If provided, must have the same
length as |
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., |
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 |
required |
centroids
|
list[Centroid]
|
List of |
required |
color
|
tuple[int, int, int]
|
RGB color tuple used when |
(0, 255, 0)
|
colors
|
list[tuple[int, int, int]] | None
|
Per-centroid RGB color tuples. If provided, must have the
same length as |
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]
|
|
(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 |
required |
labels
|
ndarray
|
Integer label array of shape |
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: |
'distinct'
|
outline
|
bool
|
If |
False
|
outline_width
|
int
|
Width of the outline in pixels (only used if
|
1
|
outline_color
|
tuple[int, int, int] | None
|
RGB color for outlines. If |
None
|
scale
|
tuple[float, float]
|
Resolution ratio |
(1.0, 1.0)
|
offset
|
tuple[float, float]
|
Origin |
(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
|
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 |
(255, 0, 0)
|
colors
|
list[tuple[int, int, int]] | None
|
Per-mask RGB color tuples. If provided, must have the same
length as |
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
|
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 |
(0, 255, 0)
|
colors
|
list[tuple[int, int, int]] | None
|
Per-ROI RGB color tuples. If provided, must have the same
length as |
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 |
required |
trails
|
list[ndarray]
|
List of trails, where each trail is an |
required |
color
|
tuple[int, int, int]
|
RGB color tuple used when |
(0, 255, 0)
|
colors
|
list[tuple[int, int, int]] | None
|
Per-trail RGB color tuples. If provided, must have the same
length as |
None
|
line_width
|
float
|
Width of the trail line in pixels. |
2.0
|
alpha_fade
|
bool
|
If |
True
|
alpha
|
float
|
Global opacity multiplier (0.0 to 1.0). |
1.0
|
offset
|
tuple[float, float]
|
|
(0.0, 0.0)
|
Returns:
| Type | Description |
|---|---|
ndarray
|
The image array with trails drawn. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
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