Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyorerun/multi_phase_rerun.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import numpy as np
import rerun as rr # NOTE: `rerun`, not `rerun-sdk`!
import rerun.blueprint as rrb
from pyomeca import Markers as PyoMarkers
from .pyomarkers import Pyomarkers as PyoMarkers

from .model_interfaces import AbstractModel
from .phase_rerun import PhaseRerun
Expand Down
2 changes: 1 addition & 1 deletion pyorerun/phase_rerun.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import numpy as np
import rerun as rr
from pyomeca import Markers as PyoMarkers
from .pyomarkers import Pyomarkers as PyoMarkers

from .abstract.q import QProperties
from .model_interfaces import AbstractModel
Expand Down
188 changes: 188 additions & 0 deletions pyorerun/pyomarkers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""
Custom Pyomarkers class to replace pyomeca dependency.
"""
import numpy as np
import ezc3d
from typing import Optional, Union, List


class Pyomarkers:
"""
A class to handle 3D marker data, designed to replace pyomeca.Markers.

This class provides compatibility with the existing pyomeca.Markers API
while being self-contained and removing the external dependency.

Attributes
----------
data : np.ndarray
The marker data with shape (4, n_markers, n_frames) where the 4th dimension
includes homogeneous coordinates (x, y, z, 1)
time : np.ndarray
Time vector for each frame
marker_names : list of str
Names/labels of the markers
attrs : dict
Metadata attributes (e.g., units)
show_labels : bool
Whether labels should be displayed
"""

def __init__(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function __init__ has 5 arguments (exceeds 4 allowed). Consider refactoring.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function __init__ has a Cognitive Complexity of 11 (exceeds 5 allowed). Consider refactoring.

self,
data: np.ndarray,
time: Optional[np.ndarray] = None,
marker_names: Optional[List[str]] = None,
show_labels: bool = True,
attrs: Optional[dict] = None
):
"""
Initialize Pyomarkers instance.

Parameters
----------
data : np.ndarray
Marker data. Can be (3, n_markers, n_frames) or (4, n_markers, n_frames)
time : np.ndarray, optional
Time vector. If None, creates a simple index-based time vector
marker_names : list of str, optional
Marker names. If None, creates default names
show_labels : bool, default True
Whether to show marker labels
attrs : dict, optional
Metadata attributes
"""
# Handle data shape - ensure we have 4D (homogeneous coordinates)
if data.ndim != 3:
raise ValueError("Data must be 3D array with shape (3 or 4, n_markers, n_frames)")

if data.shape[0] == 3:
# Add homogeneous coordinate (w=1)
ones = np.ones((1, data.shape[1], data.shape[2]))
self.data = np.vstack([data, ones])
elif data.shape[0] == 4:
self.data = data.copy()
else:
raise ValueError("First dimension must be 3 or 4 (x,y,z or x,y,z,w)")

# Set up time vector
if time is None:
self.time = np.arange(self.data.shape[2], dtype=float)
else:
self.time = np.array(time)

# Set up marker names
if marker_names is None:
self.marker_names = [f"marker_{i}" for i in range(self.data.shape[1])]
else:
self.marker_names = list(marker_names)

# Validate dimensions
if len(self.marker_names) != self.data.shape[1]:
raise ValueError("Number of marker names must match number of markers")
if len(self.time) != self.data.shape[2]:
raise ValueError("Time vector length must match number of frames")

# Set attributes
self.attrs = attrs if attrs is not None else {}
self.show_labels = show_labels

# Create a mock channel object for compatibility
self.channel = MockChannel(self.marker_names)

@property
def shape(self) -> tuple:
"""Return the shape of the data."""
return self.data.shape

def to_numpy(self) -> np.ndarray:
"""Return the data as a numpy array."""
return self.data.copy()

def __truediv__(self, other):
"""Support division for unit conversion."""
new_data = self.data / other
return Pyomarkers(
data=new_data,
time=self.time.copy(),
marker_names=self.marker_names.copy(),
show_labels=self.show_labels,
attrs=self.attrs.copy()
)

def __itruediv__(self, other):
"""Support in-place division for unit conversion."""
self.data /= other
return self

@classmethod
def from_c3d(
cls,
filename: str,
prefix_delimiter: str = ":",
suffix_delimiter: str = None,
show_labels: bool = True
) -> "Pyomarkers":
"""
Create Pyomarkers from a C3D file.

Parameters
----------
filename : str
Path to the C3D file
prefix_delimiter : str, default ":"
Delimiter for prefix in marker names
suffix_delimiter : str, optional
Delimiter for suffix in marker names
show_labels : bool, default True
Whether to show marker labels

Returns
-------
Pyomarkers
A new Pyomarkers instance
"""
c3d = ezc3d.c3d(filename)

# Get marker data
points = c3d["data"]["points"] # Shape: (4, n_markers, n_frames)

# Get marker names
marker_names = c3d["parameters"]["POINT"]["LABELS"]["value"]

# Clean up marker names (remove empty strings and strip whitespace)
marker_names = [name.strip() for name in marker_names if name.strip()]

# Get time vector
point_rate = c3d["parameters"]["POINT"]["RATE"]["value"][0]
n_frames = points.shape[2]
time = np.arange(n_frames) / point_rate

# Get units
units = "mm" # Default C3D unit
if "UNITS" in c3d["parameters"]["POINT"]:
units = c3d["parameters"]["POINT"]["UNITS"]["value"][0].strip()

attrs = {
"units": units,
"rate": point_rate,
"filename": filename
}

return cls(
data=points,
time=time,
marker_names=marker_names,
show_labels=show_labels,
attrs=attrs
)


class MockChannel:
"""
Mock channel object to provide compatibility with pyomeca.Markers.channel API.
"""

def __init__(self, marker_names: List[str]):
self.values = np.array(marker_names)
self.data = marker_names
2 changes: 1 addition & 1 deletion pyorerun/rrc3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import imageio
import numpy as np
import rerun as rr
from pyomeca import Markers as PyoMarkers
from .pyomarkers import Pyomarkers as PyoMarkers

from .multi_frame_rate_phase_rerun import MultiFrameRatePhaseRerun
from .phase_rerun import PhaseRerun
Expand Down
2 changes: 1 addition & 1 deletion pyorerun/xp_components/markers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import numpy as np
import rerun as rr
from pyomeca import Markers as PyoMarkers
from ..pyomarkers import Pyomarkers as PyoMarkers

from ..abstract.abstract_class import ExperimentalData
from ..abstract.markers import Markers, MarkerProperties
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,17 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
# "ezc3d", # Not yet available on pypi, use `conda install -c conda-forge ezc3d`
"ezc3d", # Now needed for C3D file loading in our custom Pyomarkers
"numpy==1.26.4",
"rerun-sdk==0.21.0",
"trimesh",
"pyomeca",
"tk",
"imageio",
"imageio-ffmpeg",
# "opensim", # Not yet available on pypi, use `conda install opensim-org::opensim`
# "biorbd" # Not yet available on pypi, use `conda install -c conda-forge biorbd`
]
keywords = ["c3d", "motion capture", "rerun", "biorbd", "pyomeca"]
keywords = ["c3d", "motion capture", "rerun", "biorbd", "markers"]

[project.urls]
homepage = "http://github.com/Ipuch/pyorerun"
2 changes: 1 addition & 1 deletion tests/test_phase_rerun.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import numpy as np
import pytest
from pyomeca import Markers as PyoMarkers
from pyorerun.pyomarkers import Pyomarkers as PyoMarkers

from pyorerun import BiorbdModel, PhaseRerun
from pyorerun.model_phase import ModelRerunPhase
Expand Down
131 changes: 131 additions & 0 deletions tests/test_pyomarkers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Tests for the custom Pyomarkers class.
"""
import numpy as np
import pytest
from pyorerun.pyomarkers import Pyomarkers


def test_pyomarkers_basic_init():
"""Test basic initialization of Pyomarkers."""
# Create test data with shape (3, 2, 5) - 2 markers, 5 frames
data = np.random.rand(3, 2, 5)

markers = Pyomarkers(data)

# Check shape - should be (4, 2, 5) after adding homogeneous coordinate
assert markers.shape == (4, 2, 5)

# Check data integrity - first 3 dimensions should match input
np.testing.assert_array_equal(markers.to_numpy()[:3, :, :], data)

# Check homogeneous coordinate (should be all ones)
np.testing.assert_array_equal(markers.to_numpy()[3, :, :], np.ones((2, 5)))

# Check default marker names
assert markers.marker_names == ["marker_0", "marker_1"]

# Check channel compatibility
assert hasattr(markers, 'channel')
np.testing.assert_array_equal(markers.channel.values, ["marker_0", "marker_1"])

# Check default time vector
np.testing.assert_array_equal(markers.time, np.arange(5, dtype=float))

# Check attrs
assert isinstance(markers.attrs, dict)


def test_pyomarkers_with_custom_params():
"""Test Pyomarkers with custom parameters."""
data = np.random.rand(3, 3, 10)
time = np.linspace(0, 1, 10)
marker_names = ["head", "shoulder", "elbow"]
attrs = {"units": "m", "rate": 100}

markers = Pyomarkers(
data=data,
time=time,
marker_names=marker_names,
show_labels=False,
attrs=attrs
)

assert markers.shape == (4, 3, 10)
assert markers.marker_names == marker_names
np.testing.assert_array_equal(markers.time, time)
assert markers.attrs == attrs
assert markers.show_labels == False
np.testing.assert_array_equal(markers.channel.values, marker_names)


def test_pyomarkers_4d_input():
"""Test Pyomarkers with 4D input data."""
data = np.random.rand(4, 2, 5)

markers = Pyomarkers(data)

# Should preserve the input data as-is
assert markers.shape == (4, 2, 5)
np.testing.assert_array_equal(markers.to_numpy(), data)


def test_pyomarkers_validation_errors():
"""Test validation errors in Pyomarkers."""
# Wrong number of dimensions
with pytest.raises(ValueError, match="Data must be 3D array"):
Pyomarkers(np.random.rand(3, 5)) # 2D array

# Wrong first dimension size
with pytest.raises(ValueError, match="First dimension must be 3 or 4"):
Pyomarkers(np.random.rand(5, 2, 3)) # 5D first dimension

# Mismatched marker names
data = np.random.rand(3, 2, 5)
with pytest.raises(ValueError, match="Number of marker names must match"):
Pyomarkers(data, marker_names=["one", "two", "three"]) # 3 names for 2 markers

# Mismatched time vector
with pytest.raises(ValueError, match="Time vector length must match"):
Pyomarkers(data, time=np.arange(3)) # 3 time points for 5 frames


def test_compatibility_with_existing_usage():
"""Test compatibility with existing usage patterns from the codebase."""
# Create data similar to existing tests
data = np.array([[[1000, 2000]], [[3000, 4000]], [[5000, 6000]]], dtype=float)
markers = Pyomarkers(data)
markers.attrs["units"] = "mm"

# Test shape access patterns
assert markers.shape[1] == 1 # number of markers
assert markers.shape[2] == 2 # number of frames

# Test to_numpy access pattern
result = markers.to_numpy()[:3, :, :]
np.testing.assert_array_equal(result, data)

# Test channel.values access pattern
marker_names_list = markers.channel.values.tolist()
assert isinstance(marker_names_list, list)
assert len(marker_names_list) == 1

# Test attrs access pattern
assert markers.attrs["units"] == "mm"


def test_max_xy_coordinate_span_compatibility():
"""Test compatibility with max_xy_coordinate_span function pattern."""
# Create test data that matches the existing test pattern
data = np.array([[[1, 2], [-1, -2]], [[0.5, 1], [-0.5, -1]], [[0, 0], [0, 0]]]) # X, Y, Z coords
markers = Pyomarkers(data)

# Test the actual function logic from max_xy_coordinate_span_by_markers
marker_data = markers.to_numpy()
min_markers = np.nanmin(np.nanmin(marker_data, axis=2), axis=1)
max_markers = np.nanmax(np.nanmax(marker_data, axis=2), axis=1)
x_absolute_max = np.nanmax(np.abs([min_markers[0], max_markers[0]]))
y_absolute_max = np.nanmax(np.abs([min_markers[1], max_markers[1]]))
result = np.max([x_absolute_max, y_absolute_max])

assert result == 2.0 # Same as the original test expectation
2 changes: 1 addition & 1 deletion tests/test_rerun_c3d.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import numpy as np
import pytest
from pyomeca import Markers as PyoMarkers
from pyorerun.pyomarkers import Pyomarkers as PyoMarkers

from pyorerun.rrc3d import (
max_xy_coordinate_span_by_markers,
Expand Down