diff --git a/.gitignore b/.gitignore index 817348a..e64b842 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ cython_debug/ sandbox/ .DS_Store +.aider* diff --git a/README.md b/README.md index 2699be7..3a428ea 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ And also animate biorbd models from the pyomeca organization. ``` conda install -c conda-forge pyorerun rerun-sdk=0.24.1``` ``` conda install opensim-org::opensim # not a mandatory dependency``` +``` conda install -c conda-forge biobuddy=0.2.0 # not a mandatory dependency``` # Rerun .c3d - As simple as that @@ -62,6 +63,10 @@ animation.rerun() if you want to use the OpenSim, you also need to install separately: ```conda install -c opensim-org::opensim``` +if you want to use the BioBuddy, you also need to install separately: +```conda install -c conda-forge biobuddy=0.2.0``` or +```pip install biobuddy==0.2.0``` + Then, ensure it is accessible in your Python environment by installing the package: ``` pip install . ``` or ``` python setup.py install ``` diff --git a/examples/biobuddy/from_biobuddy_model.py b/examples/biobuddy/from_biobuddy_model.py new file mode 100644 index 0000000..4b003dc --- /dev/null +++ b/examples/biobuddy/from_biobuddy_model.py @@ -0,0 +1,45 @@ +""" +To run this example, you need to install biobuddy: + `pip install biobuddy == 0.2.0` + or + `conda-forge install -c conda-forge biobuddy=0.2.0` +""" + +import numpy as np +import biobuddy + +from pyorerun import PhaseRerun, BiobuddyModel, DisplayModelOptions + + +def main(): + + # building some time components + nb_frames = 50 + nb_seconds = 1 + t_span = np.linspace(0, nb_seconds, nb_frames) + + # Creating the model + model_path = "../biorbd/models/Wu_Shoulder_Model_kinova_scaled_adjusted_2.bioMod" + biobuddy_model = biobuddy.BiomechanicalModelReal().from_biomod(model_path) + biobuddy_model.change_mesh_directories("../biorbd/models/Geometry_cleaned") + display_options = DisplayModelOptions() + prr_model = BiobuddyModel.from_biobuddy_object(biobuddy_model, options=display_options) + + # building some generalized coordinates + q = np.zeros((biobuddy_model.nb_q, nb_frames)) + q[10, :] = np.linspace(0, np.pi / 8, nb_frames) + q[12, :] = np.linspace(0, np.pi / 3, nb_frames) + q[11, :] = np.linspace(0, np.pi / 4, nb_frames) + q[13, :] = np.linspace(0, np.pi / 8, nb_frames) + q[14, :] = np.linspace(0, np.pi / 8, nb_frames) + q[15, :] = np.linspace(0, np.pi / 8, nb_frames) + + # Animate the model + viz = PhaseRerun(t_span) + viz.add_animated_model(prr_model, q) + # viz.rerun("msk_model") + viz.rerun_by_frame("msk_model") + + +if __name__ == "__main__": + main() diff --git a/pyorerun/__init__.py b/pyorerun/__init__.py index 93d29f5..d16efd5 100644 --- a/pyorerun/__init__.py +++ b/pyorerun/__init__.py @@ -3,13 +3,24 @@ from .model_components.model_display_options import DisplayModelOptions from .model_components.model_updapter import ModelUpdater +# Opensim try: from .model_interfaces import ( OsimModel, OsimModelNoMesh, ) except ImportError: - # OpenSim n'est pas installé, ces classes ne seront pas disponibles + # OpenSim is not installed, these classes will not be available + pass + +# biobuddy +try: + from .model_interfaces import ( + BiobuddyModel, + BiobuddyModelNoMesh, + ) +except ImportError: + # BioBuddy is not installed, these classes will not be available pass from .model_interfaces import ( diff --git a/pyorerun/model_interfaces/__init__.py b/pyorerun/model_interfaces/__init__.py index de91ee8..a325d4f 100644 --- a/pyorerun/model_interfaces/__init__.py +++ b/pyorerun/model_interfaces/__init__.py @@ -1,10 +1,18 @@ from .abstract_model_interface import AbstractSegment, AbstractModel, AbstractModelNoMesh from .biorbd_model_interface import BiorbdModelNoMesh, BiorbdModel +# Opensim try: from .osim_model_interface import OsimModelNoMesh, OsimModel except ImportError: - # OpenSim n'est pas installé, ces classes ne seront pas disponibles + # OpenSim is not installed, these classes will not be available + pass + +# Biobuddy +try: + from .biobuddy_model_interface import BiobuddyModelNoMesh, BiobuddyModel +except ImportError: + # BioBuddy is not installed, these classes will not be available pass from .available_interfaces import model_from_file diff --git a/pyorerun/model_interfaces/biobuddy_model_interface.py b/pyorerun/model_interfaces/biobuddy_model_interface.py new file mode 100644 index 0000000..cb24079 --- /dev/null +++ b/pyorerun/model_interfaces/biobuddy_model_interface.py @@ -0,0 +1,317 @@ +from functools import cached_property + +import numpy as np + +# Import the abstract classes +from .abstract_model_interface import AbstractModel, AbstractModelNoMesh, AbstractSegment + +MINIMAL_SEGMENT_MASS = 1e-08 + + +class BiobuddySegment(AbstractSegment): # Inherits from AbstractSegment + """ + An interface to simplify the access to a segment of a biobuddy model + """ + + def __init__(self, segment, index): + self.segment = segment + self._index: int = index + + @cached_property + def name(self) -> str: + return self.segment.name + + @cached_property + def id(self) -> int: + return self._index + + @cached_property + def has_mesh(self) -> bool: + has_mesh = self.segment.mesh_file is not None + if has_mesh: + return not self.segment.mesh_file.mesh_file_name.endswith("/") # Avoid empty mesh path + return has_mesh + + @cached_property + def has_meshlines(self) -> bool: + """ + * Not implemented in biobuddy yet, so returns False for now * + """ + return False + + @cached_property + def mesh_path(self) -> list[str]: + return [self.segment.mesh_file.mesh_file_directory + "/" + self.segment.mesh_file.mesh_file_name] + + @cached_property + def mesh_scale_factor(self) -> list[np.ndarray]: + """ + return: numpy array (3,) of the scale factor of the mesh + """ + return [self.segment.mesh_file.mesh_scale.reshape(-1)[:3]] + + @cached_property + def mass(self) -> float: + return self.segment.mass + + +class BiobuddyModelNoMesh(AbstractModelNoMesh): # Inherits from AbstractModelNoMesh + """ + A class to handle a biorbd model and its transformations + """ + + def __init__(self, path: str = None, options=None): + """ + A biobuddy.BiomechanicalModelReal cannot be created from a path directly, so we need to use the + BiobuddyModelNoMesh.from_biobuddy_object() to set it. + """ + if path is not None: + raise NotImplementedError("Loading a model from a path is not implemented yet for BioBuddy.") + super().__init__(path, options) + self.model = None + + @classmethod + def from_biobuddy_object(cls, model: "BiomechanicalModelReal", options=None): + new_object = cls(None, None) + new_object.model = model + if options is not None: + new_object.options = options + return new_object + + @cached_property + def name(self): + return "BioBuddy model" + + @cached_property + def marker_names(self) -> tuple[str, ...]: + return self.model.marker_names + + @cached_property + def nb_markers(self) -> int: + return self.model.nb_markers + + @cached_property + def segment_names(self) -> tuple[str, ...]: + return self.model.segment_names + + @cached_property + def nb_segments(self) -> int: + return self.model.nb_segments + + @cached_property + def segments(self): + """returns a NamedList[SegmentReal]""" + return self.model.segments + + @cached_property + def segments_with_mass(self) -> tuple: + """returns a tuple[SegmentReal]""" + segments_with_mass_list = [] + for s in self.segments: + inertia_parameters = s.segment.inertia_parameters + if inertia_parameters is not None: + mass = inertia_parameters.mass + if mass > MINIMAL_SEGMENT_MASS: + segments_with_mass_list.append(s) + return tuple(segments_with_mass_list) + + @cached_property + def segment_names_with_mass(self) -> tuple[str, ...]: + return tuple([s.name for s in self.segments_with_mass]) + + def segment_homogeneous_matrices_in_global(self, q: np.ndarray, segment_index: int) -> np.ndarray: + """ + Returns a biorbd object containing the roto-translation matrix of the segment in the global reference frame. + This is useful if you want to interact with biorbd directly later on. + """ + if np.sum(np.isnan(q)) != 0: + # If q contains NaN, return an identity matrix as biorbd will throw an error otherwise + rt_matrix = np.identity(4) + else: + segment_name = self.model.segment_names[segment_index] + rt_matrix = self.model.forward_kinematics(q)[segment_name][0].rt_matrix + return rt_matrix + + def markers(self, q: np.ndarray) -> np.ndarray: + """ + Returns a [N_markers x 3] array containing the position of each marker in the global reference frame + """ + return self.model.markers_in_global(q)[:3, :, 0].T + + def centers_of_mass(self, q: np.ndarray) -> np.ndarray: + """ + Returns a [N_segment_with_mass x 3] array containing the position of the centers of mass in the global reference frame + """ + segments_com = np.empty((0, 3)) + for s in self.segments_with_mass: + com = self.model.segment_com_in_global(s.segment.name, q)[:3, 0] + segments_com = np.vstack((segments_com, com)) + return segments_com + + @cached_property + def nb_ligaments(self) -> int: + """ + Returns the number of ligaments + * Not implemented in biobuddy yet, so returns 0 for now * + """ + return 0 + + @cached_property + def ligament_names(self) -> tuple[str, ...]: + """ + Returns the names of the ligaments + * Not implemented in biobuddy yet, so returns 0 for now * + """ + return () + + def ligament_strips(self, q: np.ndarray) -> list[list[np.ndarray]]: + """ + Returns the position of the ligaments in the global reference frame + * Not implemented in biobuddy yet, so returns 0 for now * + """ + return [] + + @cached_property + def nb_muscles(self) -> int: + """ + Returns the number of muscles + """ + return self.model.nb_muscles + + @cached_property + def muscle_names(self) -> tuple[str, ...]: + """ + Returns the names of the muscles + """ + return self.model.muscle_names + + def muscle_strips(self, q: np.ndarray) -> list[list[np.ndarray]]: + """ + Returns the position of the muscles in the global reference frame + """ + muscles = [] + for muscle_group in self.model.muscle_groups: + for muscle in muscle_group.muscles: + muscle_strip = [] + muscle_strip += [self.model.muscle_origin_in_global(muscle.name, q)[:3, 0].tolist()] + if muscle.nb_via_points > 0: + muscle_strip += self.model.via_points_in_global(muscle.name, q)[:3, 0].T.tolist() + muscle_strip += [self.model.muscle_insertion_in_global(muscle.name, q)[:3, 0].tolist()] + muscles += [muscle_strip] + return muscles + + @cached_property + def nb_q(self) -> int: + return self.model.nb_q + + @cached_property + def dof_names(self) -> tuple[str, ...]: + return tuple(s.dof_names for s in self.model.segments) + + @cached_property + def q_ranges(self) -> tuple[tuple[float, float], ...]: + q_ranges = [q_range for segment in self.model.segments for q_range in segment.q_ranges] + return tuple((q_range.min_bound(), q_range.max_bound()) for q_range in q_ranges) + + @cached_property + def gravity(self) -> np.ndarray: + return self.model.gravity + + @cached_property + def has_mesh(self) -> bool: + return any([s.has_mesh for s in self.segments]) + + @cached_property + def has_meshlines(self) -> bool: + return any([s.has_meshlines for s in self.segments]) + + @cached_property + def has_soft_contacts(self) -> bool: + """ + * Not implemented in biobuddy yet, so returns False for now * + """ + return False + + @property + def has_rigid_contacts(self) -> bool: + return self.model.nb_contacts > 0 + + def soft_contacts(self, q: np.ndarray) -> np.ndarray: + """ + Returns the position of the soft contacts spheres in the global reference frame + * Not implemented in biobuddy yet, so returns an empty array for now * + """ + return np.array([]) + + def rigid_contacts(self, q: np.ndarray) -> np.ndarray: + """ + Returns the position of the rigid contacts in the global reference frame + """ + return self.model.contacts_in_global(q)[:3, :, 0].T + + @cached_property + def soft_contacts_names(self) -> tuple[str, ...]: + """ + Returns the names of the soft contacts + * Not implemented in biobuddy yet, so returns an empty tuple for now * + """ + return () + + @cached_property + def rigid_contacts_names(self) -> tuple[str, ...]: + """ + Returns the names of the soft contacts + """ + return self.model.contact_names + + @cached_property + def soft_contact_radii(self) -> tuple[float, ...]: + """ + Returns the radii of the soft contacts + * Not implemented in biobuddy yet, so returns an empty tuple for now * + """ + return () + + +class BiobuddyModel(BiobuddyModelNoMesh, AbstractModel): # Inherits from BiobuddyModelNoMesh and AbstractModel + """ + This class extends the BiobuddyModelNoMesh class and overrides the segments property. + It filters the segments to only include those that have a mesh. + """ + + def __init__(self, path, options=None): + super().__init__(path, options) + + @property + def segments(self) -> tuple[BiobuddySegment, ...]: + segments_with_mesh = [] + for i, s in enumerate(self.model.segments): + segment = BiobuddySegment(s, i) + if segment.has_mesh or segment.has_meshlines: + segments_with_mesh.append(segment) + return tuple(segments_with_mesh) + + @cached_property + def meshlines(self) -> list[np.ndarray]: + raise NotImplementedError("Meshlines were not implemented for BioBuddy models.") + # # TODO + # meshes = [] + # for segment in self.segments: + # segment_mesh = segment.segment.mesh + # meshes += [np.array([segment_mesh.point(i).to_array() for i in range(segment_mesh.nbVertex())])] + # + # return meshes + + def mesh_homogenous_matrices_in_global(self, q: np.ndarray, segment_index: int, **kwargs) -> np.ndarray: + """ + Returns a list of homogeneous matrices of the mesh in the global reference frame + """ + if np.sum(np.isnan(q)) != 0: + # If q contains NaN, return an identity matrix as biorbd will throw an error otherwise + return np.identity(4) + else: + mesh_rt = super(BiobuddyModel, self).segments[segment_index].mesh_file.mesh_rt.rt_matrix + + # mesh_rt = self.segments[segment_index].segment.characteristics().mesh().getRotation().to_array() + segment_rt = self.segment_homogeneous_matrices_in_global(q, segment_index=segment_index) + return segment_rt @ mesh_rt