Multi-trial setup#
You need a short Python script when:
You have multiple trials — separate video/audio/pose files per trial
You recorded from multiple cameras
You have multiple microphone files (one
.wavper mic)
The Getting Started Wizard only handles single files. Everything else uses
eto.from_datasets() (xarray) or
eto.load_nap_data() (pynapple/NWB), plus an
alignment file for media references and trial timing.
How alignment works#
Media filenames, trial timing, and stream offsets are stored in an NWB
alignment file at .ethograph/alignment.nwb — not inside the data file itself.
This keeps data portable (filenames are stored as basename only) and lets you
change media paths without re-exporting features.
Two functions create alignment files:
Function |
When to use |
|---|---|
Media files map 1:1 to trials |
|
Session-wide files, mixed per-trial + continuous, or explicit timestamps |
Column naming convention: {stream}_{device} — e.g. video_cam-1,
audio_mic-1, pose_cam-1.
Splitting a continuous recording into trials#
If you have a single session-long xr.Dataset but want to parcelate it into a trial structure, use eto.from_continuous():
import numpy as np
import pandas as pd
import xarray as xr
import ethograph as eto
# A continuous 10-minute recording at 30 fps
n_samples = 18000
time = np.arange(n_samples) / 30.0
ds = xr.Dataset({
"speed": xr.DataArray(np.random.randn(n_samples), dims=["time"],
coords={"time": time}),
})
# Define trial boundaries (seconds)
epochs = pd.DataFrame({
"trial": [1, 2, 3],
"start_time": [0.0, 120.0, 300.0],
"stop_time": [100.0, 250.0, 500.0],
})
dt = eto.from_continuous(ds, epochs)
dt.save("session.nc")
dt.trial(2) # returns the 120–250 s slice, time shifted to start at 0
from_continuous slices the dataset on demand and shifts time coordinates to 0 for each trial.
Minimal example#
import numpy as np
import pandas as pd
import xarray as xr
import ethograph as eto
from ethograph.io.nwb_alignment import align_media_per_trial
# 1) Build one xr.Dataset per trial
datasets = []
for trial_id in range(1, 6):
n_time = 9000
ds = xr.Dataset(
{"speed": xr.DataArray(
np.random.randn(n_time),
dims=["time"],
coords={"time": np.arange(n_time) / 30.0},
)},
)
ds.attrs["trial"] = trial_id
ds.attrs["fps"] = 30.0
datasets.append(ds)
dt = eto.from_datasets(datasets)
dt.save("session.nc")
# 2) Create alignment file for media
trial_table = pd.DataFrame({
"trial": list(range(1, 6)),
"video_cam-1": [f"trial{i:03d}.mp4" for i in range(1, 6)],
})
align_media_per_trial(
trial_table,
stream_rates={"video": 30.0},
output_path=".ethograph/alignment.nwb",
)
import numpy as np
import pynapple as nap
import pandas as pd
from ethograph.io.nwb_alignment import align_media_per_trial
# 1) Save feature data as pynapple
speed = nap.Tsd(
t=np.arange(45000) / 30.0, # 5 trials × 5 min at 30 fps
d=np.random.randn(45000),
)
trials = nap.IntervalSet(
start=[i * 300.0 for i in range(5)],
end=[(i + 1) * 300.0 - 0.5 for i in range(5)],
)
nap.save_file({"speed": speed, "trials": trials}, "session")
# 2) Create alignment file for media
trial_table = pd.DataFrame({
"trial": list(range(1, 6)),
"start_time": [i * 300.0 for i in range(5)],
"stop_time": [(i + 1) * 300.0 - 0.5 for i in range(5)],
"video_cam-1": [f"trial{i:03d}.mp4" for i in range(1, 6)],
})
align_media_per_trial(
trial_table,
stream_rates={"video": 30.0},
output_path=".ethograph/alignment.nwb",
)
# NWB files already store trials and media references natively.
# If your NWB file was created with pynwb or NeuroConv, media
# paths are stored as ImageSeries in nwb.acquisition.
# No separate alignment step is needed — just load the .nwb in the GUI.
#
# For NWB files from DANDI that lack local media paths, the GUI
# creates .ethograph/alignment.nwb automatically on first load.
Multiple cameras#
trial_table = pd.DataFrame({
"trial": [1, 2],
"video_cam-1": ["cam1_trial001.mp4", "cam1_trial002.mp4"],
"video_cam-2": ["cam2_trial001.mp4", "cam2_trial002.mp4"],
"pose_cam-1": ["dlc_cam1_trial001.h5", "dlc_cam1_trial002.h5"],
"pose_cam-2": ["dlc_cam2_trial001.h5", "dlc_cam2_trial002.h5"],
})
align_media_per_trial(
trial_table,
stream_rates={"video": 30.0, "pose": 30.0},
output_path=".ethograph/alignment.nwb",
)
Camera index determines which pose file is overlaid: device cam-1 maps to
pose_cam-1, etc.
For NWB: multi-camera NWB files store each camera as a separate
ImageSeries in nwb.acquisition (e.g. video_cam-1,
video_cam-2). EthoGraph discovers cameras automatically from acquisition
items.
Per-trial audio#
If audio is split per-trial, use align_media_per_trial with audio columns:
trial_table = pd.DataFrame({
"trial": [1, 2],
"video_cam-1": ["cam1_t1.mp4", "cam1_t2.mp4"],
"audio_mic-1": ["mic1_trial001.wav", "mic1_trial002.wav"],
"audio_mic-2": ["mic2_trial001.wav", "mic2_trial002.wav"],
})
align_media_per_trial(
trial_table,
stream_rates={"video": 30.0, "audio": 48000.0},
output_path=".ethograph/alignment.nwb",
)
For session-wide audio (one continuous file spanning all trials), see the Session-wide streams section below.
Trial timing#
Trial start/stop times are part of the alignment file’s trials table. When present, they enable session-mode navigation and let the GUI restrict neural data to trial windows. See NWB alignment for the full alignment model.
trial_table = pd.DataFrame({
"trial": list(range(1, 6)),
"start_time": [i * 300.0 for i in range(5)],
"stop_time": [(i + 1) * 300.0 - 0.5 for i in range(5)],
"video_cam-1": [f"trial{i:03d}.mp4" for i in range(1, 6)],
})
align_media_per_trial(
trial_table,
stream_rates={"video": 30.0},
output_path=".ethograph/alignment.nwb",
)
If start_time / stop_time are omitted, durations are inferred from the
media files.
Full worked example#
import numpy as np
import pandas as pd
import xarray as xr
import ethograph as eto
from ethograph.io.nwb_alignment import align_media_per_trial
datasets = []
for trial_id in range(1, 11):
n_time = 9000 # 5 minutes at 30 fps
time_s = np.arange(n_time) / 30.0
ds = xr.Dataset(
data_vars={
"position": xr.DataArray(
np.random.randn(n_time, 2, 4, 2),
dims=["time", "space", "keypoints", "individuals"],
),
"speed": xr.DataArray(
np.abs(np.random.randn(n_time, 4, 2)),
dims=["time", "keypoints", "individuals"],
),
},
coords={
"time": time_s,
"space": ["x", "y"],
"keypoints": ["nose", "left_ear", "right_ear", "tail"],
"individuals": ["mouse1", "mouse2"],
},
)
ds.attrs["trial"] = trial_id
ds.attrs["fps"] = 30.0
ds.attrs["stimulus"] = "tone_A" if trial_id % 2 else "tone_B"
datasets.append(ds)
dt = eto.from_datasets(datasets)
dt.save("session.nc")
# Alignment: media files + trial timing
trial_table = pd.DataFrame({
"trial": list(range(1, 11)),
"start_time": [i * 300.0 for i in range(10)],
"stop_time": [(i + 1) * 300.0 - 0.5 for i in range(10)],
"video_cam-1": [f"cam1_trial{tid:03d}.mp4" for tid in range(1, 11)],
"video_cam-2": [f"cam2_trial{tid:03d}.mp4" for tid in range(1, 11)],
"pose_cam-1": [f"dlc_cam1_trial{tid:03d}.h5" for tid in range(1, 11)],
"pose_cam-2": [f"dlc_cam2_trial{tid:03d}.h5" for tid in range(1, 11)],
})
align_media_per_trial(
trial_table,
stream_rates={"video": 30.0, "pose": 30.0},
output_path=".ethograph/alignment.nwb",
)
# NWB files already store trials, media references, and features together.
# For DANDI datasets:
# 1. Select the .nwb URL or downloaded file in the GUI
# 2. Click Load — trials, features, and media are read automatically
#
# For custom NWB files built with pynwb:
import pynwb
from datetime import datetime
from dateutil.tz import tzlocal
nwbfile = pynwb.NWBFile(
session_description="My experiment",
identifier="session-001",
session_start_time=datetime.now(tzlocal()),
)
nwbfile.add_trial_column(name="stimulus", description="Stimulus type")
for i in range(10):
nwbfile.add_trial(
start_time=i * 300.0,
stop_time=(i + 1) * 300.0 - 0.5,
stimulus="tone_A" if i % 2 else "tone_B",
)
from pynwb.behavior import BehavioralTimeSeries
behavior_mod = nwbfile.create_processing_module("behavior", "Behavioral data")
behavior_ts = BehavioralTimeSeries(name="BehavioralTimeSeries")
behavior_ts.create_timeseries(name="speed", data=speed_array, rate=30.0, unit="cm/s")
behavior_mod.add(behavior_ts)
with pynwb.NWBHDF5IO("session.nwb", "w") as io:
io.write(nwbfile)
Session-wide streams#
For setups where media files don’t map 1:1 to trials — e.g. one continuous
audio file covering all trials, ephys recorded on a separate clock, or
explicit timestamps from a DAQ — use
align_media_from_streams().
Session-wide audio#
import pandas as pd
from ethograph.io.nwb_alignment import align_media_from_streams
trials = pd.DataFrame({
"trial": [1, 2, 3],
"start_time": [0.0, 300.0, 600.0],
"stop_time": [299.5, 599.5, 899.5],
})
streams = [
{"name": "video_cam-1", "files": ["t1.mp4", "t2.mp4", "t3.mp4"], "rate": 30.0},
# Session-wide: one file, starting_time marks when it begins in session time
{"name": "audio_mic-1", "files": ["session_ch1.wav"], "rate": 48000.0, "starting_time": 0.0},
{"name": "audio_mic-2", "files": ["session_ch2.wav"], "rate": 48000.0, "starting_time": 0.0},
]
align_media_from_streams(trials, streams, ".ethograph/alignment.nwb")
Ephys with multiple trials#
Ephys is session-wide — select the file in the GUI rather than embedding it in
the dataset. If the ephys clock differs from the behavioural reference,
include it as a stream with a starting_time offset:
streams = [
{"name": "video_cam-1", "files": ["t1.mp4", "t2.mp4"], "rate": 30.0},
{"name": "ephys_probe-1", "files": ["session.dat"], "rate": 30000.0, "starting_time": 0.5},
]
align_media_from_streams(trials_df, streams, ".ethograph/alignment.nwb")
See From an ephys recording for supported file formats, Kilosort folder setup, and channel mapping.
Stream specification#
Each entry in streams accepts:
Key |
Type |
Description |
|---|---|---|
|
|
Stream identifier: |
|
|
One file (session-wide) or one per trial |
|
|
Sampling rate in Hz |
|
|
When the stream begins in session time. Default 0.0 |
|
|
Explicit per-sample timestamps. Overrides |
References#
TrialTree —
from_datasets(),from_continuous(), timing, iterationData Format Requirements —
xarray.Datasetstructure, pynapple objects, NWB conventionsalign_media_per_trial()— per-trial alignmentalign_media_from_streams()— session-wide + mixed alignment