diff --git a/.gitignore b/.gitignore index 63250a2..117114a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ # For your local virtual environment venv +.venv + +# To have local stuff outside git +*.local.* # For all of the automatically generated videos MMS-Examples/*.mp4 diff --git a/Docs/DEVELOPERS.md b/Docs/DEVELOPERS.md index 6789c48..fddcb6a 100644 --- a/Docs/DEVELOPERS.md +++ b/Docs/DEVELOPERS.md @@ -100,9 +100,9 @@ In addition, there are binary requirements: ``` assets/ - binary files needed for the final blender scene - avasag_avatar_casual.glb - is our target character + avasag_avatar_casual.glb - is our target character, currently called Gloria. controller_config.json - described what inflection strategy must be used for each target bone - defaults.blend - default blender scene, containing an imported version of the GLB. It contains some default rendering values and lights, and the timeline will be filled by the MMS animation. + gloria-YYMMDD.blend - default blender scene, containing an imported version of the GLB. It contains some default rendering values and lights. Its timeline will be filled by the MMS animation. ignorelist.json - list of bones that must be deleted after loading an armature ``` diff --git a/assets/LICENSE.txt b/assets/LICENSE.txt index 5cadb4b..cb82ba2 100644 --- a/assets/LICENSE.txt +++ b/assets/LICENSE.txt @@ -13,7 +13,7 @@ https://creativecommons.org/licenses/by-nc-sa/4.0/ ## The default Blender scene -The file `defaults.blend` is the base Blender scene containing the imported Gloria character, default light setup, and some default render options. +The file `gloria-YYMMDD.blend` is the base Blender scene containing the imported Gloria character, default light setup, and some default render options. The "default Blender scene" by "DFKI GmbH" (https://www.dfki.de) are is licensed under CC BY-NC-SA. CREATIVE COMMONS ATTRIBUTION-NONCOMMERCIAL-SHAREALIKE 4.0 INTERNATIONAL diff --git a/assets/gloria-260624.blend b/assets/gloria-260624.blend new file mode 100644 index 0000000..6aec02b Binary files /dev/null and b/assets/gloria-260624.blend differ diff --git a/main.py b/main.py index fa5069e..a723d8e 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,18 @@ from typing import List, Optional +# The template Blender scene containing the character, the light setup, and some default rendering parameters +DEFAULT_BLEND_SCENE = "./assets/gloria-260624.blend" +# In the template scene, the name of the armature object to be animated. +TARGET_ARMATURE_NAME = "skeleton #5" +# In the template scene, the name of the camera object used for rendering. +RENDER_CAMERA_NAME = "Camera" +# The name of the final action containing the composed sign sequence +TARGET_ACTION_NAME = "final_action" +# Path to the JSON file with the list of bones to ignore during animation procedures +# BONES_IGNORE_LIST_PATH = "./assets/ignorelist.json" + + def add_options(arg_parser: argparse.ArgumentParser): """Add command line options to the parser. @@ -142,7 +154,12 @@ def add_options(arg_parser: argparse.ArgumentParser): ) arg_parser.add_argument( - "--render-sentence", action="store_true", help="Render the sentence video." + "--render-sentence", + type=int, + help="Render the video of a si ngle specified sentence (AVASAG project)." \ + " Provide an integer number X as parameter, it will be converted in 'SatzX.blend" \ + "The file will be searched in the corpus in the folder 'generated/sentences/trimmed/'.", + required=False ) arg_parser.add_argument( @@ -219,7 +236,7 @@ def post_bake( view3d.spaces[0].region_3d.view_perspective = "CAMERA" # Setup the objects to be rendered - bpy.context.scene.camera = bpy.data.objects["Camera"] + bpy.context.scene.camera = bpy.data.objects[RENDER_CAMERA_NAME] # Cleanup the animation curves action = bpy.data.actions.get(action_name) @@ -273,28 +290,94 @@ def post_bake( bpy.ops.wm.save_as_mainfile(filepath=str(blend_path)) -def render_sentence(sentence_id: str, generated_root: Path, glue: Glue, arguments: argparse.Namespace) -> None: - """Render the original sentence from the database. +def initialize_scene(): + """Initialize the current Blender context: + i) Copy all objects and worlds from the source template blender scene to the current context. + ii) Link all objects into the current context to work on them. + """ - This function performs no inflection. Therefore, rendering is as straightforward as it can be. + # Select all objects and delete them. + # Will leave only the armature data and the actions. + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete() + + # Load objects from the reference scene (nice character and lights) + with bpy.data.libraries.load(DEFAULT_BLEND_SCENE) as (data_from, data_to): + data_to.objects = data_from.objects + data_to.worlds = data_from.worlds + + # Link all objects to the scene, to make them visible + for obj in data_to.objects: + bpy.context.scene.collection.objects.link(obj) + + # Set the current world to the first found in the loaded template + bpy.context.scene.world = data_to.worlds[0] + + +def initialize_target_armature(): + """Replace the bone names in the rtemplate armature and create a new action. + The original names in the template scene are "Bone Pelvis". This name doesn't work for the + skeletal animations which are of format "Bone_Pelvis". Thus, we modify + the name of bones in the original mesh itself as it is one time operation. + + Then, creates the final target action and assign it as current action of the armature + """ - @param sentence_id: The id of the sentence to render. - @param generated_root: The root path for the corpus database. - @param glue: Glue object that is used to connect compile the final animation. - @param arguments: Command-line arguments. + target_armature_obj = bpy.data.objects[TARGET_ARMATURE_NAME] + assert isinstance(target_armature_obj, bpy.types.Object) + assert target_armature_obj.type == "ARMATURE" + + for bone in target_armature_obj.pose.bones: + bone.name = bone.name.replace(" ", "_") + bone.rotation_mode = "ZXY" + + target_armature_obj.animation_data_create() + action_ref = bpy.data.actions.new(TARGET_ACTION_NAME) + target_armature_obj.animation_data.action = action_ref + + +def execute_single_sentence_realization_pipeline(arguments: argparse.Namespace) -> None: + """ + For some use cases, it is necessary for us to only render the single original entence data into the avatar. + Inflections are not needed + Thus, the following block assures that we load the correct sentence animation render it, but bypassing the instancing and inflection overhead. + This function performs no inflection. Therefore, rendering is as straightforward as it can be. """ - sentence_id = "Satz" + sentence_id.lstrip("0") - animation_data = Path(generated_root).joinpath( - "sentences", "trimmed", f"{sentence_id}.blend" + + # Must be true otherwise this block is not called. + assert arguments.render_sentence is not None + + sentence_id: int = arguments.render_sentence + + # Initialize the target scene and armature + initialize_scene() + initialize_target_armature() + + # Load the source animation data from the sentence file + sentence_file = "Satz" + str(sentence_id) + ".blend" + sentence_path = Path(arguments.corpus_generated_directory).joinpath( + "sentences", "trimmed", sentence_file ) - with bpy.data.libraries.load(str(animation_data)) as (data_from, data_to): + + # Import the sentence animation + with bpy.data.libraries.load(str(sentence_path)) as (data_from, data_to): data_to.actions = data_from.actions - glue.create_new_fcurves(f"updated_{sentence_id}") - glue.combine_animation("final_action", f"updated_{sentence_id}", 1) + + glue = Glue( + mms=None, # It won't be needed when rendering a single sentence + target_armature_obj_name=TARGET_ARMATURE_NAME, + target_action_name=TARGET_ACTION_NAME + ) + + # Prepare the target animation curves, specifiyng the name of the source action + # By manually specifying the source action, the mms is not needed. + source_action_name = "updated_Satz" + str(sentence_id) + glue.prepare_target_fcurves(source_action_name) + glue.append_action(target_action_name="final_action", source_action_name=source_action_name, start=1) post_bake( - armature_obj_name=glue.armature_obj_name, - action_name=glue.action_name, + armature_obj_name=glue.src_armature_obj_name, + action_name=glue.target_action_name, mp4_path=arguments.export_mp4, bvh_path=arguments.export_bvh, fbx_path=arguments.export_fbx, @@ -305,13 +388,14 @@ def render_sentence(sentence_id: str, generated_root: Path, glue: Glue, argument ) -def execute_pipeline(arguments: argparse.Namespace) -> None: + +def execute_mms_realization_pipeline(arguments: argparse.Namespace) -> None: """Execute the mms pipeline. What does this method do? - 1. Read a mms file - 2. Import the necessary gloss in the MMS file - 3. Attach the IK controller + 1. Read a mms file. + 2. Import the necessary gloss in the MMS file. + 3. Attach the necessary IK controllers. 4. Run the animation production pipeline. """ mms_file = arguments.source_mms_file @@ -321,20 +405,7 @@ def execute_pipeline(arguments: argparse.Namespace) -> None: # Read the MMS from the given MMS file. mms = MMSParser(mms_file, generated_root).parse() # Compose the MoCap file names and check for their availability - mms.find_mocap_data_files() - - # During the comparison it is necessary for us to only compute the sentence. - # Thus, the following block assures that we load the correct sentence animation - # and render the animation. - if arguments.render_sentence: - glue = Glue( - mms=mms, - ignore_bone_list="./assets/ignorelist.json", - src_blendfile="./assets/defaults.blend", - action_name="final_action" - ) - render_sentence(sentence_id, generated_root, glue, arguments) - return # Exit the function. Because we do not need to continue at all. + mms.ensure_mocap_data_files() # # READ INFLECTION CONFIGURATION @@ -396,11 +467,12 @@ def execute_pipeline(arguments: argparse.Namespace) -> None: logger.info("IK Target Config: %s", ik_target.dict) logger.info("==================================") - # Remove all existing objects from the scene + # Remove all existing temporary objects from the scene for obj in bpy.data.objects: bpy.data.objects.remove(obj) # Iterate on MMS rows + # For each row, create a new action with the inflected gloss animation for gloss in mms.glosses: logger.info(f"Processing gloss {gloss}: {mms[gloss].path}") # TODO -- Path might not exist if the "gloss" is @@ -415,27 +487,45 @@ def execute_pipeline(arguments: argparse.Namespace) -> None: # sentence, we are resampling the animation frames. # The gloss animation data is loaded inside the ArmatureOperator constructor armature_operator = ArmatureOperator(mms[gloss]) + + # Here the "imported_" action has been created + mmsline = mms[gloss] + assert "imported_" + mmsline.output_name in bpy.data.actions + inflected_armature = armature_operator.copy_armature() if not arguments.ignore_gloss_duration: if arguments.use_relative_time: - armature_operator.resample(mms[gloss].duration(), use_rel_time=True) + armature_operator.resample(timing=mms[gloss].duration(), target_action_name="resampled_" + mmsline.output_name, use_rel_time=True) else: - armature_operator.resample(mms[gloss].timing(), use_rel_time=False) + armature_operator.resample(timing=mms[gloss].timing(), target_action_name="resampled_" + mmsline.output_name, use_rel_time=False) + + # Here the "updated_" animation has been created + assert "resampled_" + mmsline.output_name in bpy.data.actions - # We add the extra controllers to ensure that we will be able to modify the - # animation down the pipeline. - controller = Controller(inflected_armature, armature_operator.src_armature.name, ik_target_list, gloss[0]) + # We add the extra controllers to ensure that we will be able to modify the animation down the pipeline. + inflector = Controller(inflected_armature, armature_operator.src_armature.name, ik_target_list, gloss[0]) name = armature_operator.mms_line.output_name - controller.setup_chain( + inflector.setup_chain( source_armature=armature_operator.src_armature, target_armature=inflected_armature, - output_name=name, + inflected_action_name=name, mms_line=mms[gloss], without_inflection=arguments.without_inflection, ) + # Here the target "inflected_..." action has been already created + assert "inflected_" + mmsline.output_name in bpy.data.actions + + # Perform the inflection !!! if not arguments.without_inflection: - controller.execute(inflected_armature, mms[gloss]) + inflector.execute(inflected_armature, mms[gloss]) + + # + # For each MMS line, the inflected action has been created + for gloss in mms.glosses: + mmsline = mms[gloss] + # print("Expected inflected action presence ", "inflected_" + mmsline.output_name) + assert "inflected_" + mmsline.output_name in bpy.data.actions # # Call the data extraction if requested @@ -451,22 +541,45 @@ def execute_pipeline(arguments: argparse.Namespace) -> None: ) return + # Load the template scene + initialize_scene() + + # Checks + assert TARGET_ARMATURE_NAME in bpy.context.scene.objects + + # Initialize the target armature + initialize_target_armature() + + assert TARGET_ACTION_NAME in bpy.data.actions + assert bpy.context.scene.objects[TARGET_ARMATURE_NAME].animation_data is not None + assert bpy.context.scene.objects[TARGET_ARMATURE_NAME].animation_data.action is not None + assert bpy.context.scene.objects[TARGET_ARMATURE_NAME].animation_data.action.name == TARGET_ACTION_NAME + # Finally we merge individual signs to produce the final utterance of the full sentence. - print("Merging inflected glosses into the final timeline...") - glue = Glue(mms=mms, - ignore_bone_list="./assets/ignorelist.json", - src_blendfile="./assets/defaults.blend", - action_name="final_action") + logger.info("Merging inflected glosses into the final timeline...") + glue = Glue( + mms=mms, + target_armature_obj_name=TARGET_ARMATURE_NAME, + target_action_name=TARGET_ACTION_NAME + ) + # Since the animation data is essentially empty after initializing a new one, # it is necessary to create f-curves that match the source data. - glue.create_new_fcurves() + glue.prepare_target_fcurves() + + assert bpy.context.active_object.name == TARGET_ARMATURE_NAME + # Also the referenced Armature instance has by default the same name, + # but might have been renamed while loading the animation data from the other scenes. + assert bpy.context.active_object.data.name.startswith(TARGET_ARMATURE_NAME), f"The name of the active object does not start with '{TARGET_ARMATURE_NAME}', but is '{bpy.context.active_object.data.name}'" + + # # Put all the inflected glosses/actions into a final timeline - glue.merge_animation(use_rel_time=arguments.use_relative_time) + glue.realize_mms(use_rel_time=arguments.use_relative_time) # Finalize the scene and export as MP4, BVH, FBX, or binary blender scene post_bake( - armature_obj_name=glue.armature_obj_name, - action_name=glue.action_name, + armature_obj_name=glue.src_armature_obj_name, + action_name=glue.target_action_name, mp4_path=arguments.export_mp4, bvh_path=arguments.export_bvh, fbx_path=arguments.export_fbx, @@ -499,7 +612,11 @@ def execute_pipeline(arguments: argparse.Namespace) -> None: if args.log_to_console: enable_log_to_stdout() - print("Realizing MMS... ") - execute_pipeline(args) + if args.render_sentence: + print(f"Realizing single sentence with numer {args.render_sentence} ...") + execute_single_sentence_realization_pipeline(args) + else: + print(f"Realizing MMS from file '{args.source_mms_file}' ...") + execute_mms_realization_pipeline(args) print("All done.") diff --git a/player/ArmatureUtils.py b/player/ArmatureUtils.py index 7623105..69b7f40 100644 --- a/player/ArmatureUtils.py +++ b/player/ArmatureUtils.py @@ -21,8 +21,9 @@ import math import bpy from pathlib import Path -from typing import Tuple, Union +from typing import Tuple, Union, Optional +from .logging import logger from .mms_parser import MMSLine from . import bpy_utils, extract @@ -39,8 +40,10 @@ class ArmatureOperator: """ def __init__(self, mms_line: MMSLine) -> None: + self.mms_line = mms_line self.src_armature = self.load_animation(mms_line.path) + assert Path(mms_line.path).exists(), f"THE FILE '{mms_line.path}' DOESN'T EXIST." def load_animation(self, blend_path: Path) -> bpy.types.Object: @@ -50,6 +53,8 @@ def load_animation(self, blend_path: Path) -> bpy.types.Object: context. Since the context data can be overwritten, we store a link in mms line. """ + logger.info(f"Loading GLOSS animation data from '{blend_path}' ...") + if not blend_path.exists(): raise Exception(f"Failed to find the library data '{str(blend_path)}'.") @@ -57,19 +62,34 @@ def load_animation(self, blend_path: Path) -> bpy.types.Object: data_to.objects = data_from.objects data_to.armatures = data_from.armatures data_to.actions = data_from.actions - self.mms_line.data = data_to # Store the link to current data. + self.mms_line.data = data_to # Store in the mms line a reference to the the bpy.data containing objetcs, aramtures and actions of the current context, loaded form the animation blend file. + + # Have to cycle through the objects, because they are not a dictionary, but a list (blender type bpy_lib !) + armature_obj: Optional[bpy.types.Object] = None + for o in self.mms_line.data.objects: + # print(">>>", type(o), o.name, o.type) + if o.name == self.mms_line.name: + armature_obj = o + break + + if armature_obj is None: + raise Exception(f"ARMATURE Object with name {self.mms_line.name} not found while loading animation for {self.mms_line.output_name}") + + assert armature_obj.type == 'ARMATURE', f"Expected type ARMATURE for {armature_obj.name}: found '{armature_obj.type}' instead" + + # Link the source armature to the current context + bpy.context.scene.collection.objects.link(armature_obj) - self.mms_line.data.armatures[0].name = self.mms_line.output_name + # Replacing the armature name with the one including the progress number + armature_obj.name = self.mms_line.output_name + # Set the name of the imported action, avoiding duplicates and auto renaming in case of multiple glosses with the same name in the MMS + armature_obj.animation_data.action.name = "imported_" + self.mms_line.output_name - # link the context - for obj in self.mms_line.data.objects: - assert isinstance(obj, bpy.types.Object) - assert obj.type == 'ARMATURE' - bpy.context.scene.collection.objects.link(obj) + logger.info(f"Initialized armature '{armature_obj.name}' with action '{armature_obj.animation_data.action.name}'") - return self.mms_line.data.objects[0] + return armature_obj - def resample(self, timing: Union[Tuple[float, float], Tuple[float, bool]], use_rel_time: bool): + def resample(self, timing: Union[Tuple[float, float], Tuple[float, bool]], target_action_name: str, use_rel_time: bool): """Resample the animation according to the timing information. We assume that the animation has been loaded in the current armature's action. This function will create a new action with the resampled duration and set it as current action. @@ -83,12 +103,10 @@ def resample(self, timing: Union[Tuple[float, float], Tuple[float, bool]], use_r # 1. Initialize the armature and create a new action. source_armature = self.src_armature - name = source_armature.animation_data.action.name self.mms_line.original_frame_range = source_armature.animation_data.action.frame_range - source_armature.animation_data.action.name = f"old_{name}" - sampled_action = bpy.data.actions.new(name=name) + sampled_action = bpy.data.actions.new(name=target_action_name) # Compute the resampling time # TODO -- bring this out and let the duration of a resampling be calculated in the MMSLine class diff --git a/player/controllers.py b/player/controllers.py index b8edb74..9647308 100644 --- a/player/controllers.py +++ b/player/controllers.py @@ -53,7 +53,7 @@ def __init__(self, @param armature: The source armature to be inflected. @param dictionary_armature_name: The name of the armature in dictionary. @param ik_targets: The list of IK targets responsible for controlling the bones. - @param idx: The identifier for the gloss. + @param idx: The progressive ID of the gloss in the sequence. """ self.ik_targets = [] # Dynamically compose the inflection targets that allow to perform the inflection. @@ -74,7 +74,7 @@ def __init__(self, def setup_chain(self, source_armature: bpy.types.Armature, target_armature: bpy.types.Armature, - output_name: str, + inflected_action_name: str, mms_line: MMSLine, without_inflection: bool = False): """Set up the armature skeleton for animation. @@ -100,12 +100,13 @@ def setup_chain(self, bpy_utils.select_object(target_armature) bpy.ops.object.mode_set(mode="POSE") bpy.ops.pose.select_all(action="SELECT") - new_action = bpy.data.actions.get(f"inflected_{output_name}") + new_action = bpy.data.actions.get(f"inflected_{inflected_action_name}") # TODO -- try to get out of here this action name composition target_armature.animation_data.action = new_action bpy.context.object.animation_data.action = new_action bpy.context.scene.frame_set(start) # print("Baking the forward pose into the IK bones.") - # This handles the off-by-1 error! + + # Copies the bone rotations from the source armature to the target, inflected one for frame in range(start, end + 1): bpy.context.scene.frame_set(frame) for bone in source_armature.pose.bones: @@ -146,7 +147,7 @@ def setup_chain(self, # Instead use the original animation. (Priority: Low) def execute(self, armature: bpy.types.Armature, mms_line: MMSLine): - """Inflect the IK targets and bake the animation. + """Inflect the IK targets of a given MMSLine and bake the animation. @param armature: The target armature containing the IK targets. @param mms_line: The MMS table @@ -154,7 +155,8 @@ def execute(self, armature: bpy.types.Armature, mms_line: MMSLine): bpy_utils.select_object(armature) bpy.ops.object.mode_set(mode="POSE") - action = bpy.data.actions.get(f"inflected_{mms_line.output_name}") + + action = bpy.data.actions.get(f"inflected_{mms_line.output_name}") # TODO -- try to get out of here this action name composition bpy.context.object.animation_data.action = action start = int(action.frame_range[0]) stop = int(action.frame_range[1]) diff --git a/player/extract.py b/player/extract.py index 487ce31..a294689 100644 --- a/player/extract.py +++ b/player/extract.py @@ -185,6 +185,7 @@ def create_f_curves(source_armature: bpy.types.Armature, sampled_action: bpy.typ sampled_action.fcurves.new(data_path=path_name, index=i) +# TODO -- seems to be unused. Keep it? def extract_source(source_armature, gloss, sample_size): # Get a sample action, if it doesn't exist, create it sampled_action = bpy.data.actions.get(f"sampled_{gloss.output_name}") diff --git a/player/merge.py b/player/merge.py index 30103b9..84c6820 100644 --- a/player/merge.py +++ b/player/merge.py @@ -47,64 +47,44 @@ class Glue: def __init__( self, mms: MMS, - ignore_bone_list: str, - src_blendfile: str, - action_name: str, + target_armature_obj_name: str, + target_action_name: str, ): """ - :param mms: MMS table containing all relevant gloss information + :param mms: MMS table containing all relevant gloss information. :param src_blendfile: The scene that contains the character assets. + :param src_armature_obj_name: The name of the Armature object that we are going to animate. :param action_name: The name of new action to write the keyframes. """ - self.armature_obj_name = "skeleton #5" - self.action_name = action_name - self._duplicate_armature = "final_armature" self.mms = mms - # TODO -- this is unused! Forgotten or to be used in the future? - self.ignore_list = load_json(ignore_bone_list) - self.src_blendfile = src_blendfile + self.src_armature_obj_name = target_armature_obj_name + self.target_action_name = target_action_name - self.initialize_scene() - self.initialize_mesh() - def initialize_scene(self): - """Blender scene initialization. - - Copy the objects from original scene to the current context. - Link them into the current context to work on them. + def prepare_target_fcurves(self, reference_action_name: Optional[str] = None): + """Create new empty fcurves in the action of the target armature. + Copies the list of fcurves from the given action parameter. + If the action name is not specified (default), the list of fcurves is taken from the first inflected gloss. """ - # Select all objects and delete them. - # Will leave only the armature data and the actions. - bpy.ops.object.select_all(action="SELECT") - bpy.ops.object.delete() - - # Load objects from the reference scene (nice character and lights) - with bpy.data.libraries.load(self.src_blendfile) as (data_from, data_to): - data_to.objects = data_from.objects - data_to.worlds = data_from.worlds - for obj in data_to.objects: - bpy.context.scene.collection.objects.link(obj) - bpy.context.scene.world = data_to.worlds[0] - - def initialize_mesh(self): - """Replace the name in the original mesh and create a new action. - - The original names are "Bone Pelvis". This name doesn't work for the - skeletal animations which are of format "Bone_Pelvis". Thus, we modify - the name of bones in the original mesh itself as it is one time operation. - """ + armature_obj = bpy.data.objects[self.src_armature_obj_name] + bpy_utils.select_object(armature_obj) + + # By default, use the action of the first gloss as reference + if reference_action_name is None: + # Take one source action + gloss = self.mms[self.mms.glosses[0]] + reference_action_name = f"inflected_{gloss.output_name}" + + action = bpy.data.actions[reference_action_name] + assert len(action.fcurves) != 0, f"The action {reference_action_name} has no animation data" + + for source_fcurve in action.fcurves: + armature_obj.animation_data.action.fcurves.new( + source_fcurve.data_path, index=source_fcurve.array_index + ) - mesh_armature = bpy.data.objects[self.armature_obj_name] - assert isinstance(mesh_armature, bpy.types.Object) - assert mesh_armature.type == "ARMATURE" - for bone in mesh_armature.pose.bones: - bone.name = bone.name.replace(" ", "_") - bone.rotation_mode = "ZXY" - mesh_armature.animation_data_create() - action_ref = bpy.data.actions.new(self.action_name) - mesh_armature.animation_data.action = action_ref def perform_hold(self, target_animation: str, @@ -139,24 +119,26 @@ def perform_hold(self, return end - def combine_animation(self, - target_animation: str, - source_animation: str, + def append_action(self, + target_action_name: str, + source_action_name: str, start: float) -> int: - """Combine the animations. + """Appends the data of a source action into a target action, starting from the given keyframe. + Returns the keyframe number of the last added frame (acccording to the size of the source action). - :param target_animation: The target animation action. - :param source_animation: The source animation action. + :param target_action_name: The target animation action. + :param source_action_name: The source animation action. :param start: The starting keyframe for the given animation action :param print_debug: Debug flag. """ - source_action = bpy.data.actions[source_animation] + + source_action = bpy.data.actions[source_action_name] action_start, action_end = source_action.frame_range - logger.info(f"Copying the keyframes from {source_animation} into {target_animation} at frame {start}") + logger.info(f"Copying the keyframes from {source_action_name} into {target_action_name} at frame {start}") logger.info(f"Source action range is {action_start}-{action_end}") - target_action = bpy.data.actions[target_animation] + target_action = bpy.data.actions[target_action_name] # Iterate over all the animation curves for source_fcurve in source_action.fcurves: @@ -180,33 +162,8 @@ def combine_animation(self, return end - def create_new_fcurves(self, action_name: Optional[str] = None): - """Create a new action with empty fcurves in the target armature. - Copies the list of fcurves from the given action parameter. - If the action name is not specified (default), the list of fcurves is taken from the first inflected gloss. - """ - - armature_obj = bpy.data.objects[self.armature_obj_name] - bpy_utils.select_object(armature_obj) - bpy.context.object.name = armature_obj.name - bpy.context.object.data.name = armature_obj.name - - # By default, use the action of the first gloss as reference - if action_name is None: - # Take one source action - gloss = self.mms[self.mms.glosses[0]] - action_name = f"inflected_{gloss.output_name}" - - action = bpy.data.actions[action_name] - assert len(action.fcurves) != 0, f"The action {action_name} has no animation data" - - for source_fcurve in action.fcurves: - armature_obj.animation_data.action.fcurves.new( - source_fcurve.data_path, index=source_fcurve.array_index - ) - - def merge_animation(self, use_rel_time: bool = False): - """Generate the timing data for individual gloss and merge into final track. + def realize_mms(self, use_rel_time: bool = False): + """Generate the timing data for individual glosses and merge them into final track. """ # `last_gloss_end` holds the last frame number of the previous gloss. @@ -226,7 +183,7 @@ def merge_animation(self, use_rel_time: bool = False): if is_relative: # In this case the duration is a fraction assert 0 <= duration_or_prop - # Compute the estimated duration according to the sampled + # Compute the estimated duration according to the sign duration fs, fe = self.mms[gloss].original_frame_range dur_orig = (fe - fs) duration_or_prop = dur_orig * duration_or_prop @@ -246,16 +203,16 @@ def merge_animation(self, use_rel_time: bool = False): prev_gloss_id = self.mms.glosses[prev_gloss_index] # end = self.mms[gloss].duration()[0] end_frame = self.perform_hold( - target_animation=self.action_name, - source_animation=f"inflected_{self.mms[prev_gloss_id].output_name}", + target_animation=self.target_action_name, + source_animation=f"inflected_{self.mms[prev_gloss_id].output_name}", # TODO -- somehow remove this hard-coded name start=start, end=end ) else: # Combine the animation and get the new end_frame - end_frame = self.combine_animation( - target_animation=self.action_name, - source_animation=f"inflected_{self.mms[gloss].output_name}", + end_frame = self.append_action( + target_action_name=self.target_action_name, + source_action_name=f"inflected_{self.mms[gloss].output_name}", # TODO -- somehow remove this hard-coded name start=start ) diff --git a/player/mms_parser.py b/player/mms_parser.py index cfedbeb..cdccc12 100644 --- a/player/mms_parser.py +++ b/player/mms_parser.py @@ -38,13 +38,13 @@ class MMSLine: be inflected, the class functions to store and retrieve the data required for the inflection of the corresponding sign. """ - def __init__(self, store_index: Dict[str, int], line_data: list, gloss_idx: int): + def __init__(self, store_index: Dict[str, int], line_data: List[Optional[str]], gloss_idx: int): self.store_index = store_index # Maps the column name to its index within the MMS row self.line_data = line_data self.name, self.datatype = self.find_datatype(line_data[0]) self.output_name = f"{gloss_idx}_{self.name}" # We overwrite the name. self.path = Path("/none") - self.data = None + self.data = None # Reference to the bpy.data containing the gloss animation data. self.original_frame_range = None self.resampled_frame_range = None @@ -79,6 +79,10 @@ def handle_none(self, key) -> Optional[Tuple[float, float, float]]: if not all((x, y, z)): return None + + assert x is not None + assert y is not None + assert z is not None return float(x), float(y), float(z) @@ -203,7 +207,7 @@ class MMS: def __init__(self, mms: Dict[Tuple[int, str], MMSLine], generated_root: Path, - inflections_availability: Dict[str, bool] = None): + inflections_availability: Dict[str, bool]): self.mms: Dict[Tuple[int, str], MMSLine] = mms self.glosses: List[Tuple[int, str]] = list(mms.keys()) @@ -219,17 +223,19 @@ def __getitem__(self, key: Tuple[int, str]) -> MMSLine: def __repr__(self): return f"MMS({self.glosses})" - def find_mocap_data_files(self) -> None: - """Save the path information for each gloss in the MMS table. - - As we assign the gloss path, we verify that the path exists. + def ensure_mocap_data_files(self) -> None: """ + For each GLOSS in the MMS, as we compose the gloss path and we verify that the path exists. + Also, saves the path for each gloss file in the MMS table. + If the required file doesn't exist, an Exception is thrown. + """ + pattern = r'<(.*?)>' for num, gloss_id in enumerate(self.glosses): gloss = self[gloss_id] matches = re.findall(pattern, gloss.name) if len(matches) > 0 and matches[0] == "HOLD": - # TODO -- Why the path is set to the path of the previous gloss? And if the HOLD is the first sign in the MMS? + # TODO -- Why for HOLD the path is set to the path of the previous gloss? And if the HOLD is the first sign in the MMS, it is OK to leave it empty? self[gloss_id].path = self[self.glosses[num - 1]].path self[gloss_id].datatype = "HOLD" else: