From 972f8641635a24aebb13febe2c25ac2713ba2672 Mon Sep 17 00:00:00 2001 From: Aadit Khurana Date: Sun, 22 Mar 2026 19:45:39 -0400 Subject: [PATCH 1/4] feat: add initial draft of C3D loader using ezc3d --- movement/io/load_c3d.py | 120 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 movement/io/load_c3d.py diff --git a/movement/io/load_c3d.py b/movement/io/load_c3d.py new file mode 100644 index 000000000..6651338ec --- /dev/null +++ b/movement/io/load_c3d.py @@ -0,0 +1,120 @@ +"""Load pose data from C3D (optical marker) files.""" + +from __future__ import annotations +from pathlib import Path +import numpy as np +import xarray as xr + +# This line is the hook that connects your code to the main software +from movement.io.load import register_loader + +@register_loader("C3D") +def from_c3d_file( + file: str | Path, + fps: float | None = None, + **kwargs, +) -> xr.Dataset: + """Load pose data from C3D files into movement's standard format.""" + + try: + import ezc3d + except ImportError: + raise ImportError("ezc3d is required. Install it with: pip install ezc3d") + + file_path = Path(file) + if not file_path.exists(): + raise FileNotFoundError(f"C3D file not found: {file_path}") + + # 1. Load the file using the bulletproof engine + try: + c3d_file = ezc3d.c3d(str(file_path)) + except Exception as e: + raise ValueError(f"Failed to load C3D file. Error: {e}") + + # Extract raw data: shape is [Axis(4), Markers, Time] + raw_points = c3d_file['data']['points'] + + if raw_points.size == 0: + raise ValueError("C3D file contains no marker data") + + n_axes, n_markers, n_frames = raw_points.shape + + # 2. CHOP and SHUFFLE to match movement's requirements + xyz_points = raw_points[0:3, :, :] + + # [Space, Markers, Time] -> [Time, Space, Markers] + shuffled_points = np.transpose(xyz_points, (2, 0, 1)) + + # STAPLE the Individuals dimension -> [Time, Space, Markers, Individuals] + position_data = np.expand_dims(shuffled_points, axis=3) + + # 3. Extract correct metadata (Labels and FPS) directly from the file + try: + raw_labels = c3d_file['parameters']['POINT']['LABELS']['value'] + + # In case the file only has one single label (returns a string instead of a list) + if isinstance(raw_labels, str): + raw_labels = [raw_labels] + + # The Bouncer: Force the labels list to perfectly match the data length (n_markers) + # If the file gave us 48 labels, we chop off the extra 22. + marker_names = [str(label) for label in raw_labels][:n_markers] + + # If the file somehow gave us FEWER labels than data, we pad the rest with generic names + while len(marker_names) < n_markers: + marker_names.append(f"unlabeled_{len(marker_names)}") + + except KeyError: + marker_names = [f"marker_{i}" for i in range(n_markers)] + + if fps is None: + try: + fps = c3d_file['header']['points']['frame_rate'] + except KeyError: + fps = 100.0 + + # 4. Package it into the official xarray format + time_coords = np.arange(n_frames) + space_coords = ["x", "y", "z"] + individual_names = ["individual_0"] + + position = xr.DataArray( + position_data, + coords={ + "time": time_coords, + "space": space_coords, + "keypoints": marker_names, + "individuals": individual_names, + }, + dims=["time", "space", "keypoints", "individuals"], + name="position", + ) + + # Create dummy confidence scores (since optical markers are highly accurate) + confidence = xr.DataArray( + np.ones((n_frames, n_markers, 1)), + coords={ + "time": time_coords, + "keypoints": marker_names, + "individuals": individual_names, + }, + dims=["time", "keypoints", "individuals"], + name="confidence", + ) + + ds = xr.Dataset( + {"position": position, "confidence": confidence}, + coords={ + "time": time_coords, + "space": space_coords, + "keypoints": marker_names, + "individuals": individual_names, + } + ) + + ds.attrs["source_software"] = "C3D" + ds.attrs["fps"] = fps + ds.attrs["ds_type"] = "poses" + ds.attrs["file_path"] = str(file_path) + + return ds \ No newline at end of file From 135a3842ea3d169877ef3aee805e2dc3cddfab3a Mon Sep 17 00:00:00 2001 From: Aadit Khurana Date: Mon, 23 Mar 2026 15:28:42 -0400 Subject: [PATCH 2/4] style: remove explanatory comments from c3d loader --- movement/io/load_c3d.py | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/movement/io/load_c3d.py b/movement/io/load_c3d.py index 6651338ec..0a73f8d50 100644 --- a/movement/io/load_c3d.py +++ b/movement/io/load_c3d.py @@ -1,79 +1,49 @@ """Load pose data from C3D (optical marker) files.""" - from __future__ import annotations from pathlib import Path import numpy as np import xarray as xr - -# This line is the hook that connects your code to the main software from movement.io.load import register_loader - @register_loader("C3D") + def from_c3d_file( file: str | Path, fps: float | None = None, **kwargs, ) -> xr.Dataset: """Load pose data from C3D files into movement's standard format.""" - try: import ezc3d except ImportError: raise ImportError("ezc3d is required. Install it with: pip install ezc3d") - file_path = Path(file) if not file_path.exists(): raise FileNotFoundError(f"C3D file not found: {file_path}") - - # 1. Load the file using the bulletproof engine try: c3d_file = ezc3d.c3d(str(file_path)) except Exception as e: raise ValueError(f"Failed to load C3D file. Error: {e}") - - # Extract raw data: shape is [Axis(4), Markers, Time] raw_points = c3d_file['data']['points'] - if raw_points.size == 0: raise ValueError("C3D file contains no marker data") - n_axes, n_markers, n_frames = raw_points.shape - - # 2. CHOP and SHUFFLE to match movement's requirements xyz_points = raw_points[0:3, :, :] - - # [Space, Markers, Time] -> [Time, Space, Markers] shuffled_points = np.transpose(xyz_points, (2, 0, 1)) - - # STAPLE the Individuals dimension -> [Time, Space, Markers, Individuals] position_data = np.expand_dims(shuffled_points, axis=3) - - # 3. Extract correct metadata (Labels and FPS) directly from the file try: raw_labels = c3d_file['parameters']['POINT']['LABELS']['value'] - - # In case the file only has one single label (returns a string instead of a list) if isinstance(raw_labels, str): raw_labels = [raw_labels] - - # The Bouncer: Force the labels list to perfectly match the data length (n_markers) - # If the file gave us 48 labels, we chop off the extra 22. marker_names = [str(label) for label in raw_labels][:n_markers] - - # If the file somehow gave us FEWER labels than data, we pad the rest with generic names while len(marker_names) < n_markers: marker_names.append(f"unlabeled_{len(marker_names)}") - except KeyError: marker_names = [f"marker_{i}" for i in range(n_markers)] - if fps is None: try: fps = c3d_file['header']['points']['frame_rate'] except KeyError: fps = 100.0 - - # 4. Package it into the official xarray format time_coords = np.arange(n_frames) space_coords = ["x", "y", "z"] individual_names = ["individual_0"] @@ -89,8 +59,6 @@ def from_c3d_file( dims=["time", "space", "keypoints", "individuals"], name="position", ) - - # Create dummy confidence scores (since optical markers are highly accurate) confidence = xr.DataArray( np.ones((n_frames, n_markers, 1)), coords={ From 4f39991f6b5cdebf6086c4a38f8b32022719a7b5 Mon Sep 17 00:00:00 2001 From: Aadit Khurana Date: Mon, 23 Mar 2026 16:02:37 -0400 Subject: [PATCH 3/4] style: apply pre-commit auto-formatting --- movement/io/load_c3d.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/movement/io/load_c3d.py b/movement/io/load_c3d.py index 0a73f8d50..b0ab9b4c2 100644 --- a/movement/io/load_c3d.py +++ b/movement/io/load_c3d.py @@ -1,11 +1,16 @@ """Load pose data from C3D (optical marker) files.""" + from __future__ import annotations + from pathlib import Path + import numpy as np import xarray as xr + from movement.io.load import register_loader -@register_loader("C3D") + +@register_loader("C3D") # type: ignore[arg-type] def from_c3d_file( file: str | Path, fps: float | None = None, @@ -14,24 +19,33 @@ def from_c3d_file( """Load pose data from C3D files into movement's standard format.""" try: import ezc3d - except ImportError: - raise ImportError("ezc3d is required. Install it with: pip install ezc3d") + except ImportError as e: + raise ImportError( + "ezc3d is required. Install it with: pip install ezc3d" + ) from e + file_path = Path(file) if not file_path.exists(): raise FileNotFoundError(f"C3D file not found: {file_path}") + try: c3d_file = ezc3d.c3d(str(file_path)) except Exception as e: - raise ValueError(f"Failed to load C3D file. Error: {e}") - raw_points = c3d_file['data']['points'] + raise ValueError(f"Failed to load C3D file. Error: {e}") from e + + raw_points = c3d_file["data"]["points"] + if raw_points.size == 0: raise ValueError("C3D file contains no marker data") + n_axes, n_markers, n_frames = raw_points.shape + xyz_points = raw_points[0:3, :, :] shuffled_points = np.transpose(xyz_points, (2, 0, 1)) position_data = np.expand_dims(shuffled_points, axis=3) + try: - raw_labels = c3d_file['parameters']['POINT']['LABELS']['value'] + raw_labels = c3d_file["parameters"]["POINT"]["LABELS"]["value"] if isinstance(raw_labels, str): raw_labels = [raw_labels] marker_names = [str(label) for label in raw_labels][:n_markers] @@ -39,11 +53,13 @@ def from_c3d_file( marker_names.append(f"unlabeled_{len(marker_names)}") except KeyError: marker_names = [f"marker_{i}" for i in range(n_markers)] + if fps is None: try: - fps = c3d_file['header']['points']['frame_rate'] + fps = c3d_file["header"]["points"]["frame_rate"] except KeyError: fps = 100.0 + time_coords = np.arange(n_frames) space_coords = ["x", "y", "z"] individual_names = ["individual_0"] @@ -59,6 +75,7 @@ def from_c3d_file( dims=["time", "space", "keypoints", "individuals"], name="position", ) + confidence = xr.DataArray( np.ones((n_frames, n_markers, 1)), coords={ @@ -77,7 +94,7 @@ def from_c3d_file( "space": space_coords, "keypoints": marker_names, "individuals": individual_names, - } + }, ) ds.attrs["source_software"] = "C3D" @@ -85,4 +102,4 @@ def from_c3d_file( ds.attrs["ds_type"] = "poses" ds.attrs["file_path"] = str(file_path) - return ds \ No newline at end of file + return ds From 49ab22123c20de4829acffc798de48c6c5a2eeb2 Mon Sep 17 00:00:00 2001 From: Aadit Khurana Date: Mon, 23 Mar 2026 16:11:16 -0400 Subject: [PATCH 4/4] test: add unit tests for missing and invalid C3D files --- tests/test_unit/test_io/test_load_c3d.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/test_unit/test_io/test_load_c3d.py diff --git a/tests/test_unit/test_io/test_load_c3d.py b/tests/test_unit/test_io/test_load_c3d.py new file mode 100644 index 000000000..e43f98fe1 --- /dev/null +++ b/tests/test_unit/test_io/test_load_c3d.py @@ -0,0 +1,20 @@ +"""Test the C3D loader functionality.""" + +import pytest + +from movement.io.load_c3d import from_c3d_file + + +def test_load_c3d_file_not_found(): + """Test that the loader gracefully catches missing files.""" + fake_path = "this_file_does_not_exist.c3d" + with pytest.raises(FileNotFoundError, match="C3D file not found"): + from_c3d_file(fake_path) + + +def test_load_c3d_invalid_file(tmp_path): + """Test that the loader gracefully catches corrupted C3D files.""" + broken_file = tmp_path / "broken.c3d" + broken_file.write_text("This is definitely not a real C3D file.") + with pytest.raises(ValueError, match="Failed to load C3D file"): + from_c3d_file(broken_file)