-
Notifications
You must be signed in to change notification settings - Fork 8
Replace pyomeca dependency with custom Pyomarkers class #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
fb9d1c5
Initial plan for issue
Copilot d982561
Create custom Pyomarkers class and update imports
Copilot abc74d2
Complete Pyomarkers implementation with channels compatibility and ex…
Copilot 0f4a567
Remove final pyomeca references from setup.py and pyproject.toml
Copilot b071db7
Merge branch 'osim_model' into copilot/fix-65
Ipuch 913aff3
udpate tests
Ipuch d09bb71
fix: tests and examples
Ipuch 00bd3a9
default: no label displayed
Ipuch 5b2906d
env: pyomeca no-longer needed ;)
Ipuch 46d092e
fix
Ipuch File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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__( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Function |
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.