Optical motion capture — Vicon (.c3d)¶
Converts 3D marker trajectories from a Vicon C3D recording into BIDS.
This example shows how to:
- Load a C3D file with
ezc3d - Read marker trajectories and the capture frame rate
- Convert marker units from millimetres (C3D default) to metres (BIDS)
- Build consistent
POSchannel metadata — one channel per marker and axis - Validate, export, and plot a marker trajectory to sanity-check the result
examples/from_c3d_vicon.py
"""
Example: Convert Vicon marker data from a .c3d file to BIDS format
This example demonstrates:
- Loading a Vicon C3D recording with ezc3d
- Reading 3D marker trajectories and the capture frame rate
- Converting marker units from millimetres (C3D default) to metres (BIDS)
- Building consistent BIDS Channel metadata for position (POS) data
- Validating and exporting the recording as BIDS motion files
- Plotting a single marker trajectory to sanity-check the export
Requirements:
pip install motionbids ezc3d numpy pandas matplotlib
Example data:
A sample Vicon recording is downloaded automatically into a `data/` folder
next to this script on first run, from:
https://github.com/JuliusWelzel/motionbids/blob/main/examples/data/example_vicon.c3d
"""
import urllib.request
from pathlib import Path
import pandas as pd
import ezc3d
from matplotlib import pyplot as plt
from motionbids import (
MotionData,
Channel,
validate_motion_data,
export_bids_motion,
create_bids_directory_structure,
export_dataset_description,
)
# Raw download URL for the bundled example recording
EXAMPLE_DATA_URL = (
"https://raw.githubusercontent.com/JuliusWelzel/motionbids/main/"
"examples/data/example_vicon.c3d"
)
# The three spatial axes a Vicon marker is sampled on. Defined once and reused
# everywhere below so the DataFrame columns, the Channel metadata, and the plot
# can never drift apart.
AXES = ("x", "y", "z")
# Configuration
bids_root = Path(__file__).parent / "bids_dataset"
data_folder = Path(__file__).parent / "data"
subject = "ID001"
session = "01" # Optional: set to None if no sessions
task_name = "calibration"
tracksys = "vicon"
def clean_marker_label(label: str, index: int) -> str:
"""Return a clean, BIDS-friendly marker name.
Vicon names *unlabeled* trajectories "*0", "*1", ... The leading "*" is an
awkward character for a BIDS channel label, so those are renamed to
"marker0", "marker1", ... Real marker labels (e.g. "LASI") are kept as-is,
minus any stray surrounding whitespace.
"""
label = label.strip()
if not label or label.startswith("*"):
return f"marker{index}"
return label
def channel_name(marker: str, axis: str) -> str:
"""Build the canonical channel name for a marker/axis pair (e.g. "LASI_x").
Using a single helper for naming guarantees the DataFrame column, the
Channel object, and any later lookup (plotting, validation) all agree.
"""
return f"{marker}_{axis}"
# =========================================================================
# 1) Make sure the example recording is available locally
# =========================================================================
data_folder.mkdir(exist_ok=True)
example_file = data_folder / "example_vicon.c3d"
if example_file.exists():
print(f"Example data already present: {example_file}")
else:
print(f"Downloading example data from {EXAMPLE_DATA_URL}")
urllib.request.urlretrieve(EXAMPLE_DATA_URL, example_file)
print(f"Saved to {example_file}")
# =========================================================================
# 2) Load the C3D file
# =========================================================================
print("\n1. Loading C3D data")
c3d = ezc3d.c3d(str(example_file))
# Marker positions, shape = (4, n_markers, n_frames):
# axis 0 -> [X, Y, Z, residual] (residual is a tracking-quality value)
# axis 1 -> marker index
# axis 2 -> frame index
points = c3d["data"]["points"]
# Marker labels (one per marker) and the capture rate in Hz.
# Labels are cleaned so unlabeled Vicon trajectories ("*0", ...) become valid,
# readable channel names ("marker0", ...) and stay consistent everywhere.
raw_marker_labels = c3d["parameters"]["POINT"]["LABELS"]["value"]
marker_labels = [clean_marker_label(label, i) for i, label in enumerate(raw_marker_labels)]
sampling_frequency = c3d["parameters"]["POINT"]["RATE"]["value"][0]
n_frames = points.shape[2]
print(f" Found {len(marker_labels)} markers, {n_frames} frames at {sampling_frequency:.1f} Hz")
# =========================================================================
# 3) Build the data table and matching channel metadata
# =========================================================================
print("\n2. Building data table and BIDS channel metadata")
# `data` holds one column per marker/axis; `channels` holds the BIDS metadata
# describing each of those columns. They are built together in the same loop so
# every column has exactly one matching Channel, in the same order.
data = {}
channels = []
for i, marker in enumerate(marker_labels):
# points[axis, marker, :] -> trajectory for one axis of this marker.
# C3D stores positions in millimetres; BIDS expects metres, so divide by 1000.
xyz = points[:3, i, :] / 1000.0
for axis_idx, axis in enumerate(AXES):
name = channel_name(marker, axis)
# Column in the data table...
data[name] = xyz[axis_idx]
# ...and the BIDS Channel that describes it (POS = position data).
channels.append(
Channel(
channel_name=name,
channel_component=axis,
channel_type="POS",
channel_tracked_point=marker,
channel_units="m",
)
)
df = pd.DataFrame(data)
print(f" Built {df.shape[1]} channels ({len(marker_labels)} markers x {len(AXES)} axes)")
print(df.head())
# =========================================================================
# 4) Assemble the MotionData object
# =========================================================================
print("\n3. Creating MotionData object")
# Each unique tracked point (marker) counts once, regardless of its 3 axes.
tracked_points_count = len({c.channel_tracked_point for c in channels})
motion = MotionData(
subject=subject,
session=session,
task_name=task_name,
tracksys=tracksys,
tracked_points_count=tracked_points_count,
data=df.to_numpy(),
sampling_frequency=sampling_frequency,
channels=channels,
)
# Run the package's internal consistency checks (e.g. data columns vs channels).
validate_motion_data(motion)
print(" Package internal validation passed")
# =========================================================================
# 5) Export to BIDS
# =========================================================================
print(f"\n4. Exporting to BIDS format at {bids_root}")
# Create the sub-/ses-/motion directory tree and the top-level dataset metadata.
motion_dir = create_bids_directory_structure(
base_dir=bids_root,
subject=subject,
session=session,
)
export_dataset_description(
bids_root=bids_root,
name="Vicon Motion Dataset",
authors=["Ilaria D'Ascanio"],
dataset_type="raw",
task_description="Static posture, Vicon calibration task",
)
# Write the *_motion.tsv / *_channels.tsv / *_motion.json files.
export_bids_motion(
data=motion,
out_dir=motion_dir,
validate=True,
overwrite=True,
)
print(" Export completed")
# =========================================================================
# 6) Sanity-check plot: read the exported TSV back and plot one marker
# =========================================================================
print("\n5. Plotting exported data for a quick visual check")
bids_file = (
motion_dir
/ f"sub-{subject}_ses-{session}_task-{task_name}_tracksys-{tracksys}_motion.tsv"
)
# Reload from disk to confirm the file round-trips correctly.
# IMPORTANT: BIDS motion.tsv files are written WITHOUT a header row (the column
# descriptions live in the companion channels.tsv). So we read with
# `header=None` and re-attach the channel names ourselves, in the same order
# they were exported.
column_names = [c.channel_name for c in channels]
exported = pd.read_csv(bids_file, sep="\t", header=None, names=column_names)
# Plot the first marker so the example works regardless of which markers exist.
# `channel_name` is reused here, so the columns we look up are guaranteed to
# match the ones written above.
marker = marker_labels[0]
plt.figure(figsize=(12, 6))
for axis in AXES:
name = channel_name(marker, axis)
plt.plot(exported[name], label=name)
plt.title(f"Marker {marker} trajectory")
plt.xlabel("Frame")
plt.ylabel("Position (m)")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Next Steps¶
- Movella DOT example — IMU conversion from XDF
- Workflow Guide — step-by-step explanation of each stage
- Class Reference —
MotionDataandChannelAPI