├── EasyMode ├── utils │ ├── requirements.txt │ └── mixamo_to_openpose.py ├── settings.txt ├── INSTRUCTIONS.txt ├── install_requirements.bat └── mixamo_to_openpose.bat ├── LICENSE ├── README.md └── mixamo_to_openpose.py /EasyMode/utils/requirements.txt: -------------------------------------------------------------------------------- 1 | pillow 2 | numpy 3 | opencv-python -------------------------------------------------------------------------------- /EasyMode/settings.txt: -------------------------------------------------------------------------------- 1 | width=512 2 | height=512 3 | scale=2.0 4 | rotation_x=0 5 | rotation_y=0 6 | rotation_z=0 7 | input_fps=30 8 | max_frames=0 9 | output_format=GIF -------------------------------------------------------------------------------- /EasyMode/INSTRUCTIONS.txt: -------------------------------------------------------------------------------- 1 | Run "install_requirements.bat", this will create a venv in the "utils" subfolder with the required python packages. 2 | 3 | Next, grab your animation .dae file and drag-and-drop it on top of "mixamo_to_openpose.bat". 4 | 5 | To change settings, open "settings.txt" and modify the values. -------------------------------------------------------------------------------- /EasyMode/install_requirements.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Setting up the virtual environment and installing dependencies... 3 | 4 | REM Navigate to the utils folder 5 | cd utils 6 | 7 | REM Check if virtual environment already exists 8 | if not exist "venv\Scripts\activate" ( 9 | echo Creating virtual environment in the utils folder... 10 | python -m venv venv 11 | ) 12 | 13 | REM Activate the virtual environment 14 | call venv\Scripts\activate 15 | 16 | REM Upgrade pip and install requirements 17 | echo Installing dependencies from requirements.txt... 18 | python -m pip install --upgrade pip 19 | pip install -r requirements.txt 20 | 21 | echo Setup complete. Virtual environment and dependencies installed. 22 | pause -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Astropulse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /EasyMode/mixamo_to_openpose.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Starting conversion process... 3 | 4 | REM Check if a file was provided 5 | if "%~1"=="" ( 6 | echo No .dae file provided. Drag and drop a .dae file onto this batch script. 7 | pause 8 | exit /b 9 | ) 10 | 11 | REM Check if the file is a .dae file 12 | if /i not "%~x1"==".dae" ( 13 | echo The provided file is not a .dae file. Please drag a .dae file onto this script. 14 | pause 15 | exit /b 16 | ) 17 | 18 | REM Check if settings.txt exists 19 | if not exist settings.txt ( 20 | echo settings.txt not found. Please ensure it exists in the same directory as this script. 21 | pause 22 | exit /b 23 | ) 24 | 25 | REM Check if virtual environment exists, create it if necessary 26 | if not exist "utils\venv\Scripts\activate" ( 27 | echo Virtual environment not found. Setting up virtual environment... 28 | pushd utils 29 | python -m venv venv 30 | venv\Scripts\activate 31 | python -m pip install --upgrade pip 32 | pip install -r requirements.txt 33 | popd 34 | ) 35 | 36 | REM Activate virtual environment 37 | call utils\venv\Scripts\activate 38 | 39 | REM Initialize default settings 40 | set width=512 41 | set height=512 42 | set scale=2.0 43 | set rotation_x=0 44 | set rotation_y=0 45 | set rotation_z=0 46 | set input_fps=30 47 | set max_frames=0 48 | set output_format=GIF 49 | 50 | REM Load settings from settings.txt 51 | for /f "tokens=1,2 delims==" %%A in (settings.txt) do ( 52 | set %%A=%%B 53 | ) 54 | 55 | REM Set the output file path with the format specified in settings 56 | set "output_file=output\%~n1.%output_format%" 57 | 58 | REM Run the Python script with settings loaded from settings.txt 59 | python utils\mixamo_to_openpose.py -i "%~1" -o "%output_file%" -ow %width% -oh %height% -os %scale% -rx %rotation_x% -ry %rotation_y% -rz %rotation_z% -ifps %input_fps% -f %max_frames% -of %output_format% 60 | 61 | echo Conversion complete. Output saved to %output_file% 62 | pause 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mixamo to OpenPose 2 | Convert Mixamo animations directly to OpenPose image sequences 3 | 4 | Mixamo (https://www.mixamo.com/) is a massive library of ready-made human skeleton animations, commonly used in VFX and 3D games. 5 | With this script you can easily convert Mixamo .dae (Collada) files into sequences of images (gifs, single image sheets) using the OpenPose (https://github.com/CMU-Perceptual-Computing-Lab/openpose) bones system. 6 | 7 | ![Example1](https://github.com/user-attachments/assets/d2f0ef5a-aca7-4566-9542-2a4861eeb22e) 8 | 9 | Animations can be reduced to a specific number of frames, rotated to be viewed from any angle, and scaled to any size. 10 | 11 | This script allows the easy creation of OpenPose animations for AI image generation, or dataset creation. 12 | 13 | # Usage 14 | 15 | ## Easy Mode 16 | 17 | Download an animation from Mixamo as a Collada .dae file. (Recommended without skin for smaller file size. DO NOT reduce keyframes!) 18 | Extract the zip folder so you have the standard .dae file itself. 19 | 20 | Follow the "instructions.txt" inside the "EasyMode" folder to easily install and run the conversion script. 21 | 22 | ## CLI 23 | 24 | Requirements: Pillow, Numpy, OpenCV 25 | 26 | `pip install pillow numpy opencv-python` 27 | 28 | Download an animation from Mixamo as a Collada .dae file. (Recommended without skin for smaller file size. DO NOT reduce keyframes!) 29 | Extract the zip folder so you have the standard .dae file itself. 30 | 31 | Run mixamo_to_openpose.py with the required input and output arguments 32 | 33 | Arguments: 34 | 35 | - -i --input: Expects string, Path to folder contianing Mixamo .dae files, or a single .dae file. 36 | 37 | - -o --output: Expects string, Path to save outputs. 38 | 39 | - -ow --width: Expects int, Output image width. Defaults to 512. 40 | 41 | - -oh --height: Expects int, Output image height. Defaults to 512. 42 | 43 | - -os --scale: Expects float, Pose scale multiplier. Adjusts the scale of the pose in the output. Defaults to 2.0. 44 | 45 | - -rx --rotation_x: Expects int, Pose X-axis rotation in degrees. Controls the pose's rotation along the X-axis. Defaults to 0. 46 | 47 | - -ry --rotation_y: Expects int, Pose Y-axis rotation in degrees. Controls the pose's rotation along the Y-axis. Defaults to 0. 48 | 49 | - -rz --rotation_z: Expects int, Pose Z-axis rotation in degrees. Controls the pose's rotation along the Z-axis. Defaults to 0. 50 | 51 | - -ifps --input_fps: Expects int, FPS of Mixamo animation. Specifies the frame rate for the input animation, in frames per second. Defaults to 30. 52 | 53 | - -f --max_frames: Expects int, Maximum number of frames in the final sequence. Limits the total frames in the output; set to 0 for no limit. Defaults to 0. 54 | 55 | - -of --output_format: Expects string, Output format for saved images. Options are "GIF" (single animated GIF), "PNG" (folder with numbered PNG images for each frame), or "SHEET" (one image sheet containing all frames arranged in a grid). Defaults to "GIF." 56 | 57 | # More examples 58 | ![Example2](https://github.com/user-attachments/assets/ed94e49e-fcee-49ad-82e9-b8588d84cdf9) 59 | 60 | ![walk](https://github.com/user-attachments/assets/103ff122-0485-4f11-90cc-033626ff6633) 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /mixamo_to_openpose.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import cv2 3 | from PIL import Image 4 | import math 5 | import xml.etree.ElementTree as ET 6 | import os 7 | import glob 8 | import argparse 9 | 10 | ap = argparse.ArgumentParser() 11 | ap.add_argument("-i", "--input", required = True, help = "Path to folder contianing Mixamo .dae files, or a single .dae file.") 12 | ap.add_argument("-o", "--output", required = True, help = "Path to save outputs.") 13 | ap.add_argument("-ow", "--width", required = False, type=int, default=512, help = "Output image width.") 14 | ap.add_argument("-oh", "--height", required = False, type=int, default=512, help = "Output image height.") 15 | ap.add_argument("-os", "--scale", required = False, type=float, default=2.0, help = "Pose scale multiplier.") 16 | ap.add_argument("-rx", "--rotation_x", required = False, type=int, default=0, help = "Pose X rotation in degrees.") 17 | ap.add_argument("-ry", "--rotation_y", required = False, type=int, default=0, help = "Pose Y rotation in degrees.") 18 | ap.add_argument("-rz", "--rotation_z", required = False, type=int, default=0, help = "Pose Z rotation in degrees.") 19 | ap.add_argument("-ifps", "--input_fps", required = False, type=int, default=30, help = "FPS of Mixamo animation.") 20 | ap.add_argument("-f", "--max_frames", required = False, type=int, default=0, help = "Maximum number of frames in final sequence. Set to 0 for no limit.") 21 | ap.add_argument("-of", "--output_format", required = False, default="GIF", help = "Output format: GIF, PNG, SHEET") 22 | args = vars(ap.parse_args()) 23 | 24 | 25 | # OpenPose keypoints 26 | openpose_keypoints = [ 27 | "nose", "neck", "right_shoulder", "right_elbow", "right_wrist", 28 | "left_shoulder", "left_elbow", "left_wrist", "right_hip", "right_knee", 29 | "right_ankle", "left_hip", "left_knee", "left_ankle", "right_eye", 30 | "left_eye", "right_ear", "left_ear" 31 | ] 32 | 33 | 34 | # Mixamo DAE to OpenPose mappings 35 | dae_to_openpose_map = { 36 | "mixamorig_HeadTop_End": "nose", 37 | "mixamorig_Head": "head", # Not used by OpenPose, exclusively for storing head position and angle for generating nose/eye/ear points 38 | "mixamorig_Neck": "neck", 39 | "mixamorig_RightArm": "right_shoulder", 40 | "mixamorig_RightForeArm": "right_elbow", 41 | "mixamorig_RightHand": "right_wrist", 42 | "mixamorig_LeftArm": "left_shoulder", 43 | "mixamorig_LeftForeArm": "left_elbow", 44 | "mixamorig_LeftHand": "left_wrist", 45 | "mixamorig_RightUpLeg": "right_hip", 46 | "mixamorig_RightLeg": "right_knee", 47 | "mixamorig_RightFoot": "right_ankle", 48 | "mixamorig_LeftUpLeg": "left_hip", 49 | "mixamorig_LeftLeg": "left_knee", 50 | "mixamorig_LeftFoot": "left_ankle", 51 | "mixamorig_RightEye": "right_eye", 52 | "mixamorig_LeftEye": "left_eye", 53 | "mixamorig_RightEar": "right_ear", 54 | "mixamorig_LeftEar": "left_ear" 55 | } 56 | 57 | 58 | # Big boy function for stepping through the DAE file and applying animation transforms 59 | def parse_dae_for_visual_scene(dae_file_path, 60 | nose_offset=(0, 0.2, 0), 61 | eye_offset=(0.2, 0, -0.2), 62 | ear_offset=(0.4, 0.02, -0.45), 63 | image_size=(512, 512), 64 | rotation_angles=(0, 0, 0), 65 | scale_factor=2): 66 | tree = ET.parse(dae_file_path) 67 | root = tree.getroot() 68 | namespace = {'collada': 'http://www.collada.org/2005/11/COLLADASchema'} 69 | 70 | visual_scene = root.find('.//collada:library_visual_scenes/collada:visual_scene', namespace) 71 | animations = root.find('.//collada:library_animations', namespace) 72 | if visual_scene is None or animations is None: 73 | raise ValueError("Visual scene or animation data not found in DAE file.") 74 | 75 | # Dictionary to store animation data 76 | animation_data = {} 77 | 78 | # Parse the animation transforms 79 | for animation in animations.findall('collada:animation', namespace): 80 | target_id = animation.get('id').split('-')[0] 81 | times = animation.find(f'.//collada:source[@id="{target_id}-Matrix-animation-input"]/collada:float_array', namespace) 82 | transforms = animation.find(f'.//collada:source[@id="{target_id}-Matrix-animation-output-transform"]/collada:float_array', namespace) 83 | 84 | if times is None or transforms is None: 85 | continue 86 | 87 | time_values = list(map(float, times.text.split())) 88 | transform_values = list(map(float, transforms.text.split())) 89 | matrices = [np.array(transform_values[i:i+16]).reshape(4, 4) for i in range(0, len(transform_values), 16)] 90 | 91 | animation_data[target_id] = (time_values, matrices) 92 | 93 | frames = {i: {} for i in range(len(next(iter(animation_data.values()))[0]))} 94 | 95 | # Recursively parse nodes in the visual scene and apply animations 96 | def parse_joint(node, parent_transform, time_idx): 97 | joint_name = node.get('id') 98 | openpose_name = dae_to_openpose_map.get(joint_name) 99 | 100 | # Use animation transform if available, else fall back to the node's local transform 101 | if joint_name in animation_data: 102 | _, matrices = animation_data[joint_name] 103 | local_transform = matrices[time_idx] 104 | else: 105 | matrix_text = node.find('collada:matrix', namespace).text 106 | matrix_values = list(map(float, matrix_text.split())) 107 | local_transform = np.array(matrix_values).reshape(4, 4) 108 | 109 | # Compute the world transform for the joint at this time step 110 | world_transform = np.dot(parent_transform, local_transform) 111 | x, y, z = world_transform[0, 3], -world_transform[1, 3], world_transform[2, 3] 112 | 113 | if openpose_name: 114 | frames[time_idx][openpose_name] = [x, y, z] 115 | if openpose_name == "nose": # The head's orientation is based on the nose position 116 | frames[time_idx]["head_rotation_matrix"] = world_transform[:3, :3] 117 | 118 | for child in node.findall('collada:node', namespace): 119 | parse_joint(child, world_transform, time_idx) 120 | 121 | # Parse the visual scene for each time step 122 | for time_idx in range(len(next(iter(animation_data.values()))[0])): 123 | root_node = visual_scene.find('.//collada:node[@id="mixamorig_Hips"]', namespace) 124 | parse_joint(root_node, np.eye(4), time_idx) 125 | 126 | # Add facial feature points based on the head orientation and position 127 | for time_idx, frame in frames.items(): 128 | head = frame.get("nose") 129 | neck = frame.get("neck") 130 | head_rotation_matrix = frame.get("head_rotation_matrix", np.eye(3)) # Default to identity if not found 131 | 132 | if head and neck: 133 | neck_to_nose_dist = np.linalg.norm(np.array(head) - np.array(neck)) 134 | nose_offset_scaled = np.array(nose_offset) * neck_to_nose_dist 135 | eye_offset_scaled = np.array(eye_offset) * neck_to_nose_dist 136 | ear_offset_scaled = np.array(ear_offset) * neck_to_nose_dist 137 | 138 | # Apply inverse rotation to adjust direction properly 139 | adjusted_rotation_matrix = np.linalg.inv(head_rotation_matrix) 140 | adjusted_rotation_matrix[0, 2] *= -1 # Invert yaw on the Z-axis for left-right direction 141 | adjusted_rotation_matrix[2, 0] *= -1 142 | 143 | def rotate_relative_to_head(offset): 144 | return adjusted_rotation_matrix @ offset 145 | 146 | rotated_nose = rotate_relative_to_head(nose_offset_scaled) 147 | rotated_right_eye = rotate_relative_to_head(eye_offset_scaled) 148 | rotated_left_eye = rotate_relative_to_head([-eye_offset_scaled[0], eye_offset_scaled[1], eye_offset_scaled[2]]) 149 | rotated_right_ear = rotate_relative_to_head(ear_offset_scaled) 150 | rotated_left_ear = rotate_relative_to_head([-ear_offset_scaled[0], ear_offset_scaled[1], ear_offset_scaled[2]]) 151 | 152 | frame["nose"] = [head[0] + rotated_nose[0], head[1] + rotated_nose[1], head[2] + rotated_nose[2]] 153 | frame["right_eye"] = [head[0] + rotated_right_eye[0], head[1] + rotated_right_eye[1], head[2] + rotated_right_eye[2]] 154 | frame["left_eye"] = [head[0] + rotated_left_eye[0], head[1] + rotated_left_eye[1], head[2] + rotated_left_eye[2]] 155 | frame["right_ear"] = [head[0] + rotated_right_ear[0], head[1] + rotated_right_ear[1], head[2] + rotated_right_ear[2]] 156 | frame["left_ear"] = [head[0] + rotated_left_ear[0], head[1] + rotated_left_ear[1], head[2] + rotated_left_ear[2]] 157 | 158 | # Rotate and scale all points in the frames 159 | frames = rotate_and_scale_pose(frames, image_size, rotation_angles, scale_factor) 160 | 161 | # Sort frames 162 | sorted_frames = [frames[frame] for frame in sorted(frames.keys())] 163 | return sorted_frames 164 | 165 | 166 | # Apply rotations to all keypoints around the image center 167 | def rotate_and_scale_pose(frames, image_size, rotation_angles, scale_factor=1.0): 168 | # Convert angles to radians 169 | rx, ry, rz = np.radians(rotation_angles) 170 | 171 | # Rotation matrices for each axis 172 | Rx = np.array([[1, 0, 0], 173 | [0, np.cos(rx), -np.sin(rx)], 174 | [0, np.sin(rx), np.cos(rx)]]) 175 | Ry = np.array([[np.cos(ry), 0, np.sin(ry)], 176 | [0, 1, 0], 177 | [-np.sin(ry), 0, np.cos(ry)]]) 178 | Rz = np.array([[np.cos(rz), -np.sin(rz), 0], 179 | [np.sin(rz), np.cos(rz), 0], 180 | [0, 0, 1]]) 181 | 182 | # Combined rotation matrix in the order: Ry -> Rx -> Rz 183 | R = Rz @ (Rx @ Ry) 184 | 185 | # Apply rotation and scaling to each point in each frame 186 | for time_idx, frame in frames.items(): 187 | for point_key, coords in frame.items(): 188 | # Ensure coords is a list of three elements before proceeding 189 | if isinstance(coords, list) and len(coords) == 3: 190 | x, y, z = coords 191 | 192 | # Apply scaling 193 | x, y, z = x * scale_factor, y * scale_factor, z * scale_factor 194 | 195 | # Rotate the point around the origin (0, 0, 0) 196 | rotated_point = R @ np.array([x, y, z]) 197 | 198 | # Update the point with rotated and scaled coordinates 199 | frame[point_key] = rotated_point.tolist() 200 | 201 | # Center the points on the specified canvas size 202 | for time_idx, frame in frames.items(): 203 | for point_key, coords in frame.items(): 204 | # Shift x and y only if coords is a list of three elements 205 | if isinstance(coords, list) and len(coords) == 3: 206 | frame[point_key][0] += image_size[0] // 2 # Shift x to center 207 | frame[point_key][1] += image_size[1] // 2 # Shift y to center 208 | 209 | return frames 210 | 211 | 212 | # Center keypoints on image canvas 213 | def center_keypoints(frames, canvas_size=(512, 512)): 214 | canvas_center_x, canvas_center_y = canvas_size[0] // 2, canvas_size[1] // 2 215 | 216 | for frame in frames: 217 | # Calculate the average position of keypoints for centering 218 | all_points = np.array([frame[keypoint][:2] for keypoint in frame if keypoint in openpose_keypoints]) 219 | avg_x, avg_y = np.mean(all_points, axis=0) 220 | 221 | # Adjust each keypoint to center the entire pose 222 | for keypoint in frame: 223 | frame[keypoint][0] += (canvas_center_x - avg_x) 224 | frame[keypoint][1] += (canvas_center_y - avg_y) 225 | 226 | return frames 227 | 228 | 229 | # Format frame keypoints to OpenPose JSON standard 230 | def format_to_openpose(frames): 231 | formatted_frames = [] 232 | for frame in frames: 233 | candidate = [] 234 | subset = [] 235 | 236 | for keypoint in openpose_keypoints: 237 | if keypoint in frame: 238 | x, y, _ = frame[keypoint] 239 | candidate.append([x, y, 1.0]) 240 | else: 241 | candidate.append([0.0, 0.0, 0.0]) 242 | 243 | subset.append([i if candidate[i][2] > 0 else -1 for i in range(len(openpose_keypoints))]) 244 | formatted_frames.append({"candidate": candidate, "subset": subset}) 245 | 246 | return formatted_frames 247 | 248 | 249 | # Convert DAE to OpenPose frames with rotation and scaling 250 | def convert_dae_to_openpose(dae_file, image_size=(512, 512), rotation_angles=(0, 0, 0), scale_factor=2): 251 | frames = parse_dae_for_visual_scene(dae_file, image_size=image_size, rotation_angles=rotation_angles, scale_factor=scale_factor) 252 | centered_frames = center_keypoints(frames, canvas_size=image_size) 253 | openpose_frames = format_to_openpose(centered_frames) 254 | return openpose_frames 255 | 256 | 257 | # Load keypoints from the OpenPose JSON standard 258 | def load_keypoints_for_drawing(data): 259 | # Load keypoints for each frame to prepare for drawing 260 | frames = [] 261 | for frame in data: 262 | candidate = np.array(frame['candidate']) 263 | subset = np.array(frame['subset']) 264 | frames.append((candidate, subset)) 265 | 266 | return frames 267 | 268 | 269 | # Draw the body keypoint and limbs 270 | def draw_bodypose(canvas, candidate, subset): 271 | stickwidth = 4 272 | limbSeq = [[2, 3], [2, 6], [3, 4], [4, 5], [6, 7], [7, 8], [2, 9], [9, 10], \ 273 | [10, 11], [2, 12], [12, 13], [13, 14], [2, 1], [1, 15], [15, 17], \ 274 | [1, 16], [16, 18], [3, 17], [6, 18]] 275 | 276 | colors = [[255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], [170, 255, 0], [85, 255, 0], [0, 255, 0], \ 277 | [0, 255, 85], [0, 255, 170], [0, 255, 255], [0, 170, 255], [0, 85, 255], [0, 0, 255], [85, 0, 255], \ 278 | [170, 0, 255], [255, 0, 255], [255, 0, 170], [255, 0, 85]] 279 | for i in range(18): 280 | for n in range(len(subset)): 281 | index = int(subset[n][i]) 282 | if index == -1: 283 | continue 284 | x, y = candidate[index][0:2] 285 | cv2.circle(canvas, (int(x), int(y)), 4, colors[i], thickness=-1) 286 | for i in range(17): 287 | for n in range(len(subset)): 288 | index = subset[n][np.array(limbSeq[i]) - 1] 289 | if -1 in index: 290 | continue 291 | cur_canvas = canvas.copy() 292 | Y = candidate[index.astype(int), 0] 293 | X = candidate[index.astype(int), 1] 294 | mX = np.mean(X) 295 | mY = np.mean(Y) 296 | length = ((X[0] - X[1]) ** 2 + (Y[0] - Y[1]) ** 2) ** 0.5 297 | angle = math.degrees(math.atan2(X[0] - X[1], Y[0] - Y[1])) 298 | polygon = cv2.ellipse2Poly((int(mY), int(mX)), (int(length / 2), stickwidth), int(angle), 0, 360, 1) 299 | cv2.fillConvexPoly(cur_canvas, polygon, colors[i]) 300 | canvas = cv2.addWeighted(canvas, 0.4, cur_canvas, 0.6, 0) 301 | return canvas 302 | 303 | 304 | # Reduce the number of frames to a maximum with even distribution 305 | def reduce_frames(frames, max_frames): 306 | """ 307 | Reduces the number of frames to `max_frames` by keeping frames with as even a distribution as possible. 308 | """ 309 | num_frames = len(frames) 310 | if num_frames <= max_frames: 311 | return frames # No reduction needed 312 | 313 | # Calculate approximate step size and round indices 314 | step = num_frames / max_frames 315 | reduced_frames = [frames[round(i * step)] for i in range(max_frames)] 316 | 317 | return reduced_frames 318 | 319 | 320 | #Convert dae file and save as image sequence 321 | def convert_dae(input_path, output_path, image_size=(320, 512), rotation_angles=(0, 0, 0), scale_factor=2, input_fps=30, max_frames=0, output_format='GIF'): 322 | # Original frame duration based on FPS 323 | original_frame_duration = int(1000 / input_fps) 324 | 325 | # Determine if input is a single file or a folder 326 | if os.path.isfile(input_path) and input_path.lower().endswith('.dae'): 327 | dae_files = [input_path] 328 | # Set default output folder if only a file name is provided 329 | output_folder = os.path.dirname(output_path) or os.getcwd() 330 | os.makedirs(output_folder, exist_ok=True) 331 | output_paths = [os.path.join(output_folder, os.path.basename(output_path))] 332 | elif os.path.isdir(input_path): 333 | dae_files = glob.glob(os.path.join(input_path, "*.dae")) 334 | os.makedirs(output_path, exist_ok=True) 335 | output_paths = [os.path.join(output_path, os.path.splitext(os.path.basename(dae_file))[0] + f".{output_format.lower()}") for dae_file in dae_files] 336 | else: 337 | raise ValueError("Input path must be a .dae file or a directory containing .dae files.") 338 | 339 | # Process each .dae file 340 | for dae_file, output_file_path in zip(dae_files, output_paths): 341 | print(f"Converting {dae_file} to OpenPose") 342 | frames = load_keypoints_for_drawing(convert_dae_to_openpose(dae_file, image_size=image_size, rotation_angles=rotation_angles, scale_factor=scale_factor)) 343 | 344 | # Reduce frames if necessary 345 | original_num_frames = len(frames) 346 | if max_frames > 0: 347 | frames = reduce_frames(frames, max_frames) 348 | else: 349 | max_frames = len(frames) 350 | 351 | # Calculate adjusted frame duration for GIF 352 | if output_format == 'GIF' and len(frames) > 1: 353 | frame_duration = int(original_frame_duration * (original_num_frames / len(frames))) 354 | else: 355 | frame_duration = original_frame_duration 356 | 357 | pil_images = [] 358 | for idx, (candidate, subset) in enumerate(frames): 359 | canvas = np.zeros((image_size[1], image_size[0], 3), dtype=np.uint8) 360 | drawn_canvas = draw_bodypose(canvas, candidate, subset) 361 | drawn_canvas_rgb = cv2.cvtColor(drawn_canvas, cv2.COLOR_BGR2RGB) 362 | pil_image = Image.fromarray(drawn_canvas_rgb) 363 | pil_images.append(pil_image) 364 | 365 | # Save each frame as a PNG if output format is PNG 366 | if output_format == 'PNG': 367 | frame_folder = os.path.join(os.path.splitext(output_file_path)[0]) 368 | os.makedirs(frame_folder, exist_ok=True) 369 | frame_path = os.path.join(frame_folder, f"{idx:0{len(str(max_frames))}d}.png") 370 | pil_image.save(frame_path) 371 | 372 | # Save as GIF 373 | if output_format == 'GIF': 374 | pil_images[0].save( 375 | output_file_path, 376 | save_all=True, 377 | append_images=pil_images[1:], 378 | duration=frame_duration, 379 | loop=0 380 | ) 381 | print(f"GIF saved at {output_file_path} with a maximum of {max_frames} frames") 382 | 383 | # Save as image sheet (SHEET) 384 | elif output_format == 'SHEET': 385 | output_file_path = f"{os.path.splitext(output_file_path)[0]}.png" 386 | grid_size = int(np.ceil(np.sqrt(len(pil_images)))) 387 | sheet_width = grid_size * image_size[0] 388 | sheet_height = grid_size * image_size[1] 389 | sheet_image = Image.new('RGB', (sheet_width, sheet_height), (0, 0, 0)) 390 | 391 | for idx, pil_image in enumerate(pil_images): 392 | row = idx // grid_size 393 | col = idx % grid_size 394 | sheet_image.paste(pil_image, (col * image_size[0], row * image_size[1])) 395 | 396 | sheet_image.save(output_file_path) 397 | print(f"Image sheet saved at {output_file_path}") 398 | 399 | 400 | if os.path.isfile(args["input"]): 401 | convert_dae(args["input"], args["output"], image_size=(args["width"], args["height"]), rotation_angles=(args["rotation_x"], args["rotation_y"], args["rotation_z"]), scale_factor=args["scale"], input_fps=args["input_fps"], max_frames=args["max_frames"], output_format=args["output_format"]) 402 | else: 403 | print(f"Input \"{args['input']}\" is not a file") -------------------------------------------------------------------------------- /EasyMode/utils/mixamo_to_openpose.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import cv2 3 | from PIL import Image 4 | import math 5 | import xml.etree.ElementTree as ET 6 | import os 7 | import glob 8 | import argparse 9 | 10 | ap = argparse.ArgumentParser() 11 | ap.add_argument("-i", "--input", required = True, help = "Path to folder contianing Mixamo .dae files, or a single .dae file.") 12 | ap.add_argument("-o", "--output", required = True, help = "Path to save outputs.") 13 | ap.add_argument("-ow", "--width", required = False, type=int, default=512, help = "Output image width.") 14 | ap.add_argument("-oh", "--height", required = False, type=int, default=512, help = "Output image height.") 15 | ap.add_argument("-os", "--scale", required = False, type=float, default=2.0, help = "Pose scale multiplier.") 16 | ap.add_argument("-rx", "--rotation_x", required = False, type=int, default=0, help = "Pose X rotation in degrees.") 17 | ap.add_argument("-ry", "--rotation_y", required = False, type=int, default=0, help = "Pose Y rotation in degrees.") 18 | ap.add_argument("-rz", "--rotation_z", required = False, type=int, default=0, help = "Pose Z rotation in degrees.") 19 | ap.add_argument("-ifps", "--input_fps", required = False, type=int, default=30, help = "FPS of Mixamo animation.") 20 | ap.add_argument("-f", "--max_frames", required = False, type=int, default=0, help = "Maximum number of frames in final sequence. Set to 0 for no limit.") 21 | ap.add_argument("-of", "--output_format", required = False, default="GIF", help = "Output format: GIF, PNG, SHEET") 22 | args = vars(ap.parse_args()) 23 | 24 | 25 | # OpenPose keypoints 26 | openpose_keypoints = [ 27 | "nose", "neck", "right_shoulder", "right_elbow", "right_wrist", 28 | "left_shoulder", "left_elbow", "left_wrist", "right_hip", "right_knee", 29 | "right_ankle", "left_hip", "left_knee", "left_ankle", "right_eye", 30 | "left_eye", "right_ear", "left_ear" 31 | ] 32 | 33 | 34 | # Mixamo DAE to OpenPose mappings 35 | dae_to_openpose_map = { 36 | "mixamorig_HeadTop_End": "nose", 37 | "mixamorig_Head": "head", # Not used by OpenPose, exclusively for storing head position and angle for generating nose/eye/ear points 38 | "mixamorig_Neck": "neck", 39 | "mixamorig_RightArm": "right_shoulder", 40 | "mixamorig_RightForeArm": "right_elbow", 41 | "mixamorig_RightHand": "right_wrist", 42 | "mixamorig_LeftArm": "left_shoulder", 43 | "mixamorig_LeftForeArm": "left_elbow", 44 | "mixamorig_LeftHand": "left_wrist", 45 | "mixamorig_RightUpLeg": "right_hip", 46 | "mixamorig_RightLeg": "right_knee", 47 | "mixamorig_RightFoot": "right_ankle", 48 | "mixamorig_LeftUpLeg": "left_hip", 49 | "mixamorig_LeftLeg": "left_knee", 50 | "mixamorig_LeftFoot": "left_ankle", 51 | "mixamorig_RightEye": "right_eye", 52 | "mixamorig_LeftEye": "left_eye", 53 | "mixamorig_RightEar": "right_ear", 54 | "mixamorig_LeftEar": "left_ear" 55 | } 56 | 57 | 58 | # Big boy function for stepping through the DAE file and applying animation transforms 59 | def parse_dae_for_visual_scene(dae_file_path, 60 | nose_offset=(0, 0.2, 0), 61 | eye_offset=(0.2, 0, -0.2), 62 | ear_offset=(0.4, 0.02, -0.45), 63 | image_size=(512, 512), 64 | rotation_angles=(0, 0, 0), 65 | scale_factor=2): 66 | tree = ET.parse(dae_file_path) 67 | root = tree.getroot() 68 | namespace = {'collada': 'http://www.collada.org/2005/11/COLLADASchema'} 69 | 70 | visual_scene = root.find('.//collada:library_visual_scenes/collada:visual_scene', namespace) 71 | animations = root.find('.//collada:library_animations', namespace) 72 | if visual_scene is None or animations is None: 73 | raise ValueError("Visual scene or animation data not found in DAE file.") 74 | 75 | # Dictionary to store animation data 76 | animation_data = {} 77 | 78 | # Parse the animation transforms 79 | for animation in animations.findall('collada:animation', namespace): 80 | target_id = animation.get('id').split('-')[0] 81 | times = animation.find(f'.//collada:source[@id="{target_id}-Matrix-animation-input"]/collada:float_array', namespace) 82 | transforms = animation.find(f'.//collada:source[@id="{target_id}-Matrix-animation-output-transform"]/collada:float_array', namespace) 83 | 84 | if times is None or transforms is None: 85 | continue 86 | 87 | time_values = list(map(float, times.text.split())) 88 | transform_values = list(map(float, transforms.text.split())) 89 | matrices = [np.array(transform_values[i:i+16]).reshape(4, 4) for i in range(0, len(transform_values), 16)] 90 | 91 | animation_data[target_id] = (time_values, matrices) 92 | 93 | frames = {i: {} for i in range(len(next(iter(animation_data.values()))[0]))} 94 | 95 | # Recursively parse nodes in the visual scene and apply animations 96 | def parse_joint(node, parent_transform, time_idx): 97 | joint_name = node.get('id') 98 | openpose_name = dae_to_openpose_map.get(joint_name) 99 | 100 | # Use animation transform if available, else fall back to the node's local transform 101 | if joint_name in animation_data: 102 | _, matrices = animation_data[joint_name] 103 | local_transform = matrices[time_idx] 104 | else: 105 | matrix_text = node.find('collada:matrix', namespace).text 106 | matrix_values = list(map(float, matrix_text.split())) 107 | local_transform = np.array(matrix_values).reshape(4, 4) 108 | 109 | # Compute the world transform for the joint at this time step 110 | world_transform = np.dot(parent_transform, local_transform) 111 | x, y, z = world_transform[0, 3], -world_transform[1, 3], world_transform[2, 3] 112 | 113 | if openpose_name: 114 | frames[time_idx][openpose_name] = [x, y, z] 115 | if openpose_name == "nose": # The head's orientation is based on the nose position 116 | frames[time_idx]["head_rotation_matrix"] = world_transform[:3, :3] 117 | 118 | for child in node.findall('collada:node', namespace): 119 | parse_joint(child, world_transform, time_idx) 120 | 121 | # Parse the visual scene for each time step 122 | for time_idx in range(len(next(iter(animation_data.values()))[0])): 123 | root_node = visual_scene.find('.//collada:node[@id="mixamorig_Hips"]', namespace) 124 | parse_joint(root_node, np.eye(4), time_idx) 125 | 126 | # Add facial feature points based on the head orientation and position 127 | for time_idx, frame in frames.items(): 128 | head = frame.get("nose") 129 | neck = frame.get("neck") 130 | head_rotation_matrix = frame.get("head_rotation_matrix", np.eye(3)) # Default to identity if not found 131 | 132 | if head and neck: 133 | neck_to_nose_dist = np.linalg.norm(np.array(head) - np.array(neck)) 134 | nose_offset_scaled = np.array(nose_offset) * neck_to_nose_dist 135 | eye_offset_scaled = np.array(eye_offset) * neck_to_nose_dist 136 | ear_offset_scaled = np.array(ear_offset) * neck_to_nose_dist 137 | 138 | # Apply inverse rotation to adjust direction properly 139 | adjusted_rotation_matrix = np.linalg.inv(head_rotation_matrix) 140 | adjusted_rotation_matrix[0, 2] *= -1 # Invert yaw on the Z-axis for left-right direction 141 | adjusted_rotation_matrix[2, 0] *= -1 142 | 143 | def rotate_relative_to_head(offset): 144 | return adjusted_rotation_matrix @ offset 145 | 146 | rotated_nose = rotate_relative_to_head(nose_offset_scaled) 147 | rotated_right_eye = rotate_relative_to_head(eye_offset_scaled) 148 | rotated_left_eye = rotate_relative_to_head([-eye_offset_scaled[0], eye_offset_scaled[1], eye_offset_scaled[2]]) 149 | rotated_right_ear = rotate_relative_to_head(ear_offset_scaled) 150 | rotated_left_ear = rotate_relative_to_head([-ear_offset_scaled[0], ear_offset_scaled[1], ear_offset_scaled[2]]) 151 | 152 | frame["nose"] = [head[0] + rotated_nose[0], head[1] + rotated_nose[1], head[2] + rotated_nose[2]] 153 | frame["right_eye"] = [head[0] + rotated_right_eye[0], head[1] + rotated_right_eye[1], head[2] + rotated_right_eye[2]] 154 | frame["left_eye"] = [head[0] + rotated_left_eye[0], head[1] + rotated_left_eye[1], head[2] + rotated_left_eye[2]] 155 | frame["right_ear"] = [head[0] + rotated_right_ear[0], head[1] + rotated_right_ear[1], head[2] + rotated_right_ear[2]] 156 | frame["left_ear"] = [head[0] + rotated_left_ear[0], head[1] + rotated_left_ear[1], head[2] + rotated_left_ear[2]] 157 | 158 | # Rotate and scale all points in the frames 159 | frames = rotate_and_scale_pose(frames, image_size, rotation_angles, scale_factor) 160 | 161 | # Sort frames 162 | sorted_frames = [frames[frame] for frame in sorted(frames.keys())] 163 | return sorted_frames 164 | 165 | 166 | # Apply rotations to all keypoints around the image center 167 | def rotate_and_scale_pose(frames, image_size, rotation_angles, scale_factor=1.0): 168 | # Convert angles to radians 169 | rx, ry, rz = np.radians(rotation_angles) 170 | 171 | # Rotation matrices for each axis 172 | Rx = np.array([[1, 0, 0], 173 | [0, np.cos(rx), -np.sin(rx)], 174 | [0, np.sin(rx), np.cos(rx)]]) 175 | Ry = np.array([[np.cos(ry), 0, np.sin(ry)], 176 | [0, 1, 0], 177 | [-np.sin(ry), 0, np.cos(ry)]]) 178 | Rz = np.array([[np.cos(rz), -np.sin(rz), 0], 179 | [np.sin(rz), np.cos(rz), 0], 180 | [0, 0, 1]]) 181 | 182 | # Combined rotation matrix in the order: Ry -> Rx -> Rz 183 | R = Rz @ (Rx @ Ry) 184 | 185 | # Apply rotation and scaling to each point in each frame 186 | for time_idx, frame in frames.items(): 187 | for point_key, coords in frame.items(): 188 | # Ensure coords is a list of three elements before proceeding 189 | if isinstance(coords, list) and len(coords) == 3: 190 | x, y, z = coords 191 | 192 | # Apply scaling 193 | x, y, z = x * scale_factor, y * scale_factor, z * scale_factor 194 | 195 | # Rotate the point around the origin (0, 0, 0) 196 | rotated_point = R @ np.array([x, y, z]) 197 | 198 | # Update the point with rotated and scaled coordinates 199 | frame[point_key] = rotated_point.tolist() 200 | 201 | # Center the points on the specified canvas size 202 | for time_idx, frame in frames.items(): 203 | for point_key, coords in frame.items(): 204 | # Shift x and y only if coords is a list of three elements 205 | if isinstance(coords, list) and len(coords) == 3: 206 | frame[point_key][0] += image_size[0] // 2 # Shift x to center 207 | frame[point_key][1] += image_size[1] // 2 # Shift y to center 208 | 209 | return frames 210 | 211 | 212 | # Center keypoints on image canvas 213 | def center_keypoints(frames, canvas_size=(512, 512)): 214 | canvas_center_x, canvas_center_y = canvas_size[0] // 2, canvas_size[1] // 2 215 | 216 | for frame in frames: 217 | # Calculate the average position of keypoints for centering 218 | all_points = np.array([frame[keypoint][:2] for keypoint in frame if keypoint in openpose_keypoints]) 219 | avg_x, avg_y = np.mean(all_points, axis=0) 220 | 221 | # Adjust each keypoint to center the entire pose 222 | for keypoint in frame: 223 | frame[keypoint][0] += (canvas_center_x - avg_x) 224 | frame[keypoint][1] += (canvas_center_y - avg_y) 225 | 226 | return frames 227 | 228 | 229 | # Format frame keypoints to OpenPose JSON standard 230 | def format_to_openpose(frames): 231 | formatted_frames = [] 232 | for frame in frames: 233 | candidate = [] 234 | subset = [] 235 | 236 | for keypoint in openpose_keypoints: 237 | if keypoint in frame: 238 | x, y, _ = frame[keypoint] 239 | candidate.append([x, y, 1.0]) 240 | else: 241 | candidate.append([0.0, 0.0, 0.0]) 242 | 243 | subset.append([i if candidate[i][2] > 0 else -1 for i in range(len(openpose_keypoints))]) 244 | formatted_frames.append({"candidate": candidate, "subset": subset}) 245 | 246 | return formatted_frames 247 | 248 | 249 | # Convert DAE to OpenPose frames with rotation and scaling 250 | def convert_dae_to_openpose(dae_file, image_size=(512, 512), rotation_angles=(0, 0, 0), scale_factor=2): 251 | frames = parse_dae_for_visual_scene(dae_file, image_size=image_size, rotation_angles=rotation_angles, scale_factor=scale_factor) 252 | centered_frames = center_keypoints(frames, canvas_size=image_size) 253 | openpose_frames = format_to_openpose(centered_frames) 254 | return openpose_frames 255 | 256 | 257 | # Load keypoints from the OpenPose JSON standard 258 | def load_keypoints_for_drawing(data): 259 | # Load keypoints for each frame to prepare for drawing 260 | frames = [] 261 | for frame in data: 262 | candidate = np.array(frame['candidate']) 263 | subset = np.array(frame['subset']) 264 | frames.append((candidate, subset)) 265 | 266 | return frames 267 | 268 | 269 | # Draw the body keypoint and limbs 270 | def draw_bodypose(canvas, candidate, subset): 271 | stickwidth = 4 272 | limbSeq = [[2, 3], [2, 6], [3, 4], [4, 5], [6, 7], [7, 8], [2, 9], [9, 10], \ 273 | [10, 11], [2, 12], [12, 13], [13, 14], [2, 1], [1, 15], [15, 17], \ 274 | [1, 16], [16, 18], [3, 17], [6, 18]] 275 | 276 | colors = [[255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], [170, 255, 0], [85, 255, 0], [0, 255, 0], \ 277 | [0, 255, 85], [0, 255, 170], [0, 255, 255], [0, 170, 255], [0, 85, 255], [0, 0, 255], [85, 0, 255], \ 278 | [170, 0, 255], [255, 0, 255], [255, 0, 170], [255, 0, 85]] 279 | for i in range(18): 280 | for n in range(len(subset)): 281 | index = int(subset[n][i]) 282 | if index == -1: 283 | continue 284 | x, y = candidate[index][0:2] 285 | cv2.circle(canvas, (int(x), int(y)), 4, colors[i], thickness=-1) 286 | for i in range(17): 287 | for n in range(len(subset)): 288 | index = subset[n][np.array(limbSeq[i]) - 1] 289 | if -1 in index: 290 | continue 291 | cur_canvas = canvas.copy() 292 | Y = candidate[index.astype(int), 0] 293 | X = candidate[index.astype(int), 1] 294 | mX = np.mean(X) 295 | mY = np.mean(Y) 296 | length = ((X[0] - X[1]) ** 2 + (Y[0] - Y[1]) ** 2) ** 0.5 297 | angle = math.degrees(math.atan2(X[0] - X[1], Y[0] - Y[1])) 298 | polygon = cv2.ellipse2Poly((int(mY), int(mX)), (int(length / 2), stickwidth), int(angle), 0, 360, 1) 299 | cv2.fillConvexPoly(cur_canvas, polygon, colors[i]) 300 | canvas = cv2.addWeighted(canvas, 0.4, cur_canvas, 0.6, 0) 301 | return canvas 302 | 303 | 304 | # Reduce the number of frames to a maximum with even distribution 305 | def reduce_frames(frames, max_frames): 306 | """ 307 | Reduces the number of frames to `max_frames` by keeping frames with as even a distribution as possible. 308 | """ 309 | num_frames = len(frames) 310 | if num_frames <= max_frames: 311 | return frames # No reduction needed 312 | 313 | # Calculate approximate step size and round indices 314 | step = num_frames / max_frames 315 | reduced_frames = [frames[round(i * step)] for i in range(max_frames)] 316 | 317 | return reduced_frames 318 | 319 | 320 | #Convert dae file and save as image sequence 321 | def convert_dae(input_path, output_path, image_size=(320, 512), rotation_angles=(0, 0, 0), scale_factor=2, input_fps=30, max_frames=0, output_format='GIF'): 322 | # Original frame duration based on FPS 323 | original_frame_duration = int(1000 / input_fps) 324 | 325 | # Determine if input is a single file or a folder 326 | if os.path.isfile(input_path) and input_path.lower().endswith('.dae'): 327 | dae_files = [input_path] 328 | # Set default output folder if only a file name is provided 329 | output_folder = os.path.dirname(output_path) or os.getcwd() 330 | os.makedirs(output_folder, exist_ok=True) 331 | output_paths = [os.path.join(output_folder, os.path.basename(output_path))] 332 | elif os.path.isdir(input_path): 333 | dae_files = glob.glob(os.path.join(input_path, "*.dae")) 334 | os.makedirs(output_path, exist_ok=True) 335 | output_paths = [os.path.join(output_path, os.path.splitext(os.path.basename(dae_file))[0] + f".{output_format.lower()}") for dae_file in dae_files] 336 | else: 337 | raise ValueError("Input path must be a .dae file or a directory containing .dae files.") 338 | 339 | # Process each .dae file 340 | for dae_file, output_file_path in zip(dae_files, output_paths): 341 | print(f"Converting {dae_file} to OpenPose") 342 | frames = load_keypoints_for_drawing(convert_dae_to_openpose(dae_file, image_size=image_size, rotation_angles=rotation_angles, scale_factor=scale_factor)) 343 | 344 | # Reduce frames if necessary 345 | original_num_frames = len(frames) 346 | if max_frames > 0: 347 | frames = reduce_frames(frames, max_frames) 348 | else: 349 | max_frames = len(frames) 350 | 351 | # Calculate adjusted frame duration for GIF 352 | if output_format == 'GIF' and len(frames) > 1: 353 | frame_duration = int(original_frame_duration * (original_num_frames / len(frames))) 354 | else: 355 | frame_duration = original_frame_duration 356 | 357 | pil_images = [] 358 | for idx, (candidate, subset) in enumerate(frames): 359 | canvas = np.zeros((image_size[1], image_size[0], 3), dtype=np.uint8) 360 | drawn_canvas = draw_bodypose(canvas, candidate, subset) 361 | drawn_canvas_rgb = cv2.cvtColor(drawn_canvas, cv2.COLOR_BGR2RGB) 362 | pil_image = Image.fromarray(drawn_canvas_rgb) 363 | pil_images.append(pil_image) 364 | 365 | # Save each frame as a PNG if output format is PNG 366 | if output_format == 'PNG': 367 | frame_folder = os.path.join(os.path.splitext(output_file_path)[0]) 368 | os.makedirs(frame_folder, exist_ok=True) 369 | frame_path = os.path.join(frame_folder, f"{idx:0{len(str(max_frames))}d}.png") 370 | pil_image.save(frame_path) 371 | 372 | # Save as GIF 373 | if output_format == 'GIF': 374 | pil_images[0].save( 375 | output_file_path, 376 | save_all=True, 377 | append_images=pil_images[1:], 378 | duration=frame_duration, 379 | loop=0 380 | ) 381 | print(f"GIF saved at {output_file_path} with a maximum of {max_frames} frames") 382 | 383 | # Save as image sheet (SHEET) 384 | elif output_format == 'SHEET': 385 | output_file_path = f"{os.path.splitext(output_file_path)[0]}.png" 386 | grid_size = int(np.ceil(np.sqrt(len(pil_images)))) 387 | sheet_width = grid_size * image_size[0] 388 | sheet_height = grid_size * image_size[1] 389 | sheet_image = Image.new('RGB', (sheet_width, sheet_height), (0, 0, 0)) 390 | 391 | for idx, pil_image in enumerate(pil_images): 392 | row = idx // grid_size 393 | col = idx % grid_size 394 | sheet_image.paste(pil_image, (col * image_size[0], row * image_size[1])) 395 | 396 | sheet_image.save(output_file_path) 397 | print(f"Image sheet saved at {output_file_path}") 398 | 399 | 400 | if os.path.isfile(args["input"]): 401 | convert_dae(args["input"], args["output"], image_size=(args["width"], args["height"]), rotation_angles=(args["rotation_x"], args["rotation_y"], args["rotation_z"]), scale_factor=args["scale"], input_fps=args["input_fps"], max_frames=args["max_frames"], output_format=args["output_format"]) 402 | else: 403 | print(f"Input \"{args['input']}\" is not a file") --------------------------------------------------------------------------------