|
import os |
|
import time |
|
import glob |
|
import json |
|
import numpy as np |
|
import trimesh |
|
import argparse |
|
from scipy.spatial.transform import Rotation |
|
import PIL.Image |
|
from PIL import Image |
|
import math |
|
import trimesh.transformations as tf |
|
from trimesh.exchange.gltf import export_glb |
|
|
|
os.environ['PYOPENGL_PLATFORM'] = 'egl' |
|
|
|
import gradio as gr |
|
|
|
def parse_args(): |
|
parser = argparse.ArgumentParser(description='Create animations for 3D models') |
|
|
|
parser.add_argument( |
|
'--input', |
|
type=str, |
|
default='./data/demo_glb/', |
|
help='Input file or directory path (default: ./data/demo_glb/)' |
|
) |
|
|
|
parser.add_argument( |
|
'--log_path', |
|
type=str, |
|
default='./results/demo', |
|
help='Output directory path (default: results/demo)' |
|
) |
|
|
|
parser.add_argument( |
|
'--animation_type', |
|
type=str, |
|
default='rotate', |
|
choices=['rotate', 'float', 'explode', 'assemble', 'pulse', 'swing'], |
|
help='Type of animation to apply' |
|
) |
|
|
|
parser.add_argument( |
|
'--animation_duration', |
|
type=float, |
|
default=3.0, |
|
help='Duration of animation in seconds' |
|
) |
|
|
|
parser.add_argument( |
|
'--fps', |
|
type=int, |
|
default=30, |
|
help='Frames per second for animation' |
|
) |
|
|
|
return parser.parse_args() |
|
|
|
def get_input_files(input_path): |
|
if os.path.isfile(input_path): |
|
return [input_path] |
|
elif os.path.isdir(input_path): |
|
return glob.glob(os.path.join(input_path, '*')) |
|
else: |
|
raise ValueError(f"Input path {input_path} is neither a file nor a directory") |
|
|
|
args = parse_args() |
|
|
|
LOG_PATH = args.log_path |
|
os.makedirs(LOG_PATH, exist_ok=True) |
|
|
|
print(f"Output directory: {LOG_PATH}") |
|
|
|
def normalize_mesh(mesh): |
|
"""Normalize mesh to fit in a unit cube centered at origin""" |
|
vertices = mesh.vertices |
|
bounds = np.array([vertices.min(axis=0), vertices.max(axis=0)]) |
|
center = (bounds[0] + bounds[1]) / 2 |
|
scale = 1.0 / (bounds[1] - bounds[0]).max() |
|
|
|
|
|
normalized_mesh = mesh.copy() |
|
normalized_mesh.vertices = (vertices - center) * scale |
|
|
|
return normalized_mesh, center, scale |
|
|
|
def create_rotation_animation(mesh, duration=3.0, fps=30): |
|
"""Create a rotation animation around the Y axis""" |
|
num_frames = int(duration * fps) |
|
frames = [] |
|
|
|
|
|
mesh, original_center, original_scale = normalize_mesh(mesh) |
|
|
|
for frame_idx in range(num_frames): |
|
t = frame_idx / (num_frames - 1) |
|
angle = t * 2 * math.pi |
|
|
|
|
|
animated_mesh = mesh.copy() |
|
|
|
|
|
rotation_matrix = tf.rotation_matrix(angle, [0, 1, 0]) |
|
animated_mesh.apply_transform(rotation_matrix) |
|
|
|
|
|
frames.append(animated_mesh) |
|
|
|
return frames |
|
|
|
def create_float_animation(mesh, duration=3.0, fps=30, amplitude=0.2): |
|
"""Create a floating animation where the mesh moves up and down""" |
|
num_frames = int(duration * fps) |
|
frames = [] |
|
|
|
|
|
mesh, original_center, original_scale = normalize_mesh(mesh) |
|
|
|
for frame_idx in range(num_frames): |
|
t = frame_idx / (num_frames - 1) |
|
|
|
|
|
animated_mesh = mesh.copy() |
|
|
|
|
|
y_offset = amplitude * math.sin(2 * math.pi * t) |
|
translation_matrix = tf.translation_matrix([0, y_offset, 0]) |
|
animated_mesh.apply_transform(translation_matrix) |
|
|
|
|
|
frames.append(animated_mesh) |
|
|
|
return frames |
|
|
|
def create_explode_animation(mesh, duration=3.0, fps=30): |
|
"""Create an explode animation where parts of the mesh move outward""" |
|
num_frames = int(duration * fps) |
|
frames = [] |
|
|
|
|
|
mesh, original_center, original_scale = normalize_mesh(mesh) |
|
|
|
|
|
|
|
try: |
|
components = mesh.split(only_watertight=False) |
|
if len(components) <= 1: |
|
raise ValueError("Mesh cannot be split into components") |
|
except: |
|
|
|
components = None |
|
|
|
for frame_idx in range(num_frames): |
|
t = frame_idx / (num_frames - 1) |
|
|
|
if components: |
|
|
|
scene = trimesh.Scene() |
|
|
|
|
|
for component in components: |
|
|
|
animated_component = component.copy() |
|
|
|
|
|
direction = animated_component.centroid |
|
if np.linalg.norm(direction) < 1e-10: |
|
|
|
direction = np.random.rand(3) - 0.5 |
|
|
|
direction = direction / np.linalg.norm(direction) |
|
|
|
|
|
translation = direction * t * 0.5 |
|
translation_matrix = tf.translation_matrix(translation) |
|
animated_component.apply_transform(translation_matrix) |
|
|
|
|
|
scene.add_geometry(animated_component) |
|
|
|
|
|
animated_mesh = trimesh.util.concatenate(scene.dump()) |
|
else: |
|
|
|
animated_mesh = mesh.copy() |
|
vertices = animated_mesh.vertices.copy() |
|
|
|
|
|
directions = vertices.copy() |
|
norms = np.linalg.norm(directions, axis=1, keepdims=True) |
|
mask = norms > 1e-10 |
|
directions[mask] = directions[mask] / norms[mask] |
|
directions[~mask] = np.random.rand(np.sum(~mask), 3) - 0.5 |
|
|
|
|
|
vertices += directions * t * 0.3 |
|
animated_mesh.vertices = vertices |
|
|
|
|
|
frames.append(animated_mesh) |
|
|
|
return frames |
|
|
|
def create_assemble_animation(mesh, duration=3.0, fps=30): |
|
"""Create an assembly animation (reverse of explode)""" |
|
|
|
explode_frames = create_explode_animation(mesh, duration, fps) |
|
return list(reversed(explode_frames)) |
|
|
|
def create_pulse_animation(mesh, duration=3.0, fps=30, min_scale=0.8, max_scale=1.2): |
|
"""Create a pulsing animation where the mesh scales up and down""" |
|
num_frames = int(duration * fps) |
|
frames = [] |
|
|
|
|
|
mesh, original_center, original_scale = normalize_mesh(mesh) |
|
|
|
for frame_idx in range(num_frames): |
|
t = frame_idx / (num_frames - 1) |
|
|
|
|
|
animated_mesh = mesh.copy() |
|
|
|
|
|
scale_factor = min_scale + (max_scale - min_scale) * (0.5 + 0.5 * math.sin(2 * math.pi * t)) |
|
scale_matrix = tf.scale_matrix(scale_factor) |
|
animated_mesh.apply_transform(scale_matrix) |
|
|
|
|
|
frames.append(animated_mesh) |
|
|
|
return frames |
|
|
|
def create_swing_animation(mesh, duration=3.0, fps=30, max_angle=math.pi/6): |
|
"""Create a swinging animation where the mesh rotates back and forth""" |
|
num_frames = int(duration * fps) |
|
frames = [] |
|
|
|
|
|
mesh, original_center, original_scale = normalize_mesh(mesh) |
|
|
|
for frame_idx in range(num_frames): |
|
t = frame_idx / (num_frames - 1) |
|
|
|
|
|
animated_mesh = mesh.copy() |
|
|
|
|
|
angle = max_angle * math.sin(2 * math.pi * t) |
|
rotation_matrix = tf.rotation_matrix(angle, [0, 1, 0]) |
|
animated_mesh.apply_transform(rotation_matrix) |
|
|
|
|
|
frames.append(animated_mesh) |
|
|
|
return frames |
|
|
|
def generate_gif_from_frames(frames, output_path, fps=30, resolution=(640, 480), background_color=(255, 255, 255, 255)): |
|
"""Generate a GIF from animation frames""" |
|
gif_frames = [] |
|
|
|
for frame in frames: |
|
|
|
scene = trimesh.Scene(frame) |
|
|
|
|
|
try: |
|
|
|
scene.camera_transform = scene.camera_transform |
|
except: |
|
|
|
scene.camera_transform = tf.translation_matrix([0, 0, 2]) |
|
|
|
|
|
try: |
|
img = scene.save_image(resolution=resolution, background=background_color) |
|
gif_frames.append(Image.open(img)) |
|
except Exception as e: |
|
print(f"Error rendering frame: {str(e)}") |
|
|
|
gif_frames.append(Image.new('RGB', resolution, (255, 255, 255))) |
|
|
|
|
|
if gif_frames: |
|
gif_frames[0].save( |
|
output_path, |
|
save_all=True, |
|
append_images=gif_frames[1:], |
|
optimize=False, |
|
duration=int(1000 / fps), |
|
loop=0 |
|
) |
|
return output_path |
|
else: |
|
return None |
|
|
|
def create_animation_mesh(input_mesh_path, animation_type='rotate', duration=3.0, fps=30): |
|
"""Create animation from input mesh based on animation type""" |
|
|
|
try: |
|
mesh = trimesh.load(input_mesh_path) |
|
except Exception as e: |
|
print(f"Error loading mesh: {str(e)}") |
|
return None, None |
|
|
|
|
|
if animation_type == 'rotate': |
|
frames = create_rotation_animation(mesh, duration, fps) |
|
elif animation_type == 'float': |
|
frames = create_float_animation(mesh, duration, fps) |
|
elif animation_type == 'explode': |
|
frames = create_explode_animation(mesh, duration, fps) |
|
elif animation_type == 'assemble': |
|
frames = create_assemble_animation(mesh, duration, fps) |
|
elif animation_type == 'pulse': |
|
frames = create_pulse_animation(mesh, duration, fps) |
|
elif animation_type == 'swing': |
|
frames = create_swing_animation(mesh, duration, fps) |
|
else: |
|
print(f"Unknown animation type: {animation_type}") |
|
return None, None |
|
|
|
base_filename = os.path.basename(input_mesh_path).rsplit('.', 1)[0] |
|
|
|
|
|
try: |
|
animated_glb_path = os.path.join(LOG_PATH, f'animated_{base_filename}.glb') |
|
|
|
|
|
|
|
if frames and len(frames) > 0: |
|
|
|
first_frame = frames[0] |
|
|
|
scene = trimesh.Scene(first_frame) |
|
scene.export(animated_glb_path) |
|
else: |
|
return None, None |
|
except Exception as e: |
|
print(f"Error exporting GLB: {str(e)}") |
|
animated_glb_path = None |
|
|
|
|
|
try: |
|
animated_gif_path = os.path.join(LOG_PATH, f'animated_{base_filename}.gif') |
|
generate_gif_from_frames(frames, animated_gif_path, fps) |
|
except Exception as e: |
|
print(f"Error creating GIF: {str(e)}") |
|
animated_gif_path = None |
|
|
|
return animated_glb_path, animated_gif_path |
|
|
|
def process_3d_model(input_3d, animation_type, animation_duration, fps): |
|
"""Process a 3D model and apply animation""" |
|
print(f"Processing: {input_3d} with animation type: {animation_type}") |
|
|
|
try: |
|
|
|
animated_glb_path, animated_gif_path = create_animation_mesh( |
|
input_3d, |
|
animation_type=animation_type, |
|
duration=animation_duration, |
|
fps=fps |
|
) |
|
|
|
if not animated_glb_path or not animated_gif_path: |
|
return "Error creating animation", None |
|
|
|
|
|
metadata = { |
|
"animation_type": animation_type, |
|
"duration": animation_duration, |
|
"fps": fps, |
|
"original_model": os.path.basename(input_3d), |
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S") |
|
} |
|
|
|
json_path = os.path.join(LOG_PATH, f'metadata_{os.path.basename(input_3d).rsplit(".", 1)[0]}.json') |
|
with open(json_path, 'w') as f: |
|
json.dump(metadata, f, indent=4) |
|
|
|
return animated_glb_path, animated_gif_path, json_path |
|
except Exception as e: |
|
error_msg = f"Error processing file: {str(e)}" |
|
print(error_msg) |
|
return error_msg, None, None |
|
|
|
_HEADER_ = ''' |
|
<h2><b>GLB ์ ๋๋ฉ์ด์
์์ฑ๊ธฐ - 3D ๋ชจ๋ธ ์์ง์ ํจ๊ณผ</b></h2> |
|
|
|
์ด ๋ฐ๋ชจ๋ฅผ ํตํด ์ ์ ์ธ 3D ๋ชจ๋ธ(GLB ํ์ผ)์ ๋ค์ํ ์ ๋๋ฉ์ด์
ํจ๊ณผ๋ฅผ ์ ์ฉํ ์ ์์ต๋๋ค. |
|
|
|
โ๏ธโ๏ธโ๏ธ**์ค์์ฌํญ:** |
|
- ์ด ๋ฐ๋ชจ๋ ์
๋ก๋๋ GLB ํ์ผ์ ์ ๋๋ฉ์ด์
์ ์ ์ฉํฉ๋๋ค. |
|
- ๋ค์ํ ์ ๋๋ฉ์ด์
์คํ์ผ ์ค์์ ์ ํํ์ธ์: ํ์ , ๋ถ์ , ํญ๋ฐ, ์กฐ๋ฆฝ, ํ์ค, ์ค์. |
|
- ๊ฒฐ๊ณผ๋ ์ ๋๋ฉ์ด์
๋ GLB ํ์ผ๊ณผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ฉ GIF ํ์ผ๋ก ์ ๊ณต๋ฉ๋๋ค. |
|
''' |
|
|
|
_INFO_ = r""" |
|
### ์ ๋๋ฉ์ด์
์ ํ ์ค๋ช
|
|
- **ํ์ (rotate)**: ๋ชจ๋ธ์ด Y์ถ์ ์ค์ฌ์ผ๋ก ํ์ ํฉ๋๋ค. |
|
- **๋ถ์ (float)**: ๋ชจ๋ธ์ด ์์๋๋ก ๋ถ๋๋ฝ๊ฒ ๋ ๋ค๋๋๋ค. |
|
- **ํญ๋ฐ(explode)**: ๋ชจ๋ธ์ ๊ฐ ๋ถ๋ถ์ด ์ค์ฌ์์ ๋ฐ๊นฅ์ชฝ์ผ๋ก ํผ์ ธ๋๊ฐ๋๋ค. |
|
- **์กฐ๋ฆฝ(assemble)**: ํญ๋ฐ ์ ๋๋ฉ์ด์
์ ๋ฐ๋ - ๋ถํ๋ค์ด ํจ๊ป ๋ชจ์
๋๋ค. |
|
- **ํ์ค(pulse)**: ๋ชจ๋ธ์ด ํฌ๊ธฐ๊ฐ ์ปค์ก๋ค ์์์ก๋ค๋ฅผ ๋ฐ๋ณตํฉ๋๋ค. |
|
- **์ค์(swing)**: ๋ชจ๋ธ์ด ์ข์ฐ๋ก ๋ถ๋๋ฝ๊ฒ ํ๋ค๋ฆฝ๋๋ค. |
|
|
|
### ํ |
|
- ์ ๋๋ฉ์ด์
๊ธธ์ด์ FPS๋ฅผ ์กฐ์ ํ์ฌ ์์ง์์ ์๋์ ๋ถ๋๋ฌ์์ ์กฐ์ ํ ์ ์์ต๋๋ค. |
|
- ๋ณต์กํ ๋ชจ๋ธ์ ์ฒ๋ฆฌ ์๊ฐ์ด ๋ ์ค๋ ๊ฑธ๋ฆด ์ ์์ต๋๋ค. |
|
- GIF ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ ๋น ๋ฅธ ์ฐธ์กฐ์ฉ์ด๋ฉฐ, ๊ณ ํ์ง ๊ฒฐ๊ณผ๋ฅผ ์ํด์๋ ์ ๋๋ฉ์ด์
๋ GLB ํ์ผ์ ๋ค์ด๋ก๋ํ์ธ์. |
|
""" |
|
|
|
|
|
def create_gradio_interface(): |
|
with gr.Blocks(title="GLB ์ ๋๋ฉ์ด์
์์ฑ๊ธฐ") as demo: |
|
|
|
gr.Markdown(_HEADER_) |
|
|
|
with gr.Row(): |
|
with gr.Column(): |
|
|
|
input_3d = gr.Model3D(label="3D ๋ชจ๋ธ ํ์ผ ์
๋ก๋ (GLB ํฌ๋งท)") |
|
|
|
with gr.Row(): |
|
animation_type = gr.Dropdown( |
|
label="์ ๋๋ฉ์ด์
์ ํ", |
|
choices=["rotate", "float", "explode", "assemble", "pulse", "swing"], |
|
value="rotate" |
|
) |
|
|
|
with gr.Row(): |
|
animation_duration = gr.Slider( |
|
label="์ ๋๋ฉ์ด์
๊ธธ์ด (์ด)", |
|
minimum=1.0, |
|
maximum=10.0, |
|
value=3.0, |
|
step=0.5 |
|
) |
|
fps = gr.Slider( |
|
label="์ด๋น ํ๋ ์ ์", |
|
minimum=15, |
|
maximum=60, |
|
value=30, |
|
step=1 |
|
) |
|
|
|
submit_btn = gr.Button("๋ชจ๋ธ ์ฒ๋ฆฌ ๋ฐ ์ ๋๋ฉ์ด์
์์ฑ") |
|
|
|
with gr.Column(): |
|
|
|
output_3d = gr.Model3D(label="์ ๋๋ฉ์ด์
์ ์ฉ๋ 3D ๋ชจ๋ธ") |
|
output_gif = gr.Image(label="์ ๋๋ฉ์ด์
๋ฏธ๋ฆฌ๋ณด๊ธฐ (GIF)") |
|
output_json = gr.File(label="๋ฉํ๋ฐ์ดํฐ ํ์ผ ๋ค์ด๋ก๋") |
|
|
|
|
|
gr.Markdown(_INFO_) |
|
|
|
|
|
submit_btn.click( |
|
fn=process_3d_model, |
|
inputs=[input_3d, animation_type, animation_duration, fps], |
|
outputs=[output_3d, output_gif, output_json] |
|
) |
|
|
|
|
|
example_files = [ [f] for f in glob.glob('./data/demo_glb/*.glb') ] |
|
|
|
if example_files: |
|
example = gr.Examples( |
|
examples=example_files, |
|
inputs=[input_3d], |
|
examples_per_page=10, |
|
) |
|
|
|
return demo |
|
|
|
|
|
if __name__ == "__main__": |
|
demo = create_gradio_interface() |
|
demo.launch(share=True) |