Skip to content

Animation Overlays

Visualize animal behavior alongside spatial field dynamics using flexible overlay types for positions, poses, and head direction.

Quick Start

from neurospatial import Environment
from neurospatial.animation import PositionOverlay
from neurospatial.decoding import decode_session

# positions: (n_samples, 2) tracking in cm; times: (n_samples,) seconds;
# spike_times: one array of spike times per unit.
env = Environment.from_samples(positions, bin_size=2.5, units="cm")
result = decode_session(env, spike_times, times, positions, dt=0.025)

# The animal's position on its own clock, with a short trail
position_overlay = PositionOverlay(
    positions=positions,
    times=times,
    color="magenta",
    size=10.0,
    trail_length=5,
)

# One posterior row per 25 ms decoding bin, with the animal drawn over it
env.animate_fields(
    result.posterior,
    frame_times=result.times,
    overlays=[position_overlay],
    backend="napari",  # or "widget", "fastplotlib", or save_path="clip.mp4"
)

Overview

The overlay system enables rich behavioral visualizations by combining spatial fields with:

  • Position tracking: Trajectory with trails
  • Pose estimation: Multi-keypoint tracking with skeleton
  • Head direction: Orientation arrows
  • Multi-animal: Multiple overlays of the same type
  • Regions: Named spatial zones

Key features:

  • Temporal alignment: Overlays automatically sync to field frames
  • Mixed sampling rates: Interpolation handles different data frequencies
  • Every backend draws every overlay type; a combination an environment cannot show is refused before anything is drawn (see Backend Capabilities)
  • Scientifically correct: NaN extrapolation, validation, actionable errors

Related resources:

Overlay Types

Position Overlay

Track a single trajectory with optional trail visualization.

from neurospatial.animation import PositionOverlay

# Basic position overlay
overlay = PositionOverlay(
    positions=trajectory,   # (n_samples, n_dims) - x, y coordinates
    times=None,             # Optional timestamps for alignment
    color="red",            # Marker color
    size=10.0,              # Marker size
    trail_length=None       # Frames of trail (None = no trail)
)

# With temporal alignment
overlay = PositionOverlay(
    positions=trajectory,   # shape: (1000, 2) at 250 Hz
    times=timestamps,       # shape: (1000,) in seconds
    color="blue",
    size=12.0,
    trail_length=10         # Show last 10 frames
)

Parameters:

  • positions: NDArray of shape (n_samples, n_dims) with coordinates
  • times: Optional timestamps for temporal alignment (default: assumes 1:1 with frames)
  • color: Matplotlib/Napari color name (default: "red")
  • size: Marker diameter in logical screen pixels (default: 10.0), independent of environment units and camera zoom.
  • trail_length: Number of frames to show as trail (default: None = no trail)

Bodypart Overlay

Multi-keypoint pose tracking with optional skeleton visualization.

from neurospatial.animation import BodypartOverlay, Skeleton

# Build a reusable skeleton (edge styling lives here)
skeleton = Skeleton.from_edge_list(
    [
        ("nose", "ear_left"),
        ("nose", "ear_right"),
        ("nose", "tail_base"),
    ],
    name="rodent",
    edge_color="white",         # Skeleton edge color
    edge_width=2.0,             # Edge width
)

# Pose with skeleton
overlay = BodypartOverlay(
    data={
        "nose": nose_coords,        # (n_samples, 2)
        "ear_left": ear_l_coords,   # (n_samples, 2)
        "ear_right": ear_r_coords,  # (n_samples, 2)
        "tail_base": tail_coords,   # (n_samples, 2)
    },
    times=timestamps,               # Optional alignment
    skeleton=skeleton,              # Connect keypoints
    colors={                        # Per-keypoint colors
        "nose": "red",
        "ear_left": "blue",
        "ear_right": "blue",
        "tail_base": "green",
    },
)

Parameters:

  • data: Dict mapping keypoint names to coordinate arrays (n_samples, n_dims)
  • times: Optional timestamps for temporal alignment
  • skeleton: A Skeleton defining keypoint edges and edge styling (default: None)
  • colors: Dict mapping keypoint names to colors (default: None = auto-color)

Edge color and width are configured on the Skeleton (edge_color, edge_width).

Important: Each keypoint can have independent timestamps for per-keypoint interpolation:

# Per-keypoint temporal alignment (advanced)
from neurospatial.animation.overlays import _convert_overlays_to_data

# Use conversion funnel for per-keypoint interpolation
# (Handled automatically by animate_fields)

Head Direction Overlay

Visualize orientation as arrows using angles or unit vectors.

from neurospatial.animation import HeadDirectionOverlay
import numpy as np

# From angles (radians)
overlay = HeadDirectionOverlay(
    headings=angles,        # (n_samples,) in radians
    times=timestamps,       # Optional alignment
    color="yellow",
    length=20.0             # Arrow length in env units (e.g., cm)
)

# From unit vectors
directions = np.column_stack([np.cos(angles), np.sin(angles)])  # (n_samples, 2)
overlay = HeadDirectionOverlay(
    headings=directions,
    color="orange",
    length=15.0
)

Parameters:

  • headings: NDArray of shape (n_samples,) (angles in radians) or (n_samples, n_dims) (unit vectors)
  • times: Optional timestamps for temporal alignment
  • color: Arrow color (default: "yellow")
  • length: Arrow length in environment units (default: 20.0)

Video Overlay

Display raw behavioral video frames as a background or foreground layer with spatial calibration.

from neurospatial.animation import VideoOverlay, calibrate_video

# Calibrate video to environment coordinates
calibration = calibrate_video(
    env,
    "session.mp4",
    landmarks_px=arena_corners_px,    # Video pixel coordinates
    landmarks_env=arena_corners_env,  # Coordinates in env.units
)

# Create video overlay
overlay = VideoOverlay(
    source="session.mp4",       # Path or pre-loaded array
    calibration=calibration,    # Pixel↔cm transform
    alpha=0.5,                  # Blend opacity (0-1)
    z_order="above",            # "above" or "below" field
)

# Animate with video background
env.animate_fields(
    fields,
    frame_times=frame_times,
    overlays=[overlay],
    backend="napari"  # or "video", "widget"
)

Parameters:

  • source: Path to video file or pre-loaded array (n_frames, height, width, 3)
  • calibration: VideoCalibration object for spatial alignment (optional but recommended)
  • times: Video frame timestamps for temporal alignment (optional)
  • alpha: Opacity of video layer, 0.0=transparent, 1.0=opaque (default: 0.5)
  • z_order: Draw order relative to field - "above" (default) or "below"
  • crop: Crop region (x, y, width, height) in pixels (default: None)
  • downsample: Spatial downsampling factor, e.g., 2 = half resolution (default: 1)

Best practices:

Goal Settings Result
Balanced view alpha=0.5, z_order="above" Equal video/field visibility (default)
Field dominant alpha=0.3, z_order="above" Field shows through video
Video dominant alpha=0.7, z_order="above" Video shows through field
Video as background z_order="below" Field drawn over the video, see-through where its values are low (a posterior over the camera's view): opacity rises from clear at vmin to opaque at a typical frame's peak

Requirements:

  • 2D environments only (env.n_dims == 2)
  • Not supported for 1D linearized tracks or 3D environments
  • Non-grid 2D environments work with approximate alignment (warning emitted)

Spike Raster Overlay

Draws the spikes of many units beside the field, one row per unit, over the animation's window_seconds around the current time, so replay or theta sequences can be read against a decoded posterior. Sort the rows by place-field peak to see a sequence as a diagonal:

from neurospatial.animation import SpikeRasterOverlay
from neurospatial.encoding import compute_spatial_rates

rates = compute_spatial_rates(env, spike_times, times, positions)
# Along the track; on a 1-D environment use rates.peak_locations()[:, 0].
order = np.argsort(env.to_linear(rates.peak_locations()))

env.animate_fields(
    result.posterior,
    frame_times=result.times,
    backend="fastplotlib",
    window_seconds=1.0,  # every panel drawn against time
    overlays=[SpikeRasterOverlay(spike_times, order=order, rates=rates.firing_rates)],
)

order lists the units from the bottom row up. Hovering anywhere in a row shows its unit's label (unit_ids, or the labels of a pynapple TsGroup), and over a spike also its time. With rates, one rate map per unit on the animated environment, clicking anywhere in a unit's row, or the Up and Down arrow keys, shows its rate map under the field (on a track, rate against position along it) and marks the unit: its spikes highlighted and its row striped. Escape shows none. So you can check where each cell of a sequence has its field. The hover labels, clicking a row and the arrow keys are fastplotlib controls. The napari view, the notebook widget and exported files draw the same raster as a panel below the field, with each row labeled by its unit and a cursor at the current time.

Epoch Overlay

Marks intervals of time, such as sharp-wave ripples, replay events or run bouts, on every panel drawn against time: shaded behind spike rasters and time series, and as a strip above a track's position-by-time panel, where shading would tint the posterior's colors. The line above the panels names the epoch the current time is in ("ripple 3 of 120"), and N and Shift+N jump to the start of the next and previous epoch (or event).

from neurospatial.animation import EpochOverlay

ripples = EpochOverlay(ripple_times, label="ripple")  # (n_ripples, 2) seconds
env.animate_fields(
    result.posterior,
    frame_times=result.times,
    backend="fastplotlib",
    overlays=[ripples, SpikeRasterOverlay(spike_times, order=order)],
)

For non-local or replay periods, the lab's decoding figures shade red: EpochOverlay(nonlocal_times, label="non-local", color="red", alpha=0.2).

epochs takes a (start, stop) pair, an (n, 2) array or a pynapple IntervalSet; overlapping or touching epochs are merged. The shading on every time panel, the status line and the N keys are fastplotlib's. The napari view, the notebook widget and exported files draw each EpochOverlay as a labeled row of shaded intervals in a panel against time, with a cursor at the current time.

Multi-Animal Support

Track multiple animals by passing multiple overlays of the same type:

# Two animals with different colors
animal1_pos = PositionOverlay(
    positions=trajectory_1,
    color="red",
    size=12.0,
    trail_length=10
)

animal2_pos = PositionOverlay(
    positions=trajectory_2,
    color="blue",
    size=12.0,
    trail_length=10
)

env.animate_fields(
    fields,
    frame_times=frame_times,
    overlays=[animal1_pos, animal2_pos],  # Both positions rendered
    backend="napari"
)

Automatic naming: Backends assign suffixes for disambiguation (e.g., "Position 1", "Position 2").

Temporal Alignment

Overlays automatically sync to field frames using interpolation.

Without Timestamps (1:1 Alignment)

# Assume overlay has same number of samples as fields
overlay = PositionOverlay(
    positions=trajectory,  # shape: (100, 2) - matches 100 fields
    times=None        # No interpolation
)

Requirement: len(overlay.positions) must equal len(fields).

With Timestamps (Interpolation)

# Overlay sampled at 250 Hz, fields at 30 fps
overlay = PositionOverlay(
    positions=trajectory,  # shape: (1000, 2) at 250 Hz
    times=timestamps,    # shape: (1000,) in seconds
)

# Provide frame times for alignment
frame_times = np.linspace(0, 10, 100)  # 100 frames over 10 seconds

env.animate_fields(
    fields,
    overlays=[overlay],
    frame_times=frame_times,  # Interpolation target
)

Interpolation behavior:

  • Linear interpolation for smooth trajectories
  • NaN extrapolation outside overlay time range (scientifically correct)
  • Warnings for partial temporal overlap (<50%)
  • Errors for zero temporal overlap

Frame Times

frame_times is required: provide one timestamp per field frame.

frame_times = np.arange(len(fields), dtype=float) / 30.0

env.animate_fields(
    fields,
    frame_times=frame_times,
    overlays=[overlay],
)

Regions Overlay

Display named spatial regions (from env.regions) during animation.

from neurospatial import Regions

from neurospatial.regions import Region

# Add regions to environment
region_builder = Regions(env.regions.values())
region_builder.add("goal", point=np.array([50.0, 50.0]))
region_builder.add("start", polygon=start_polygon)
env = env.with_regions(region_builder)

# Show all regions
env.animate_fields(
    fields,
    frame_times=frame_times,
    show_regions=True,
    region_alpha=0.3  # Transparency
)

# Show specific regions only
env.animate_fields(
    fields,
    frame_times=frame_times,
    show_regions=["goal", "start"],  # List of names
    region_alpha=0.5
)

Parameters:

  • show_regions: bool (all regions) or list[str] (specific names) (default: False)
  • region_alpha: Transparency for region rendering (default: 0.3)

Backend Capabilities

Every backend (napari, the notebook widget, fastplotlib, and video and HTML files) draws every overlay type and regions. What an environment can show limits a few combinations, and each is refused before any viewer opens or any frame is written, with a message naming the alternative:

  • 1-D environments (tracks): no regions, HeadDirectionOverlay or VideoOverlay; show a heading as a TimeSeriesOverlay instead. Napari needs 2-D or 3-D coordinates, and fastplotlib also refuses BodypartOverlay and ObjectVectorOverlay on a track.
  • 3-D environments: headings must be 3-D unit vectors; only point regions are drawn; no VideoOverlay or scale bar.
  • Fastplotlib: no scale bar.

See What each environment can show in the animation guide.

Video Overlay Coordinate Systems

VideoOverlay requires calibration to align video pixels to environment coordinates. Without one, the video frame is stretched over env.dimension_ranges, the extent of the environment's active cells. On a subset (or a grid whose border cells are all inactive) that is smaller than the arena the camera recorded, so a video of the whole arena is squeezed onto the kept cells: calibrate it.

Coordinate Spaces

Three coordinate systems are involved:

Space Origin X-axis Y-axis Units
Video Pixel Top-left Right (columns) Down (rows) Pixels
Environment Bottom-left Right Up cm (or your units)
Napari Display Top-left Right (columns) Down (rows) Pixels

The calibration transform handles the coordinate conversion, including:

  1. Y-axis flip: Video origin (top-left) → Environment origin (bottom-left)
  2. Scale: Pixels → centimeters → environment metric units
  3. Rotation/Translation: If camera and arena are not perfectly aligned

VideoCalibration always stores a pixel-to-centimeter transform. Overlay preparation converts it to env.units, so the same calibration can be used with centimeter, meter or millimeter environments. An environment without declared units follows the historical centimeter convention. Calibrated video rejects unknown units and pixel units; uncalibrated video can still be fitted to environment bounds.

Landmarks passed as landmarks_env use the environment's units. The cm_per_px parameter and scale-bar length always use centimeters, regardless of environment units.

Calibration Methods

Choose the method that matches your available reference data:

Method 1: Scale Bar (Simplest)

Use when you have two points of known distance in the video.

from neurospatial.animation import calibrate_video

# Two endpoints of a scale bar (e.g., ruler in video)
calibration = calibrate_video(
    env,
    "session.mp4",
    scale_bar=((100, 200), (300, 200), 50.0),  # px1, px2, length_cm
)

Limitations: Assumes uniform scaling and standard camera orientation (no rotation).

Method 2: Landmark Correspondences (Most Flexible)

Use when you can identify the same points in both video and environment.

import numpy as np
from neurospatial.animation import calibrate_video

# Arena corners in video pixels (x, y)
corners_px = np.array([
    [50, 50],    # Top-left in video
    [590, 50],   # Top-right
    [590, 430],  # Bottom-right
    [50, 430],   # Bottom-left
])

# Same corners in environment coordinates (this example uses env.units="cm")
corners_env = np.array([
    [0, 80],     # Environment top-left (high Y)
    [100, 80],   # Environment top-right
    [100, 0],    # Environment bottom-right (low Y)
    [0, 0],      # Environment bottom-left
])

calibration = calibrate_video(
    env,
    "session.mp4",
    landmarks_px=corners_px,
    landmarks_env=corners_env,
)

Best for: Cameras at angles, non-uniform scaling, or complex arena geometries.

Method 3: Direct Scale Factor

Use when you know the exact cm-per-pixel ratio.

from neurospatial.animation import calibrate_video

# 4 pixels = 1 cm
calibration = calibrate_video(
    env,
    "session.mp4",
    cm_per_px=0.25,
)

Limitations: Assumes uniform scaling, no rotation, and video origin at environment origin.

Calibration Comparison

Method Accuracy Ease of Use Best For
Scale bar Medium Easiest Quick setup, overhead cameras
Landmarks Highest More work Angled cameras, precise alignment
cm_per_px Low-Medium Easy Known camera setup

Troubleshooting Spatial Misalignment

Video and field don't align

  1. Check coordinate order: Video landmarks use (x_px, y_px) = (column, row)
  2. Verify Y-axis direction: Environment Y increases upward, video Y increases downward
  3. Use more landmarks: 4+ points give better transforms than 3

Video appears rotated

Use landmark calibration instead of scale bar—it can handle rotation.

Warning about bounds mismatch

UserWarning: Environment bounds extend beyond calibrated video coverage.

The environment is larger than what the video shows. Either:

  • Expand video frame to cover more area
  • Accept that regions outside video will show no video pixels

1D environment error

ValueError: VideoOverlay requires 2D environment.

Video overlay only works with 2D environments. Linearized tracks (1D) have no meaningful 2D extent for video alignment.

Complete Example

Combine all overlay types for comprehensive behavioral visualization:

from neurospatial import Regions

from neurospatial import Environment
from neurospatial.animation import (
    PositionOverlay,
    BodypartOverlay,
    HeadDirectionOverlay,
    Skeleton,
)
from neurospatial.decoding import decode_session
import numpy as np

# Load data
positions = np.load("positions.npy")  # Animal position data
pose_data = np.load("pose.npy")  # DLC/SLEAP output
head_angles = np.load("angles.npy")  # Computed head direction
timestamps = np.load("times.npy")  # Timestamps in seconds
spike_times = ...  # list of per-cell spike-time arrays

# Create environment
env = Environment.from_samples(positions, bin_size=2.5, units="cm")
region_builder = Regions(env.regions.values())
region_builder.add("goal", point=np.array([80.0, 80.0]))
env = env.with_regions(region_builder)

# Decoded position over time: one posterior row per 25 ms bin
result = decode_session(env, spike_times, timestamps, positions, dt=0.025)

# Create overlays
position_overlay = PositionOverlay(
    positions=positions,
    times=timestamps,
    color="red",
    size=15.0,
    trail_length=8
)

pose_skeleton = Skeleton.from_edge_list(
    [
        ("nose", "ear_left"),
        ("nose", "ear_right"),
        ("nose", "tail_base"),
    ],
    name="rodent",
    edge_color="white",
    edge_width=2.0,
)
pose_overlay = BodypartOverlay(
    data={
        "nose": pose_data[:, 0, :],
        "ear_left": pose_data[:, 1, :],
        "ear_right": pose_data[:, 2, :],
        "tail_base": pose_data[:, 3, :],
    },
    times=timestamps,
    skeleton=pose_skeleton,
    colors={
        "nose": "yellow",
        "ear_left": "cyan",
        "ear_right": "cyan",
        "tail_base": "magenta",
    },
)

direction_overlay = HeadDirectionOverlay(
    headings=head_angles,
    times=timestamps,
    color="orange",
    length=10.0
)

# Animate with all overlays; each overlay keeps its own clock
env.animate_fields(
    result.posterior,
    frame_times=result.times,
    overlays=[position_overlay, pose_overlay, direction_overlay],
    show_regions=["goal"],
    region_alpha=0.4,
    colorbar_label="Probability",
    save_path="behavior_analysis.mp4",
)

Each frame of the video is labeled with its recording time.

Common Errors and Solutions

The overlay system provides actionable error messages following WHAT/WHY/HOW format.

Non-Monotonic Times

Error:

ValueError: Overlay timestamps are not monotonically increasing.
  Found 5 non-monotonic points at indices: [10, 25, 47, ...].
  First violation: times[10] = 1.523 >= times[11] = 1.521

  WHY: Interpolation requires strictly increasing timestamps.

  HOW: Choose one of these solutions:
    1. Sort timestamps and corresponding data: idx = np.argsort(times); times = times[idx]; data = data[idx]
    2. Remove duplicate or reversed timestamps
    3. Verify timestamp source for recording errors

Solution:

# Sort timestamps
idx = np.argsort(overlay_times)
sorted_times = overlay_times[idx]
sorted_data = overlay_data[idx]

overlay = PositionOverlay(positions=sorted_data, times=sorted_times, ...)

NaN/Inf Values

Error:

ValueError: Found 12 NaN/Inf values in overlay data at indices: [5, 8, 23, ...].
  First invalid value at index 5: [nan, 45.2]

  WHY: Rendering cannot place markers at invalid coordinates.

  HOW: Choose one of these solutions:
    1. Remove invalid samples: mask = np.isfinite(data).all(axis=1); data = data[mask]; times = times[mask]
    2. Interpolate over gaps: from scipy.interpolate import interp1d; ...
    3. Mask invalid values and skip rendering: data[~np.isfinite(data)] = np.nan

Solution:

# Remove invalid samples
mask = np.isfinite(trajectory).all(axis=1)
clean_trajectory = trajectory[mask]
clean_times = timestamps[mask]

overlay = PositionOverlay(positions=clean_trajectory, times=clean_times, ...)

Shape Mismatch

Error:

ValueError: Overlay data has incorrect shape.
  Expected: (n_samples, 2) for 2D environment
  Got: (500, 3)

  WHY: Coordinate dimensionality must match environment.n_dims = 2.

  HOW: Choose one of these solutions:
    1. Project 3D data to 2D: data_2d = data[:, :2]
    2. Use correct dimensionality for environment
    3. Recreate environment with matching dimensions

Solution:

# Project to 2D
trajectory_2d = trajectory_3d[:, :2]

overlay = PositionOverlay(positions=trajectory_2d, ...)

No Temporal Overlap

Error:

ValueError: No temporal overlap between overlay and frames.
  Overlay time range: [10.5, 25.3] seconds
  Frame time range: [0.0, 8.7] seconds
  Overlap: 0.0%

  WHY: Interpolation cannot generate values outside source data range.

  HOW: Choose one of these solutions:
    1. Adjust frame_times to overlap with overlay times
    2. Provide overlay data that covers frame time range
    3. Verify timestamp alignment (check units: seconds vs milliseconds)

Solution:

# Verify timestamp units
print(f"Overlay times: {overlay.times.min():.2f} - {overlay.times.max():.2f}")
print(f"Frame times: {frame_times.min():.2f} - {frame_times.max():.2f}")

# Adjust frame_times to overlap
frame_times = np.linspace(overlay.times.min(), overlay.times.max(), n_frames)

Partial Temporal Overlap (Warning)

Warning:

UserWarning: Limited temporal overlap between overlay and frames.
  Overlay time range: [0.0, 15.0] seconds
  Frame time range: [10.0, 25.0] seconds
  Overlap: 33.3% (5.0 / 15.0 seconds)

  Some frames will show NaN (no overlay data).
  Consider providing overlay data spanning the full frame time range.

Solution:

# Extend overlay data to cover full range, or accept NaN extrapolation
# NaN extrapolation is scientifically correct for missing data

Skeleton Missing Parts

Error:

ValueError: Skeleton references bodyparts not in data.
  Missing: ['ear_left', 'tail_tip']
  Available: ['nose', 'ear_right', 'tail_base']

  WHY: Cannot draw edges without both endpoint keypoints.

  HOW: Choose one of these solutions:
    1. Fix bodypart names in skeleton (check for typos)
    2. Add missing bodyparts to data dict
    3. Remove edges with missing parts from skeleton

  Suggestions (fuzzy match):
    'ear_left' -> Did you mean 'ear_right'?
    'tail_tip' -> Did you mean 'tail_base'?

Solution:

# Fix skeleton typo
skeleton = [
    ("nose", "ear_right"),   # Fixed: was "ear_left"
    ("nose", "tail_base"),   # Fixed: was "tail_tip"
]

Out-of-Bounds Coordinates (Warning)

Warning:

UserWarning: 23.4% of overlay points (234 / 1000) are outside environment bounds.
  Environment x range: [0.0, 100.0] cm
  Environment y range: [0.0, 100.0] cm
  Overlay x range: [-5.2, 105.3] cm (min/max)
  Overlay y range: [-2.1, 102.7] cm (min/max)

  Out-of-bounds points will still be rendered (may appear outside field).
  Verify coordinate system and units match.

Solution:

# Verify coordinate systems match
print(f"Environment range: {env.dimension_ranges}")
print(f"Overlay range: x=[{overlay.positions[:, 0].min()}, {overlay.positions[:, 0].max()}]")

# If units mismatch (e.g., meters vs cm), convert:
trajectory_cm = trajectory_meters * 100

Pickle-ability Error (Parallel Export)

Error:

ValueError: Parallel export (n_workers=4) sends the scene to worker processes,
but Video overlay 0 cannot be pickled (...). Pass n_workers=1 to
render in this process, or replace what cannot be pickled with plain data:
NumPy arrays, numbers, strings, and file paths instead of open readers, locks,
lambdas or local functions.

With n_workers above 1, each worker process rebuilds the scene from the environment, the fields and the overlay observations the scene prepared, so these must be picklable; the overlay objects themselves are not sent. Only objects you supplied can fail: a field source or custom video reader holding a lambda, an open file or a lock. The environment's cached analyses are never sent, and memory-mapped fields are reopened from their files rather than copied.

Solution:

# Replace that object with plain data (arrays, numbers, strings, file paths),
# or render in this process:
env.animate_fields(
    fields,
    frame_times=frame_times,
    overlays=[overlay],
    n_workers=1,
    save_path="output.mp4",
)

Troubleshooting Guide

Problem: Overlays don't appear in animation

Possible causes:

  1. The overlay is outside the shown time
  2. An overlay draws nothing before its first sample, after its last, or across a gap longer than its max_gap

  3. Temporal misalignment

  4. Overlay time range doesn't overlap with frame times
  5. Check for timestamp unit mismatches (seconds vs milliseconds)

  6. NaN values in overlay data

  7. Interpolation may produce all-NaN frames if outside data range
  8. Check error messages for NaN detection

Debug:

# Check temporal alignment
print(f"Overlay times: {overlay.times.min():.2f} - {overlay.times.max():.2f}")
print(f"Frame times: {frame_times.min():.2f} - {frame_times.max():.2f}")

# Check for NaN
print(f"NaN count: {np.isnan(overlay.positions).sum()}")

Problem: Animation runs slowly

Possible causes:

  1. The notebook widget draws every frame with Matplotlib
  2. Use backend="napari" or backend="fastplotlib" for long recordings
  3. Reduce trail_length for position overlays

  4. A long file export renders in one process

  5. Pass n_workers=None to render frames on every CPU
  6. Keep overlays to plain data (arrays, numbers, strings, file paths) so they can be sent to worker processes

  7. High-resolution rendering

  8. Reduce dpi (default: 100)
  9. Export a shorter window with Animation.export(time_range=...), or subsample the fields

Optimize:

# Parallel export at a lower resolution
if __name__ == "__main__":  # required for worker processes on macOS and Windows
    env.animate_fields(
        fields,
        frame_times=frame_times,
        overlays=[overlay],
        n_workers=None,  # every CPU
        dpi=80,
        save_path="output.mp4",
    )

# Or explore interactively
env.animate_fields(fields, frame_times=frame_times, overlays=[overlay], backend="napari")

Problem: Skeleton doesn't connect properly

Possible causes:

  1. Bodypart name typo in skeleton
  2. Error message provides fuzzy match suggestions
  3. Check for case sensitivity

  4. Missing keypoints in data

  5. Ensure all skeleton endpoints exist in data dict

  6. NaN values in keypoint coordinates

  7. Skeleton won't render if either endpoint is NaN

Fix:

# Verify all skeleton parts exist
bodypart_names = set(pose_overlay.data.keys())
skeleton_parts = set(sum(pose_overlay.skeleton, ()))  # Flatten tuples
missing = skeleton_parts - bodypart_names
if missing:
    print(f"Missing bodyparts: {missing}")

Problem: Warning about partial temporal overlap

This is expected when overlay data doesn't span the full frame time range.

Options:

  1. Accept NaN extrapolation (scientifically correct)
  2. Frames outside overlay range will show no overlay
  3. This is the correct behavior for missing data

  4. Adjust frame_times to match overlay coverage

    frame_times = np.linspace(
        overlay.times.min(),
        overlay.times.max(),
        n_frames
    )
    

  5. Extend overlay data to cover full range

  6. Collect more data or adjust time window

Advanced Usage

Subsampling for Video Export

Reduce frame count for faster video rendering:

from neurospatial.animation import subsample_frames

# Subsample from 250 Hz to 30 fps
subsampled_fields = subsample_frames(fields, source_fps=250, target_fps=30)
subsampled_times = subsample_frames(frame_times, source_fps=250, target_fps=30)

env.animate_fields(
    subsampled_fields,
    frame_times=subsampled_times,
    overlays=[overlay],  # Interpolation handles rate mismatch
    backend="video",
    save_path="output.mp4"
)

Custom Frame Times

Precisely control temporal alignment:

# Non-uniform frame times (e.g., event-triggered)
frame_times = np.array([0.0, 0.5, 1.2, 2.8, 3.1, ...])

env.animate_fields(
    fields,
    overlays=[overlay],
    frame_times=frame_times,  # Custom timing
    backend="napari"
)

Caches and Parallel Export

Worker processes for n_workers never receive the environment's cached analyses, so a parallel export does not need a cleared cache. Clearing only frees the memory held by kernels and eigenbases in the main process:

# Optional: release every cached analysis (takes no arguments)
env.clear_cache()

API Reference

PositionOverlay

@dataclass
class PositionOverlay:
    """Single trajectory with optional trail."""
    positions: NDArray[np.float64]              # (n_samples, n_dims)
    times: NDArray[np.float64] | None = None    # (n_samples,) or None
    color: str = "red"
    size: float = 10.0
    trail_length: int | None = None
    interp: Literal["linear", "nearest"] = "linear"

BodypartOverlay

@dataclass
class BodypartOverlay:
    """Multi-keypoint pose with optional skeleton."""
    data: dict[str, NDArray[np.float64]]        # {name: (n_samples, n_dims)}
    times: NDArray[np.float64] | None = None    # (n_samples,) or None
    skeleton: Skeleton | None = None            # edge styling lives on Skeleton
    colors: dict[str, str] | None = None
    interp: Literal["linear", "nearest"] = "linear"

HeadDirectionOverlay

@dataclass
class HeadDirectionOverlay:
    """Heading as angles (rad) or unit vectors."""
    data: NDArray[np.float64]           # (n_samples,) or (n_samples, n_dims)
    times: NDArray[np.float64] | None   # (n_samples,) or None
    color: str = "yellow"
    length: float = 20.0

VideoOverlay

@dataclass
class VideoOverlay:
    """Background video layer with spatial calibration."""
    source: str | Path | NDArray[np.uint8]  # Path or (n_frames, H, W, 3)
    calibration: VideoCalibration | None = None
    times: NDArray[np.float64] | None = None
    alpha: float = 0.5
    z_order: Literal["below", "above"] = "above"
    crop: tuple[int, int, int, int] | None = None  # (x, y, w, h)
    downsample: int = 1

VideoCalibration

@dataclass(frozen=True, eq=False)
class VideoCalibration:
    """Video→environment coordinate calibration (immutable)."""
    transform_px_to_cm: AffineND          # Pixel → cm transform (2D)
    frame_size_px: tuple[int, int]        # (width, height), positive integers
    transform_cm_to_px: AffineND          # Inverse (cm → pixel), set at construction

    @property
    def cm_per_px(self) -> float:
        """Approximate scale factor."""

animate_fields() Signature

def animate_fields(
    self,
    fields: NDArray[np.float64] | Sequence[NDArray[np.float64]] | Mapping[str, Any],
    *,
    frame_times: NDArray[np.float64],
    backend: Literal["auto", "napari", "video", "html", "widget", "fastplotlib"] = "auto",
    save_path: str | Path | None = None,
    overwrite: bool = False,
    overlays: list[OverlayProtocol] | None = None,
    speed: float = 1.0,
    max_playback_fps: int = 60,
    max_gap: float | None = 0.5,
    cmap: str = "viridis",
    vmin: float | None = None,
    vmax: float | None = None,
    title: str | None = None,
    show_regions: bool | list[str] = False,
    region_alpha: float = 0.3,
    scale_bar: bool | ScaleBarConfig | None = None,
    **kwargs: Any,  # e.g. colorbar_label, layer_names, track, dpi, n_workers
) -> AnimationView | Path:
    """Animate spatial fields on their recording clock."""
  • overlays: List of overlay instances, each on its own clock
  • frame_times: Time of each field row, in seconds (required)
  • max_gap: Longest interval over which a field row is held; longer gaps show as missing
  • show_regions: Display named regions (bool for all, list for specific)
  • region_alpha: Transparency for region rendering (0-1)

It returns an AnimationView for napari, the notebook widget and fastplotlib, or the Path of the written file. See the animation guide for every option.

See Also