Complete Workflow¶
This guide demonstrates a full workflow from raw motion data to a BIDS-compliant dataset.
Installation¶
Or with uv:
For development:
Basic Workflow¶
Step 1: Prepare Your Data¶
Motion data should be a NumPy array with: - Rows: timepoints (samples) - Columns: channels (e.g., marker positions)
import numpy as np
from motionbids import MotionData, Channel, validate_motion_data, export_bids_motion
# Example: 10 markers tracked at 120 Hz for 10 seconds
n_timepoints = 1200 # 10 seconds × 120 Hz
n_markers = 10
n_channels = n_markers * 3 # x, y, z for each marker
# Load or generate your data (rows=time, columns=channels)
data = np.random.randn(n_timepoints, n_channels)
# Define channel metadata (REQUIRED for BIDS compliance)
# Each channel needs: channel_name, channel_component, channel_type, channel_tracked_point, channel_units
channels = [
Channel(
channel_name=f"marker{i}_{axis}",
channel_component=axis,
channel_type="POS",
channel_tracked_point=f"marker{i}",
channel_units="mm"
)
for i in range(n_markers)
for axis in ['x', 'y', 'z']
]
Step 2: Describe the Reference Frame (Optional)¶
Channels can state which coordinate system they are expressed in, through the
channel_reference_frame field. That field only holds a label (e.g.
"global"); what the label means is described by a ReferenceFrame and
exported as the *_channels.json sidecar.
from motionbids import ReferenceFrame
# 1. Label the channels (add to the Channel definitions from Step 1)
channels = [
Channel(
channel_name=f"marker{i}_{axis}",
channel_component=axis,
channel_type="POS",
channel_tracked_point=f"marker{i}",
channel_units="mm",
channel_reference_frame="global" # links to the frame described below
)
for i in range(n_markers)
for axis in ['x', 'y', 'z']
]
# 2. Describe what the label means
global_frame = ReferenceFrame(
name="global", # matches channel_reference_frame
spatial_axes="PLS", # X posterior, Y left, Z superior
rotation_order="ZXY",
rotation_rule="left-hand",
description="Laboratory frame of the optical system, origin at the "
"calibration L-frame corner."
)
Reading spatial_axes: one character per axis, in the order X, Y, Z, naming
the direction that axis points to:
Aanterior (forward),Pposterior (backward)Lleft,RrightSsuperior (up),Iinferior (down)_axis not used (e.g."P_S"for 2D data)
An optical system whose X points to the back of the room, Y to the left and Z up
is therefore "PLS".
A/P/S or F/B/U?
The reference frame section of the motion specification spells the
directions anatomically (A, P, L, R, S, I, _), while the
SpatialAxes object in the BIDS schema documents the equivalent egocentric
characters (F, B, L, R, U, D, _ — forward, backward, left,
right, up, down). Both are accepted; F/B/U/D emit a UserWarning
suggesting the anatomical spelling ("BLU" → "PLS").
The handedness of the axis triad follows from the directions alone, so it is worth checking against the rotation rule you declared:
Step 3: Create MotionData Object¶
motion = MotionData(
# Required fields
subject="01",
task_name="walk",
tracksys="optical", # optical, imu, video, etc.
sampling_frequency=120.0,
tracked_points_count=10,
# Recommended fields
manufacturer="Vicon",
manufacturers_model_name="Vantage V5",
recording_type="continuous",
# Optional session/run info
session="01",
run=1,
acquisition="indoor",
acq_time="2025-11-05T14:30:00",
# Data and channels (REQUIRED)
data=data,
channels=channels, # List of Channel objects
# Reference frames (optional, from Step 2)
reference_frames=[global_frame]
)
Step 4: Validate and Export¶
# Validate BIDS compliance
validate_motion_data(motion)
print("✓ Data is BIDS compliant!")
# Export to BIDS format
files = export_bids_motion(
motion,
out_dir="bids_dataset/sub-01/ses-01/motion/",
validate=True,
overwrite=True
)
print(f"Created: {list(files.keys())}")
# Output: ['json', 'tsv', 'channels', 'channels_json', 'scans']
channels_json only when frames are described
The *_channels.json sidecar (key 'channels_json') is written only when
reference_frames is set and a *_channels.tsv is written alongside it —
it describes a column of that file. If channels use a reference_frame
label that no ReferenceFrame describes — or the other way around —
validation emits a ValidationWarning.
Complete Dataset Example¶
Full BIDS Dataset Creation¶
from pathlib import Path
from motionbids import (
MotionData,
export_bids_motion,
create_bids_directory_structure,
export_dataset_description,
export_participants_tsv,
)
# 1. Setup BIDS root directory
bids_root = Path("my_motion_study")
# 2. Create dataset description
export_dataset_description(
bids_root=bids_root,
name="Motion Capture Study",
authors=["Your Name"],
dataset_type="raw"
)
# 3. Process each subject
for subject in ["01", "02", "03"]:
# Record participant demographics
export_participants_tsv(bids_root, participant_id=subject, age="25", sex="F")
for session in ["01", "02"]:
# Create directory structure
motion_dir = create_bids_directory_structure(
base_dir=bids_root,
subject=subject,
session=session
)
# Create motion data
data = load_subject_data(subject, session) # Your function
motion = MotionData(
subject=subject,
session=session,
task_name="walk",
tracksys="optical",
sampling_frequency=120.0,
tracked_points_count=10,
manufacturer="Vicon",
data=data,
channels=channels, # List of Channel objects
acq_time=get_timestamp(subject, session) # Your function
)
# Export
export_bids_motion(motion, out_dir=motion_dir, validate=True)
print(f"✓ Dataset created at {bids_root}")
Output Structure¶
my_motion_study/
├── dataset_description.json
├── participants.tsv
├── sub-01/
│ ├── sub-01_ses-01_scans.tsv
│ ├── sub-01_ses-02_scans.tsv
│ ├── ses-01/
│ │ └── motion/
│ │ ├── sub-01_ses-01_task-walk_tracksys-optical_motion.json
│ │ ├── sub-01_ses-01_task-walk_tracksys-optical_motion.tsv
│ │ ├── sub-01_ses-01_task-walk_tracksys-optical_channels.tsv
│ │ └── sub-01_ses-01_task-walk_tracksys-optical_channels.json
│ └── ses-02/
│ └── motion/
│ └── ...
├── sub-02/
│ └── ...
└── sub-03/
└── ...
Output Files Explained¶
1. JSON Metadata (*_motion.json)¶
Contains acquisition parameters:
{
"TaskName": "walk",
"SamplingFrequency": 120.0,
"TrackedPointsCount": 10,
"Manufacturer": "Vicon",
"ManufacturersModelName": "Vantage V5",
"RecordingType": "continuous"
}
2. TSV Data (*_motion.tsv)¶
Time series data (no headers, per BIDS spec):
3. Channels File (*_channels.tsv)¶
Channel descriptions:
name component type tracked_point units
marker0_x x POS marker0 mm
marker0_y y POS marker0 mm
marker0_z z POS marker0 mm
pelvis_quat_w quat_w ORNT pelvis n/a
An optional reference_frame column names the coordinate system each channel is
expressed in; the frames themselves are described in *_channels.json.
4. Channels Sidecar (*_channels.json)¶
Reference frame descriptions (when reference_frames is provided). Each entry
under Levels describes one label of the reference_frame column:
{
"reference_frame": {
"Levels": {
"global": {
"SpatialAxes": "PLS",
"RotationOrder": "ZXY",
"RotationRule": "left-hand",
"Description": "Laboratory frame: X to the back, Y to the left, Z up."
}
}
}
}
5. Scans File (*_scans.tsv)¶
Acquisition timestamps (when acq_time provided):
6. Dataset Description (dataset_description.json)¶
Create a dataset description with additional fields:
from motionbids import export_dataset_description
export_dataset_description(
bids_root="my_motion_study",
name="Motion Capture Study",
authors=["Jane Doe", "John Smith"],
License="CC0",
Acknowledgements="Thanks to our participants",
Funding=["NIH Grant 12345"]
)
7. Participants File (participants.tsv)¶
Store participant information. The file is created or updated automatically — existing entries for other participants are preserved:
from motionbids import export_participants_tsv
# Create / append participant entries
export_participants_tsv(
bids_root="my_motion_study",
participant_id="01",
age="25",
sex="F",
handedness="R",
group="control" # extra columns are supported via **kwargs
)
# A second call appends a new row (or updates if the participant already exists)
export_participants_tsv(
bids_root="my_motion_study",
participant_id="02",
age="30",
sex="M",
handedness="L",
group="patient"
)
Output (participants.tsv):
Note
If participants.tsv already exists (e.g., from a prior EEG conversion),
a UserWarning is emitted. Existing rows are preserved; only the row for
the given participant_id is added or updated.
Common Patterns¶
Multiple Runs¶
for run_num in [1, 2, 3]:
motion = MotionData(
subject="01",
task_name="walk",
tracksys="optical",
run=run_num, # Increment run
sampling_frequency=120.0,
tracked_points_count=10,
data=load_run(run_num),
channels=channels # List of Channel objects
)
export_bids_motion(motion, out_dir=motion_dir)
Loading from CSV¶
import pandas as pd
# Load motion data from CSV
df = pd.read_csv("motion_capture.csv")
data = df.values # Convert to NumPy array
columns = df.columns.tolist()
# Define channel metadata based on your data structure
n_markers = 10
channels = [
Channel(
channel_name=columns[i],
channel_component=['x', 'y', 'z'][i % 3],
channel_type='POS',
channel_tracked_point=f'marker{i // 3}',
channel_units='mm'
)
for i in range(len(columns))
]
motion = MotionData(
subject="01",
task_name="walk",
tracksys="optical",
sampling_frequency=120.0,
tracked_points_count=10,
data=data,
channels=channels
)
Loading from C3D (requires ezc3d)¶
import ezc3d
import numpy as np
from motionbids import MotionData, Channel
c3d = ezc3d.c3d("recording.c3d")
points = c3d["data"]["points"] # (4, n_markers, n_frames)
labels = c3d["parameters"]["POINT"]["LABELS"]["value"]
freq = c3d["parameters"]["POINT"]["RATE"]["value"][0]
# Reshape to (n_frames, n_channels)
data = points[:3, :, :].transpose(2, 1, 0).reshape(-1, len(labels) * 3)
channels = [
Channel(
channel_name=f"{label}_{axis}",
channel_component=axis,
channel_type="POS",
channel_tracked_point=label,
channel_units="mm"
)
for label in labels
for axis in ["x", "y", "z"]
]
motion = MotionData(
subject="01",
task_name="walk",
tracksys="optical",
sampling_frequency=freq,
tracked_points_count=len(labels),
data=data,
channels=channels
)
Loading IMU Data¶
import numpy as np
from motionbids import MotionData, Channel
# Example: 2 IMU sensors with accelerometer + gyroscope
sensors = ["left_wrist", "right_wrist"]
acc_axes = ["x", "y", "z"]
gyro_axes = ["x", "y", "z"]
# Load your IMU data (rows=time, cols=channels)
# Expected column order: sensor1_acc_x, sensor1_acc_y, sensor1_acc_z,
# sensor1_gyro_x, ..., sensor2_acc_x, ...
data = np.random.randn(6000, len(sensors) * 6) # 60s at 100 Hz
channels = []
for sensor in sensors:
for axis in acc_axes:
channels.append(Channel(
channel_name=f"{sensor}_ACCEL_{axis}",
channel_component=axis,
channel_type="ACCEL",
channel_tracked_point=sensor,
channel_units="m/s^2"
))
for axis in gyro_axes:
channels.append(Channel(
channel_name=f"{sensor}_GYRO_{axis}",
channel_component=axis,
channel_type="GYRO",
channel_tracked_point=sensor,
channel_units="rad/s"
))
motion = MotionData(
subject="01",
task_name="walk",
tracksys="imu",
sampling_frequency=100.0,
tracked_points_count=len(sensors),
manufacturer="Xsens",
data=data,
channels=channels
)
Custom Metadata¶
motion = MotionData(
subject="01",
task_name="walk",
tracksys="optical",
sampling_frequency=120.0,
tracked_points_count=10,
data=data,
channels=channels,
additional_metadata={
"CaptureVolume": "8m x 6m x 3m",
"CalibrationDate": "2025-11-01",
"AmbientTemperature": 22.5
}
)
Validation¶
Important
The validation provided by this package is for convenience only and is not officially supported by BIDS. Always validate your dataset with the official BIDS Validator before sharing or publishing.
Package Validation (Convenience)¶
from motionbids import validate_motion_data, ValidationError
try:
validate_motion_data(motion)
print("✓ Valid")
except ValidationError as e:
print(f"✗ Validation failed: {e}")
What Gets Validated¶
Required Fields¶
- ✅ Required fields present (
subject,task_name,tracksys, etc.) - ✅ Positive numeric values (
sampling_frequency > 0) - ⚠️ Recommended fields present (warnings only)
Data Consistency¶
- ✅ Array dimensions match (
data.shape[1] == len(columns)) - ✅ Units match columns (
len(units) == len(columns))
Channel Information¶
- ✅ Channel metadata validated against BIDS schema during construction
- ✅ All three channel metadata fields required together:
channel_component,channel_type,channel_tracked_point - ✅ Component values validated:
x,y,z,quat_x,quat_y,quat_z,quat_w,n/a - ✅ Type values validated (uppercase):
POS,ORNT,VEL,ACCEL,GYRO,ANGACCEL,MAGN,JNTANG,LATENCY,MISC - ✅ Component-type compatibility checked (e.g., quaternion components only allowed with
ORNTtype)
Reference Frames¶
- ✅
spatial_axes,rotation_orderandrotation_rulevalidated duringReferenceFrameconstruction - ✅ Frame names are unique across
MotionData.reference_frames - ⚠️
check_reference_frameswarns when channel labels and described frames do not line up (label without a frame, frame without a channel, or labels but no frames at all)
Channel Export Validation¶
- ✅ Required fields check: On export, channels are validated to have all required BIDS fields (
name,component,type,tracked_point,units). Export will fail with aValueErrorif any required field is missing. - ⚠️ Unknown fields warning: If a channel contains fields not in the required or optional BIDS specification, a
ValidationWarningis raised.
Channel Metadata Required
You must provide explicit channel metadata (channel_component, channel_type, channel_tracked_point) when creating a MotionData object with data. These are validated during construction to ensure BIDS compliance. All three fields must be provided together and match the length of your columns list.
Official BIDS Validation¶
Important
After exporting your dataset, always validate with the official BIDS Validator:
- Web version: https://bids-standard.github.io/bids-validator/
The official validator is the authoritative source for BIDS compliance.
Troubleshooting¶
"Missing required field: tracksys"¶
# ✗ Missing tracksys
motion = MotionData(subject="01", task_name="walk", ...)
# ✓ Include tracksys
motion = MotionData(
subject="01",
task_name="walk",
tracksys="optical", # Required!
...
)
"Number of columns must match data dimensions"¶
# ✗ Mismatch
data = np.random.randn(1000, 30)
columns = ["ch0", "ch1"] # Only 2 names for 30 columns
# ✓ Match dimensions
columns = [f"ch{i}" for i in range(30)] # 30 names for 30 columns
Next Steps¶
- MotionData Class Reference - Detailed class documentation
- Schema Fields - All available BIDS fields
- Reference Frame Example - Runnable script for the
*_channels.jsonsidecar