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:
- Example Notebook - Hands-on tutorial with overlays, an HTML player and calibrated video
- Field Animation Guide - Viewers, timing, color limits and exports
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 coordinatestimes: 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 alignmentskeleton: ASkeletondefining 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 alignmentcolor: 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:VideoCalibrationobject 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) orlist[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,
HeadDirectionOverlayorVideoOverlay; show a heading as aTimeSeriesOverlayinstead. Napari needs 2-D or 3-D coordinates, and fastplotlib also refusesBodypartOverlayandObjectVectorOverlayon a track. - 3-D environments: headings must be 3-D unit vectors; only point regions
are drawn; no
VideoOverlayor 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:
- Y-axis flip: Video origin (top-left) → Environment origin (bottom-left)
- Scale: Pixels → centimeters → environment metric units
- 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¶
- Check coordinate order: Video landmarks use
(x_px, y_px)=(column, row) - Verify Y-axis direction: Environment Y increases upward, video Y increases downward
- 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¶
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¶
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:
- The overlay is outside the shown time
-
An overlay draws nothing before its first sample, after its last, or across a gap longer than its
max_gap -
Temporal misalignment
- Overlay time range doesn't overlap with frame times
-
Check for timestamp unit mismatches (seconds vs milliseconds)
-
NaN values in overlay data
- Interpolation may produce all-NaN frames if outside data range
- 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:
- The notebook widget draws every frame with Matplotlib
- Use
backend="napari"orbackend="fastplotlib"for long recordings -
Reduce
trail_lengthfor position overlays -
A long file export renders in one process
- Pass
n_workers=Noneto render frames on every CPU -
Keep overlays to plain data (arrays, numbers, strings, file paths) so they can be sent to worker processes
-
High-resolution rendering
- Reduce
dpi(default: 100) - 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:
- Bodypart name typo in skeleton
- Error message provides fuzzy match suggestions
-
Check for case sensitivity
-
Missing keypoints in data
-
Ensure all skeleton endpoints exist in
datadict -
NaN values in keypoint coordinates
- 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:
- Accept NaN extrapolation (scientifically correct)
- Frames outside overlay range will show no overlay
-
This is the correct behavior for missing data
-
Adjust frame_times to match overlay coverage
-
Extend overlay data to cover full range
- 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:
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 clockframe_times: Time of each field row, in seconds (required)max_gap: Longest interval over which a field row is held; longer gaps show as missingshow_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¶
- Field Animation - Viewers, timing, color limits and exports
- Regions - Defining spatial zones