718 lines
31 KiB
Python
718 lines
31 KiB
Python
"""
|
|
CMU Motion Capture Nodes for ComfyUI
|
|
|
|
Provides motion capture playback from the CMU Motion Database.
|
|
Supports locomotion with proper drift, sequence chaining, and multi-character pairs.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import numpy as np
|
|
import torch
|
|
import random
|
|
import tarfile
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
# HuggingFace URL for CMU data
|
|
CMU_HF_URL = "https://huggingface.co/datasets/ckinpdx/CMUMDB/resolve/main/cmu_npy.tar.gz"
|
|
|
|
|
|
def download_cmu_library(destination, max_retries=3):
|
|
"""Download and extract CMU motion library from HuggingFace."""
|
|
destination = Path(destination)
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
|
|
index_path = destination / "index.json"
|
|
if index_path.exists():
|
|
return True
|
|
|
|
tar_path = destination / "cmu_npy.tar.gz"
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
print(f"[CMU] Downloading motion library (attempt {attempt + 1})...")
|
|
urllib.request.urlretrieve(CMU_HF_URL, tar_path)
|
|
|
|
# Verify file size
|
|
if tar_path.stat().st_size < 600_000_000: # ~660MB expected
|
|
print(f"[CMU] Download incomplete, retrying...")
|
|
tar_path.unlink()
|
|
continue
|
|
|
|
print(f"[CMU] Extracting...")
|
|
with tarfile.open(tar_path, 'r:gz') as tar:
|
|
tar.extractall(destination)
|
|
|
|
tar_path.unlink()
|
|
|
|
if index_path.exists():
|
|
return True
|
|
else:
|
|
print(f"[CMU] Extraction failed, index.json not found")
|
|
|
|
except Exception as e:
|
|
print(f"[CMU] Download error: {e}")
|
|
if tar_path.exists():
|
|
tar_path.unlink()
|
|
|
|
return False
|
|
|
|
|
|
class SCAILCMULibraryLoader:
|
|
"""Load CMU Motion Capture library."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"source": (["huggingface", "local"],),
|
|
},
|
|
"optional": {
|
|
"local_path": ("STRING", {"default": "", "tooltip": "Path to local CMU npy folder with index.json"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("CMU_LIBRARY",)
|
|
RETURN_NAMES = ("library",)
|
|
FUNCTION = "load"
|
|
CATEGORY = "SCAIL/Motion"
|
|
|
|
def load(self, source, local_path=""):
|
|
if source == "local" and local_path:
|
|
base_path = local_path
|
|
else:
|
|
# Get ComfyUI models path
|
|
comfy_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
base_path = os.path.join(comfy_path, "models", "cmu_motion")
|
|
|
|
# Download if needed
|
|
if not os.path.exists(os.path.join(base_path, "index.json")):
|
|
success = download_cmu_library(base_path)
|
|
if not success:
|
|
raise RuntimeError("Failed to download CMU motion library")
|
|
|
|
# Load index
|
|
index_path = os.path.join(base_path, "index.json")
|
|
if not os.path.exists(index_path):
|
|
raise RuntimeError(f"index.json not found at {base_path}")
|
|
|
|
with open(index_path, 'r') as f:
|
|
index = json.load(f)
|
|
|
|
library = {
|
|
'base_path': base_path,
|
|
'index': index,
|
|
'motions': index['motions'],
|
|
'categories': index['categories']
|
|
}
|
|
|
|
total = len(index['motions'])
|
|
cats = len(index['categories'])
|
|
print(f"[CMU] Loaded library: {total} motions in {cats} categories")
|
|
|
|
return (library,)
|
|
|
|
|
|
class SCAILCMUMotion:
|
|
"""
|
|
Single CMU motion node. Chain multiple for sequences.
|
|
Preserves locomotion drift and handles transitions.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"library": ("CMU_LIBRARY",),
|
|
"category": ([
|
|
"walk", "run", "jump", "dance", "fight", "sports",
|
|
"climb", "interact", "sit", "crouch", "gesture",
|
|
"swim", "getup", "fall", "balance", "misc"
|
|
],),
|
|
"selection_mode": (["random", "specific"],),
|
|
"motion_id": ("STRING", {"default": "", "tooltip": "Motion ID when selection_mode is 'specific' (e.g., '02_01')"}),
|
|
"transition_frames": ("INT", {"default": 12, "min": 0, "max": 60}),
|
|
"loop_mode": (["none", "loop", "ping_pong"],),
|
|
"target_fps": ("INT", {"default": 24, "min": 1, "max": 120}),
|
|
},
|
|
"optional": {
|
|
"scail_pose": ("SCAIL_POSE",),
|
|
"pose_sequence_in": ("SCAIL_POSE_SEQUENCE",),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE", "INT", "STRING")
|
|
RETURN_NAMES = ("pose_sequence", "frame_count_out", "motion_info")
|
|
FUNCTION = "generate"
|
|
CATEGORY = "SCAIL/Motion"
|
|
|
|
def generate(self, library, category, selection_mode, motion_id, transition_frames, loop_mode,
|
|
target_fps, scail_pose=None, pose_sequence_in=None):
|
|
|
|
base_path = library['base_path']
|
|
index = library['index']
|
|
source_fps = index.get('fps', 120)
|
|
time_scale = source_fps / target_fps
|
|
|
|
# Select motion
|
|
if selection_mode == "random" or not motion_id:
|
|
available = library['categories'].get(category, [])
|
|
if not available:
|
|
available = list(index['motions'].keys())
|
|
motion_id = random.choice(available)
|
|
elif motion_id not in index['motions']:
|
|
print(f"[CMU] Motion '{motion_id}' not found, selecting random from {category}")
|
|
available = library['categories'].get(category, [])
|
|
if not available:
|
|
available = list(index['motions'].keys())
|
|
motion_id = random.choice(available)
|
|
|
|
motion_info = index['motions'][motion_id]
|
|
motion_path = os.path.join(base_path, motion_info['file'])
|
|
|
|
# Load motion data
|
|
motion_data = np.load(motion_path)
|
|
motion_frames = len(motion_data)
|
|
|
|
# Calculate output frame count from motion length and fps scaling
|
|
frame_count = int(motion_frames / time_scale)
|
|
|
|
motion_desc = f"{motion_id}: {motion_info['description']}"
|
|
print(f"[CMU] Playing: {motion_desc[:70]} ({motion_frames} @ {source_fps}fps -> {frame_count} @ {target_fps}fps)")
|
|
|
|
# Get frame count from input sequence
|
|
if pose_sequence_in is not None:
|
|
start_frame = pose_sequence_in['frame_count']
|
|
input_poses = pose_sequence_in['poses']
|
|
last_position_x = pose_sequence_in.get('last_position_x', None)
|
|
last_pose = input_poses[-1] if len(input_poses) > 0 else None
|
|
else:
|
|
start_frame = 0
|
|
input_poses = []
|
|
last_position_x = None
|
|
last_pose = None
|
|
|
|
# Get reference data for scaling/positioning
|
|
if scail_pose is not None:
|
|
ref_joints = scail_pose['joints'].numpy()
|
|
char_count = scail_pose.get('char_count', 1)
|
|
else:
|
|
ref_joints = None
|
|
char_count = 1
|
|
|
|
# Build reference data per character
|
|
# Get reference data for scaling/positioning
|
|
ref_data = []
|
|
|
|
if scail_pose is not None:
|
|
ref_joints = scail_pose['joints'].numpy()
|
|
char_count = scail_pose.get('char_count', 1)
|
|
|
|
# Build reference data per character
|
|
for i in range(char_count):
|
|
start_idx = i * 18
|
|
end_idx = start_idx + 18
|
|
if end_idx <= len(ref_joints):
|
|
char_joints = ref_joints[start_idx:end_idx]
|
|
|
|
# Helper to check if joint is valid (not zero/origin)
|
|
def is_valid(joint_idx):
|
|
j = char_joints[joint_idx]
|
|
return abs(j[0]) > 1e-3 or abs(j[1]) > 1e-3 or abs(j[2]) > 1e-3
|
|
|
|
# Helper to calculate leg angle for straight leg detection
|
|
def calc_leg_angle(hip, knee, ankle):
|
|
v1 = hip - knee
|
|
v2 = ankle - knee
|
|
cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8)
|
|
return np.degrees(np.arccos(np.clip(cos_angle, -1, 1)))
|
|
|
|
CMU_AVG_HEIGHT = 24.0
|
|
|
|
# Cascading scale detection for partial poses
|
|
full_height = None
|
|
char_scale = None
|
|
scale_method = "default"
|
|
|
|
# 1. Try full height (head to ankles) - best
|
|
if is_valid(0) and (is_valid(10) or is_valid(13)):
|
|
head_y = char_joints[0][1]
|
|
|
|
# Use straight leg detection if both legs visible
|
|
if is_valid(8) and is_valid(9) and is_valid(10) and is_valid(11) and is_valid(12) and is_valid(13):
|
|
l_hip, l_knee, l_ankle = char_joints[11], char_joints[12], char_joints[13]
|
|
r_hip, r_knee, r_ankle = char_joints[8], char_joints[9], char_joints[10]
|
|
|
|
l_angle = calc_leg_angle(l_hip, l_knee, l_ankle)
|
|
r_angle = calc_leg_angle(r_hip, r_knee, r_ankle)
|
|
|
|
straight_legs = []
|
|
if l_angle > 160:
|
|
straight_legs.append(l_ankle[1])
|
|
if r_angle > 160:
|
|
straight_legs.append(r_ankle[1])
|
|
|
|
if straight_legs:
|
|
foot_y = sum(straight_legs) / len(straight_legs)
|
|
else:
|
|
foot_y = max(l_ankle[1], r_ankle[1])
|
|
else:
|
|
# Partial leg data - use what we have
|
|
l_ankle_y = char_joints[13][1] if is_valid(13) else char_joints[10][1]
|
|
r_ankle_y = char_joints[10][1] if is_valid(10) else char_joints[13][1]
|
|
foot_y = max(l_ankle_y, r_ankle_y)
|
|
|
|
full_height = abs(foot_y - head_y)
|
|
if full_height > 10:
|
|
char_scale = full_height / CMU_AVG_HEIGHT
|
|
scale_method = "full_height"
|
|
else:
|
|
full_height = None
|
|
|
|
# 2. Try torso (neck to hips) - good
|
|
if char_scale is None and is_valid(1) and (is_valid(8) or is_valid(11)):
|
|
neck = char_joints[1]
|
|
r_hip = char_joints[8] if is_valid(8) else char_joints[11]
|
|
l_hip = char_joints[11] if is_valid(11) else char_joints[8]
|
|
mid_hip = (r_hip + l_hip) / 2
|
|
torso_len = np.linalg.norm(neck - mid_hip)
|
|
|
|
if torso_len > 3:
|
|
# Torso is ~40% of full height, so full = torso / 0.4 = torso * 2.5
|
|
full_height = torso_len * 2.5
|
|
char_scale = full_height / CMU_AVG_HEIGHT
|
|
scale_method = "torso"
|
|
|
|
# 3. Try shoulder width - decent
|
|
if char_scale is None and is_valid(2) and is_valid(5):
|
|
r_shoulder = char_joints[2]
|
|
l_shoulder = char_joints[5]
|
|
shoulder_width = np.linalg.norm(l_shoulder - r_shoulder)
|
|
|
|
if shoulder_width > 2:
|
|
# Shoulder width is ~25% of height, so full = width * 4
|
|
full_height = shoulder_width * 4
|
|
char_scale = full_height / CMU_AVG_HEIGHT
|
|
scale_method = "shoulders"
|
|
|
|
# 4. Fallback to defaults
|
|
if char_scale is None:
|
|
full_height = 200
|
|
char_scale = 1.0
|
|
scale_method = "default"
|
|
|
|
print(f"[CMU] Scale method: {scale_method}, scale: {char_scale:.3f}")
|
|
|
|
# Get position/depth from best available joint
|
|
if is_valid(1):
|
|
neck = char_joints[1]
|
|
center_x = neck[0]
|
|
depth_z = neck[2]
|
|
elif is_valid(2) and is_valid(5):
|
|
center_x = (char_joints[2][0] + char_joints[5][0]) / 2
|
|
depth_z = (char_joints[2][2] + char_joints[5][2]) / 2
|
|
else:
|
|
center_x = 256
|
|
depth_z = 800
|
|
|
|
# Floor Y from lowest valid joint
|
|
valid_y = [char_joints[j][1] for j in range(18) if is_valid(j)]
|
|
floor_y = max(valid_y) if valid_y else 400
|
|
|
|
ref_data.append({
|
|
'height': full_height,
|
|
'floor_y': floor_y,
|
|
'center_x': center_x,
|
|
'depth_z': depth_z,
|
|
'scale': char_scale
|
|
})
|
|
else:
|
|
ref_data.append({'height': 200, 'floor_y': 400, 'center_x': 256, 'depth_z': 800, 'scale': 1.0})
|
|
|
|
elif pose_sequence_in is not None and 'ref_data' in pose_sequence_in:
|
|
# INHERIT FROM CHAIN
|
|
ref_data = pose_sequence_in['ref_data']
|
|
char_count = pose_sequence_in.get('char_count', 1)
|
|
|
|
else:
|
|
# DEFAULTS
|
|
char_count = 1
|
|
ref_data.append({'height': 200, 'floor_y': 400, 'center_x': 256, 'depth_z': 800, 'scale': 1.0})
|
|
|
|
ref = ref_data[0]
|
|
scale = ref['scale']
|
|
|
|
# Initialize position tracking
|
|
if last_position_x is not None:
|
|
current_x = last_position_x
|
|
else:
|
|
current_x = ref['center_x']
|
|
|
|
# Get first frame center for offset calculation
|
|
first_frame = motion_data[0]
|
|
first_center_x = (first_frame[2, 0] + first_frame[5, 0] + first_frame[8, 0] + first_frame[11, 0]) / 4
|
|
|
|
# Track motion playback
|
|
src_frame = 0
|
|
direction = 1 # 1 = forward, -1 = reverse (for ping_pong)
|
|
prev_center_x = first_center_x
|
|
|
|
# Generate frames
|
|
output_poses = list(input_poses) # Start with input poses
|
|
frames_to_generate = frame_count
|
|
|
|
for frame_idx in range(frames_to_generate):
|
|
# Get source frame with interpolation
|
|
src_float = src_frame
|
|
src_low = int(src_float)
|
|
src_high = min(src_low + 1, motion_frames - 1)
|
|
t = src_float - src_low
|
|
|
|
# Handle loop modes
|
|
if src_low >= motion_frames - 1:
|
|
if loop_mode == "loop":
|
|
src_frame = 0
|
|
src_low = 0
|
|
src_high = 1
|
|
t = 0
|
|
# Reset position offset for seamless loop
|
|
prev_center_x = first_center_x
|
|
elif loop_mode == "ping_pong":
|
|
direction *= -1
|
|
src_frame = motion_frames - 2
|
|
src_low = motion_frames - 2
|
|
src_high = motion_frames - 1
|
|
t = 0
|
|
else:
|
|
# Hold last frame
|
|
src_low = motion_frames - 1
|
|
src_high = motion_frames - 1
|
|
t = 0
|
|
elif src_low < 0:
|
|
if loop_mode == "ping_pong":
|
|
direction *= -1
|
|
src_frame = 1
|
|
src_low = 0
|
|
src_high = 1
|
|
t = 0
|
|
else:
|
|
src_low = 0
|
|
src_high = 0
|
|
t = 0
|
|
|
|
# Interpolate pose
|
|
pose = (1 - t) * motion_data[src_low] + t * motion_data[src_high]
|
|
|
|
# Calculate current frame center
|
|
frame_center_x = (pose[2, 0] + pose[5, 0] + pose[8, 0] + pose[11, 0]) / 4
|
|
|
|
# Accumulate drift (negative because we mirror X)
|
|
delta_x = (frame_center_x - prev_center_x) * scale * direction
|
|
# current_x += delta_x
|
|
prev_center_x = frame_center_x
|
|
|
|
# Build output for all characters
|
|
all_joints = []
|
|
|
|
for char_idx in range(char_count):
|
|
char_pose = pose.copy()
|
|
char_ref = ref_data[char_idx] if char_idx < len(ref_data) else ref_data[0]
|
|
|
|
# --- FACE SYNTHESIS START ---
|
|
# CMU data maps index 0 to "Top of Head". OpenPose expects "Nose".
|
|
|
|
raw_head_top = char_pose[0]
|
|
neck = char_pose[1]
|
|
|
|
# 1. Calculate Torso for Stable Scaling
|
|
# The raw head-to-neck distance fluctuates in CMU data, causing "shrinking".
|
|
# We use the torso length (Neck to Hips) as a stable reference for head size.
|
|
r_hip = char_pose[8]
|
|
l_hip = char_pose[11]
|
|
mid_hip = (r_hip + l_hip) / 2
|
|
spine_vec = neck - mid_hip
|
|
torso_len = np.linalg.norm(spine_vec)
|
|
|
|
# Fix head size to roughly 40% of torso length
|
|
# This guarantees the head never shrinks, even if raw data collapses.
|
|
stable_head_len = torso_len * 0.4
|
|
|
|
# 2. Determine Orientation
|
|
head_vec_raw = raw_head_top - neck
|
|
raw_len = np.linalg.norm(head_vec_raw)
|
|
|
|
# If raw head data is collapsed/garbage, align head with spine direction
|
|
if raw_len < 0.01 or raw_len < (torso_len * 0.1):
|
|
up_dir = spine_vec / (torso_len + 1e-6)
|
|
else:
|
|
up_dir = head_vec_raw / (raw_len + 1e-6)
|
|
|
|
r_sh = char_pose[2]
|
|
l_sh = char_pose[5]
|
|
sh_vec = l_sh - r_sh
|
|
right_dir = sh_vec / (np.linalg.norm(sh_vec) + 1e-6)
|
|
|
|
# Forward vector
|
|
fwd_dir = np.cross(right_dir, up_dir)
|
|
fwd_dir = fwd_dir / (np.linalg.norm(fwd_dir) + 1e-6)
|
|
|
|
# 3. Reposition Nose (Index 0)
|
|
# Use the STABLE length for placement
|
|
new_nose = neck + (up_dir * stable_head_len * 0.7) + (fwd_dir * stable_head_len * 0.25)
|
|
char_pose[0] = new_nose
|
|
nose = new_nose
|
|
|
|
# 4. Synthesize Eyes and Ears using STABLE length
|
|
# Right Eye (14)
|
|
char_pose[14] = nose + (right_dir * -0.15 * stable_head_len) + (up_dir * 0.15 * stable_head_len) - (fwd_dir * 0.05 * stable_head_len)
|
|
# Left Eye (15)
|
|
char_pose[15] = nose + (right_dir * 0.15 * stable_head_len) + (up_dir * 0.15 * stable_head_len) - (fwd_dir * 0.05 * stable_head_len)
|
|
# Right Ear (16)
|
|
char_pose[16] = nose + (right_dir * -0.4 * stable_head_len) + (up_dir * 0.0 * stable_head_len) - (fwd_dir * 0.3 * stable_head_len)
|
|
# Left Ear (17)
|
|
char_pose[17] = nose + (right_dir * 0.4 * stable_head_len) + (up_dir * 0.0 * stable_head_len) - (fwd_dir * 0.3 * stable_head_len)
|
|
# --- FACE SYNTHESIS END ---
|
|
|
|
# Get body center
|
|
shoulder_center = (char_pose[2] + char_pose[5]) / 2
|
|
hip_center = (char_pose[8] + char_pose[11]) / 2
|
|
pose_center_x = (shoulder_center[0] + hip_center[0]) / 2
|
|
pose_center_y = (shoulder_center[1] + hip_center[1]) / 2
|
|
|
|
# Scale around center (but don't re-center X - use accumulated position)
|
|
# WIDTH CORRECTION:
|
|
# CMU data is often too "wide" for standard/anime proportions.
|
|
# We multiply the X offset by 0.75 - 0.85 to slim the shoulders/hips.
|
|
width_factor = 0.8
|
|
|
|
char_pose[:, 0] = (char_pose[:, 0] - pose_center_x) * scale * width_factor + current_x
|
|
char_pose[:, 1] = (char_pose[:, 1] - pose_center_y) * scale
|
|
char_pose[:, 2] = char_ref['depth_z']
|
|
|
|
# Flip Y (CMU Y-up to screen Y-down)
|
|
char_pose[:, 1] = -char_pose[:, 1]
|
|
|
|
# Floor anchor
|
|
pose_floor_y = np.max(char_pose[:, 1])
|
|
floor_offset = char_ref['floor_y'] - pose_floor_y
|
|
char_pose[:, 1] = char_pose[:, 1] + floor_offset
|
|
|
|
all_joints.append(char_pose.astype(np.float32))
|
|
|
|
frame_joints = np.concatenate(all_joints, axis=0)
|
|
output_poses.append(frame_joints)
|
|
|
|
# Advance source frame
|
|
src_frame += direction * time_scale
|
|
|
|
# Blend transition from previous sequence
|
|
if last_pose is not None and transition_frames > 0:
|
|
blend_start = start_frame
|
|
blend_end = min(start_frame + transition_frames, len(output_poses))
|
|
|
|
for i in range(blend_start, blend_end):
|
|
t = (i - blend_start) / transition_frames
|
|
t = t * t * (3 - 2 * t) # Smooth easing
|
|
output_poses[i] = (1 - t) * last_pose + t * output_poses[i]
|
|
|
|
# Blend from reference pose if this is first in chain
|
|
elif scail_pose is not None and transition_frames > 0 and start_frame == 0:
|
|
ref_joints_np = scail_pose['joints'].numpy()
|
|
blend_frames = min(transition_frames, len(output_poses))
|
|
|
|
for i in range(blend_frames):
|
|
t = i / blend_frames
|
|
t = t * t * (3 - 2 * t)
|
|
output_poses[i] = (1 - t) * ref_joints_np + t * output_poses[i]
|
|
|
|
# OpenPose 18 limb sequence (matching SCAIL nodes.py exactly)
|
|
limb_seq = [
|
|
(1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10),
|
|
(1, 11), (11, 12), (12, 13), (1, 0), (0, 14), (14, 16), (0, 15), (15, 17),
|
|
]
|
|
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
# RGBA colors matching SCAIL nodes.py BONE_COLORS exactly
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
|
|
# Build full limb_seq and colors for all characters
|
|
full_limb_seq = []
|
|
full_colors = []
|
|
for i in range(char_count):
|
|
offset = i * 18
|
|
for limb in limb_seq:
|
|
full_limb_seq.append((limb[0] + offset, limb[1] + offset))
|
|
full_colors.extend(colors)
|
|
|
|
# Build output sequence
|
|
pose_sequence = {
|
|
'poses': output_poses,
|
|
'limb_seq': full_limb_seq,
|
|
'bone_colors': full_colors,
|
|
'frame_count': len(output_poses),
|
|
'last_position_x': current_x,
|
|
'char_count': char_count,
|
|
'ref_data': ref_data
|
|
}
|
|
|
|
print(f"[CMU] Output keys: {pose_sequence.keys()}")
|
|
print(f"[CMU] Poses count: {len(output_poses)}")
|
|
|
|
return (pose_sequence, len(output_poses), motion_desc)
|
|
|
|
|
|
class SCAILCMUSequenceToSCAILPose:
|
|
"""Convert CMU pose sequence to frame-by-frame SCAIL_POSE for rendering."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"pose_sequence": ("SCAIL_POSE_SEQUENCE",),
|
|
"image_width": ("INT", {"default": 512, "min": 64, "max": 4096}),
|
|
"image_height": ("INT", {"default": 768, "min": 64, "max": 4096}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE",)
|
|
RETURN_NAMES = ("pose_sequence",)
|
|
FUNCTION = "convert"
|
|
CATEGORY = "SCAIL/Motion"
|
|
|
|
def convert(self, pose_sequence, image_width, image_height):
|
|
poses = pose_sequence['poses']
|
|
char_count = pose_sequence.get('char_count', 1)
|
|
|
|
# Stack all poses into tensor
|
|
pose_array = np.stack(poses, axis=0)
|
|
joints_tensor = torch.from_numpy(pose_array).float()
|
|
|
|
# OpenPose 18 limb sequence (matching SCAIL nodes.py exactly)
|
|
limb_seq = [
|
|
(1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10),
|
|
(1, 11), (11, 12), (12, 13), (1, 0), (0, 14), (14, 16), (0, 15), (15, 17),
|
|
]
|
|
|
|
# RGBA colors matching SCAIL nodes.py (converted to RGB tuples 0-255)
|
|
colors = [
|
|
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
|
|
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
|
|
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
|
|
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
|
|
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
|
|
]
|
|
|
|
# Build full limb_seq and colors for all characters
|
|
full_limb_seq = []
|
|
full_colors = []
|
|
for i in range(char_count):
|
|
offset = i * 18
|
|
for limb in limb_seq:
|
|
full_limb_seq.append((limb[0] + offset, limb[1] + offset))
|
|
full_colors.extend(colors)
|
|
|
|
return ({
|
|
'joints': joints_tensor,
|
|
'limb_seq': full_limb_seq,
|
|
'colors': full_colors,
|
|
'char_count': char_count,
|
|
'frame_count': len(poses),
|
|
'image_width': image_width,
|
|
'image_height': image_height,
|
|
},)
|
|
|
|
|
|
# Node mappings
|
|
NODE_CLASS_MAPPINGS = {
|
|
"SCAILCMULibraryLoader": SCAILCMULibraryLoader,
|
|
"SCAILCMUMotion": SCAILCMUMotion,
|
|
"SCAILCMUSequenceToSCAILPose": SCAILCMUSequenceToSCAILPose,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"SCAILCMULibraryLoader": "SCAIL CMU Library Loader",
|
|
"SCAILCMUMotion": "SCAIL CMU Motion",
|
|
"SCAILCMUSequenceToSCAILPose": "SCAIL CMU Sequence to Pose",
|
|
}
|