| """ |
| Simple 3D skeleton motion visualizer for HumanML3D motion data. |
| Usage: python visualize.py <motion.pt> [--output output.mp4] [--fps 20] |
| """ |
|
|
| import argparse |
| import numpy as np |
| import torch |
| import matplotlib.pyplot as plt |
| from matplotlib.animation import FuncAnimation, FFMpegWriter |
| from pathlib import Path |
|
|
|
|
| |
| |
| |
| SKELETON_CHAINS = [ |
| [0, 3, 6, 9, 12, 15], |
| [9, 14, 17, 19, 21], |
| [9, 13, 16, 18, 20], |
| [0, 2, 5, 8, 11], |
| [0, 1, 4, 7, 10], |
| ] |
|
|
|
|
| def load_motion(pt_path: str) -> np.ndarray: |
| """ |
| Load motion data from .pt file (PyTorch tensor). |
| |
| HumanML3D format: (frames, 22, 3) where last dimension is (x, y, z) |
| In HumanML3D: Y is vertical (up), X and Z are horizontal |
| For proper 3D visualization: we'll map Y -> Z (vertical), X -> X, Z -> Y |
| |
| Returns numpy array for matplotlib visualization. |
| """ |
| |
| motion_tensor = torch.load(pt_path, map_location="cpu") |
| motion = motion_tensor.numpy() |
|
|
| print(f"Loaded motion: {motion.shape}") |
| print(f" Frames: {motion.shape[0]}") |
| print(f" Joints: {motion.shape[1]}") |
| print(f" Dimensions: {motion.shape[2]}") |
|
|
| |
| |
| motion_remapped = motion.copy() |
| motion_remapped[:, :, [0, 1, 2]] = motion[:, :, [0, 2, 1]] |
|
|
| return motion_remapped |
|
|
|
|
| def setup_3d_plot(): |
| """Set up the 3D plot with proper viewing angle.""" |
| fig = plt.figure(figsize=(10, 10)) |
| ax = fig.add_subplot(111, projection="3d") |
|
|
| |
| ax.set_xlabel("X") |
| ax.set_ylabel("Y") |
| ax.set_zlabel("Z") |
|
|
| return fig, ax |
|
|
|
|
| def update_frame(frame_idx: int, motion: np.ndarray, ax, lines: list, points: list): |
| """Update function for animation.""" |
| ax.clear() |
|
|
| |
| frame = motion[frame_idx] |
|
|
| |
| all_coords = motion.reshape(-1, 3) |
| margin = 0.5 |
| x_range = [all_coords[:, 0].min() - margin, all_coords[:, 0].max() + margin] |
| y_range = [all_coords[:, 1].min() - margin, all_coords[:, 1].max() + margin] |
| z_range = [0, all_coords[:, 2].max() + margin] |
|
|
| ax.set_xlim(x_range) |
| ax.set_ylim(y_range) |
| ax.set_zlim(z_range) |
|
|
| |
| ax.set_xlabel("X", fontsize=10) |
| ax.set_ylabel("Y", fontsize=10) |
| ax.set_zlabel("Z (Height)", fontsize=10) |
| ax.set_title(f"Frame {frame_idx + 1}/{len(motion)}", fontsize=14, pad=20) |
|
|
| |
| ax.view_init(elev=15, azim=45) |
|
|
| |
| xx, yy = np.meshgrid( |
| np.linspace(x_range[0], x_range[1], 2), np.linspace(y_range[0], y_range[1], 2) |
| ) |
| zz = np.zeros_like(xx) |
| ax.plot_surface(xx, yy, zz, alpha=0.1, color="gray") |
|
|
| |
| colors = ["red", "blue", "green", "cyan", "magenta"] |
| for chain_idx, chain in enumerate(SKELETON_CHAINS): |
| color = colors[chain_idx % len(colors)] |
| for i in range(len(chain) - 1): |
| j1, j2 = chain[i], chain[i + 1] |
| if j1 < len(frame) and j2 < len(frame): |
| xs = [frame[j1, 0], frame[j2, 0]] |
| ys = [frame[j1, 1], frame[j2, 1]] |
| zs = [frame[j1, 2], frame[j2, 2]] |
| linewidth = 4.0 if chain_idx == 0 else 3.0 |
| ax.plot(xs, ys, zs, color=color, linewidth=linewidth, alpha=0.8) |
|
|
| |
| ax.scatter( |
| frame[:, 0], |
| frame[:, 1], |
| frame[:, 2], |
| c="darkred", |
| marker="o", |
| s=50, |
| alpha=0.9, |
| edgecolors="black", |
| linewidth=0.5, |
| ) |
|
|
| |
| ax.grid(True, alpha=0.3) |
|
|
| return (ax,) |
|
|
|
|
| def create_video_from_joints( |
| joints: torch.Tensor | np.ndarray, output_path: str, fps: int = 20 |
| ) -> str: |
| """ |
| Create 3D skeleton animation directly from joint tensor or array. |
| |
| Args: |
| joints: Joint positions as torch.Tensor or np.ndarray (frames, 22, 3) |
| output_path: Path to save video |
| fps: Frames per second for the video |
| |
| Returns: |
| Path to output video |
| """ |
| |
| if isinstance(joints, torch.Tensor): |
| joints = joints.cpu().numpy() |
|
|
| |
| motion = joints.copy() |
| motion[:, :, [0, 1, 2]] = joints[:, :, [0, 2, 1]] |
|
|
| |
| fig, ax = setup_3d_plot() |
| lines, points = [], [] |
|
|
| |
| anim = FuncAnimation( |
| fig, |
| update_frame, |
| frames=len(motion), |
| fargs=(motion, ax, lines, points), |
| interval=1000 / fps, |
| blit=False, |
| repeat=True, |
| ) |
|
|
| |
| writer = FFMpegWriter(fps=fps, bitrate=1800, codec="libx264") |
| anim.save(str(output_path), writer=writer, dpi=100) |
|
|
| plt.close(fig) |
| return str(output_path) |
|
|
|
|
| def visualize_motion( |
| pt_path: str, output_path: str | None = None, fps: int = 20, show: bool = False |
| ) -> str: |
| """ |
| Visualize motion from .pt file (PyTorch tensor). |
| |
| Args: |
| pt_path: Path to .pt motion file |
| output_path: Path to save video (if None, will auto-generate) |
| fps: Frames per second for the video |
| show: If True, display the animation in a window |
| |
| Returns: |
| Path to the generated video file |
| """ |
| |
| motion = load_motion(pt_path) |
|
|
| |
| if output_path is None: |
| output_path = Path(pt_path).with_suffix(".mp4") |
| else: |
| output_path = Path(output_path) |
|
|
| print(f"\nCreating animation with {fps} FPS...") |
|
|
| |
| fig, ax = setup_3d_plot() |
| lines, points = [], [] |
|
|
| |
| anim = FuncAnimation( |
| fig, |
| update_frame, |
| frames=len(motion), |
| fargs=(motion, ax, lines, points), |
| interval=1000 / fps, |
| blit=False, |
| repeat=True, |
| ) |
|
|
| |
| print(f"Saving video to: {output_path}") |
| writer = FFMpegWriter(fps=fps, bitrate=1800, codec="libx264") |
| anim.save(str(output_path), writer=writer, dpi=100) |
| print("✓ Video saved successfully!") |
|
|
| |
| if show: |
| plt.show() |
|
|
| plt.close(fig) |
| return str(output_path) |
|
|
|
|
| def main() -> int: |
| """Main entry point for CLI""" |
| parser = argparse.ArgumentParser( |
| description="Visualize HumanML3D motion data as 3D skeleton animation" |
| ) |
| parser.add_argument("input", type=str, help="Path to input .pt motion file") |
| parser.add_argument( |
| "--output", |
| "-o", |
| type=str, |
| default=None, |
| help="Path to output video file (default: input_name.mp4)", |
| ) |
| parser.add_argument( |
| "--fps", |
| type=int, |
| default=20, |
| help="Frames per second for output video (default: 20)", |
| ) |
| parser.add_argument( |
| "--show", |
| action="store_true", |
| help="Display the animation in a window (in addition to saving)", |
| ) |
|
|
| args = parser.parse_args() |
|
|
| |
| input_path = Path(args.input) |
| if not input_path.exists(): |
| print(f"Error: Input file not found: {args.input}") |
| return 1 |
|
|
| |
| try: |
| output_path = visualize_motion( |
| args.input, output_path=args.output, fps=args.fps, show=args.show |
| ) |
| print(f"\n✓ Done! Video saved to: {output_path}") |
| return 0 |
| except Exception as e: |
| print(f"\n✗ Error: {e}") |
| import traceback |
|
|
| traceback.print_exc() |
| return 1 |
|
|
|
|
| if __name__ == "__main__": |
| exit(main()) |
|
|