| """ |
| Fast Avatar Engine - MuseTalk Integration for real-time lip-sync avatar generation. |
| Compatible with RunPod MuseTalk setup at /workspace/MuseTalk |
| |
| Based on Robin's working implementation with proper face detection and blending. |
| """ |
|
|
| import os |
| import sys |
| import logging |
| import subprocess |
| import time |
| import copy |
| from pathlib import Path |
| from typing import Optional, Generator, List |
| import numpy as np |
| import cv2 |
|
|
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
|
|
| |
| SERVER_DIR = Path(__file__).parent |
|
|
| |
| MUSETALK_ROOT = Path(os.getenv("MUSETALK_DIR", "/workspace/MuseTalk")) |
| sys.path.insert(0, str(MUSETALK_ROOT)) |
| os.chdir(str(MUSETALK_ROOT)) |
|
|
| |
| DEFAULT_AVATAR_VIDEO = SERVER_DIR / "avatar_videos" / "idle.mp4" |
|
|
|
|
| class MuseTalkEngine: |
| """MuseTalk-based avatar engine for real-time lip-sync video generation.""" |
|
|
| def __init__(self, avatar_video=None, resolution=256, fps=25): |
| self.avatar_video = avatar_video or str(DEFAULT_AVATAR_VIDEO) |
| self.resolution = resolution |
| self.fps = fps |
| self._avatar_loaded = False |
| self._models_loaded = False |
|
|
| |
| self.audio_processor = None |
| self.vae = None |
| self.unet = None |
| self.pe = None |
| self.device = None |
| self.timesteps = None |
| self.face_parser = None |
|
|
| |
| self.full_frames = [] |
| self.idle_frames = [] |
| self.idle_fps = fps |
| self.input_latent_list = [] |
| self.coord_list = [] |
| self.mask_list = [] |
| self.mask_coords_list = [] |
| self.original_width = None |
| self.original_height = None |
|
|
| |
| self.version = "v15" |
| self.bbox_shift = 0 |
| self.extra_margin = 10 |
| self.parsing_mode = "jaw" |
| self.left_cheek_width = 90 |
| self.right_cheek_width = 90 |
| self.upper_boundary_ratio = 0.5 |
| self.expand = 1.5 |
|
|
| logger.info("[MuseTalk] Initializing engine...") |
| self._load_models() |
| self._load_avatar() |
|
|
| @property |
| def avatar_loaded(self): |
| return self._avatar_loaded |
|
|
| def _load_models(self): |
| """Load MuseTalk models including face detection and parsing.""" |
| try: |
| import torch |
| from musetalk.utils.utils import load_all_model |
| from musetalk.whisper.audio2feature import Audio2Feature |
| from musetalk.utils.face_parsing import FaceParsing |
|
|
| logger.info("[MuseTalk] Loading models...") |
| start = time.time() |
|
|
| self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") |
| logger.info(f"[MuseTalk] Using device: {self.device}") |
|
|
| |
| self.vae, self.unet, self.pe = load_all_model() |
|
|
| |
| logger.info("[MuseTalk] Loading Audio2Feature...") |
| self.audio_processor = Audio2Feature(model_path="tiny") |
|
|
| |
| logger.info("[MuseTalk] Loading FaceParsing...") |
| self.face_parser = FaceParsing( |
| left_cheek_width=self.left_cheek_width, |
| right_cheek_width=self.right_cheek_width |
| ) |
|
|
| |
| self.pe = self.pe.half().to(self.device) |
| self.vae.vae = self.vae.vae.half().to(self.device) |
| self.unet.model = self.unet.model.half().to(self.device) |
|
|
| self.timesteps = torch.tensor([0], device=self.device) |
| self._models_loaded = True |
|
|
| logger.info(f"[MuseTalk] Models loaded in {time.time() - start:.2f}s") |
|
|
| except Exception as e: |
| logger.error(f"[MuseTalk] Error loading models: {e}") |
| import traceback |
| traceback.print_exc() |
| self._models_loaded = False |
|
|
| def _get_cache_path(self): |
| """Get cache directory path based on avatar video.""" |
| import hashlib |
| video_hash = hashlib.md5(self.avatar_video.encode()).hexdigest()[:8] |
| cache_dir = MUSETALK_ROOT / "results" / "v15" / "avatars" / f"cache_{video_hash}" |
| return cache_dir |
|
|
| def _load_avatar(self): |
| """Load avatar video frames and detect faces (with caching).""" |
| import pickle |
| import torch |
|
|
| try: |
| if not os.path.exists(self.avatar_video): |
| logger.error(f"[MuseTalk] Avatar video not found: {self.avatar_video}") |
| return |
|
|
| logger.info(f"[MuseTalk] Loading avatar from: {self.avatar_video}") |
|
|
| |
| cache_dir = self._get_cache_path() |
| cache_file = cache_dir / "avatar_cache.pkl" |
| latents_file = cache_dir / "latents.pt" |
|
|
| if cache_file.exists() and latents_file.exists(): |
| logger.info(f"[MuseTalk] Loading from cache: {cache_dir}") |
| start = time.time() |
|
|
| with open(cache_file, 'rb') as f: |
| cache = pickle.load(f) |
|
|
| self.full_frames = cache['full_frames'] |
| self.idle_frames = cache['idle_frames'] |
| self.coord_list = cache['coord_list'] |
| self.mask_list = cache['mask_list'] |
| self.mask_coords_list = cache['mask_coords_list'] |
| self.original_width = cache['original_width'] |
| self.original_height = cache['original_height'] |
| self.idle_fps = cache['idle_fps'] |
| self.input_latent_list = torch.load(latents_file, weights_only=False) |
|
|
| logger.info(f"[MuseTalk] Loaded {len(self.full_frames)} frames from cache in {time.time()-start:.1f}s") |
| self._avatar_loaded = True |
| return |
|
|
| |
| logger.info("[MuseTalk] No cache found, processing avatar (this will be cached)...") |
|
|
| cap = cv2.VideoCapture(self.avatar_video) |
| self.idle_fps = cap.get(cv2.CAP_PROP_FPS) or self.fps |
|
|
| full_frames = [] |
| while True: |
| ret, frame = cap.read() |
| if not ret: |
| break |
| full_frames.append(frame) |
| cap.release() |
|
|
| if not full_frames: |
| logger.error("[MuseTalk] No frames loaded from video") |
| return |
|
|
| |
| self.full_frames = full_frames |
| self.original_height, self.original_width = full_frames[0].shape[:2] |
| logger.info(f"[MuseTalk] Loaded {len(full_frames)} frames at {self.idle_fps} fps (resolution: {self.original_width}x{self.original_height})") |
|
|
| |
| self._detect_faces() |
|
|
| |
| if self._models_loaded and self.vae and self.idle_frames: |
| self._precompute_latents() |
|
|
| |
| try: |
| cache_dir.mkdir(parents=True, exist_ok=True) |
| cache = { |
| 'full_frames': self.full_frames, |
| 'idle_frames': self.idle_frames, |
| 'coord_list': self.coord_list, |
| 'mask_list': self.mask_list, |
| 'mask_coords_list': self.mask_coords_list, |
| 'original_width': self.original_width, |
| 'original_height': self.original_height, |
| 'idle_fps': self.idle_fps, |
| } |
| with open(cache_file, 'wb') as f: |
| pickle.dump(cache, f) |
| torch.save(self.input_latent_list, latents_file) |
| logger.info(f"[MuseTalk] Saved cache to: {cache_dir}") |
| except Exception as e: |
| logger.warning(f"[MuseTalk] Could not save cache: {e}") |
|
|
| self._avatar_loaded = True |
|
|
| except Exception as e: |
| logger.error(f"[MuseTalk] Error loading avatar: {e}") |
| import traceback |
| traceback.print_exc() |
|
|
| def _detect_faces(self): |
| """Detect faces using MuseTalk's preprocessing (matching realtime_inference.py).""" |
| try: |
| from musetalk.utils.blending import get_image_prepare_material |
| import numpy as np |
| from musetalk.utils.face_detection import FaceAlignment, LandmarksType |
|
|
| logger.info("[MuseTalk] Detecting faces using MuseTalk preprocessing...") |
|
|
| device_str = 'cuda' if self.device is not None and self.device.type == 'cuda' else 'cpu' |
| fa = FaceAlignment(LandmarksType._2D, flip_input=False, device=device_str) |
|
|
| self.idle_frames = [] |
| self.coord_list = [] |
| self.mask_list = [] |
| self.mask_coords_list = [] |
|
|
| |
| batch_size = 8 |
| for batch_start in range(0, len(self.full_frames), batch_size): |
| batch_end = min(batch_start + batch_size, len(self.full_frames)) |
| batch_frames = self.full_frames[batch_start:batch_end] |
| batch_array = np.stack(batch_frames, axis=0) |
| detections = fa.get_detections_for_batch(batch_array) |
|
|
| for i, (frame, detection) in enumerate(zip(batch_frames, detections)): |
| frame_idx = batch_start + i |
| h, w = frame.shape[:2] |
|
|
| if detection is None: |
| |
| size = min(h, w) // 2 |
| center_x, center_y = w // 2, h // 2 |
| x1 = center_x - size // 2 |
| y1 = center_y - size // 2 |
| x2 = x1 + size |
| y2 = y1 + size |
| else: |
| x1, y1, x2, y2 = [int(v) for v in detection] |
|
|
| |
| x1 = max(0, x1 + self.bbox_shift) |
| y1 = max(0, y1 + self.bbox_shift) |
| x2 = min(w, x2 + self.bbox_shift) |
| y2 = min(h, y2 + self.bbox_shift) |
|
|
| |
| if self.version == "v15": |
| y2_extended = min(y2 + self.extra_margin, h) |
| else: |
| y2_extended = y2 |
|
|
| bbox = [x1, y1, x2, y2_extended] |
| self.coord_list.append(bbox) |
|
|
| |
| face_crop = frame[y1:y2_extended, x1:x2] |
| if face_crop.size > 0: |
| face_resized = cv2.resize(face_crop, (256, 256), interpolation=cv2.INTER_LANCZOS4) |
| else: |
| face_resized = cv2.resize(frame, (256, 256), interpolation=cv2.INTER_LANCZOS4) |
| self.idle_frames.append(face_resized) |
|
|
| logger.info(f"[MuseTalk] Face detection complete, {len(self.coord_list)} faces processed") |
|
|
| |
| if self.face_parser is not None: |
| logger.info("[MuseTalk] Pre-computing blending masks...") |
| for i, (frame, bbox) in enumerate(zip(self.full_frames, self.coord_list)): |
| try: |
| mask, crop_box = get_image_prepare_material( |
| frame, |
| bbox, |
| upper_boundary_ratio=self.upper_boundary_ratio, |
| expand=self.expand, |
| fp=self.face_parser, |
| mode=self.parsing_mode |
| ) |
| self.mask_list.append(mask) |
| self.mask_coords_list.append(crop_box) |
| except Exception as e: |
| logger.warning(f"[MuseTalk] Error computing mask for frame {i}: {e}") |
| |
| self.mask_list.append(np.zeros((256, 256), dtype=np.uint8)) |
| self.mask_coords_list.append([0, 0, 256, 256]) |
|
|
| logger.info(f"[MuseTalk] Pre-computed {len(self.mask_list)} masks") |
|
|
| except Exception as e: |
| logger.error(f"[MuseTalk] Error in face detection: {e}") |
| import traceback |
| traceback.print_exc() |
| |
| self.idle_frames = [] |
| self.coord_list = [] |
| for frame in self.full_frames: |
| resized = cv2.resize(frame, (256, 256)) |
| self.idle_frames.append(resized) |
| h, w = frame.shape[:2] |
| self.coord_list.append([0, 0, w, h]) |
|
|
| def _precompute_latents(self): |
| """Precompute latents for avatar frames.""" |
| try: |
| import torch |
| logger.info("[MuseTalk] Precomputing latents...") |
| |
| self.input_latent_list = [] |
| for frame in self.idle_frames[:50]: |
| frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) |
| latents = self.vae.get_latents_for_unet(frame_rgb) |
| self.input_latent_list.append(latents) |
| |
| logger.info(f"[MuseTalk] Precomputed {len(self.input_latent_list)} latents") |
| except Exception as e: |
| logger.error(f"[MuseTalk] Error precomputing latents: {e}") |
|
|
| def get_idle_frames(self): |
| """Get idle animation frames.""" |
| return self.idle_frames if self.idle_frames else [np.zeros((self.resolution, self.resolution, 3), dtype=np.uint8)] |
|
|
| def generate_frames_streaming(self, audio_path: str, resolution: int = None, batch_size: int = 4) -> Generator[dict, None, None]: |
| """ |
| Generate video frames from audio in streaming fashion. |
| |
| Args: |
| audio_path: Path to audio file |
| resolution: Output resolution (None = use original video resolution) |
| batch_size: Batch size for inference |
| """ |
| |
| output_width = resolution if resolution else self.original_width |
| output_height = resolution if resolution else self.original_height |
|
|
| |
| if not self._models_loaded or not self.audio_processor: |
| logger.warning("[MuseTalk] Models not loaded, returning idle frames") |
| duration = self._get_audio_duration(audio_path) |
| num_frames = int(duration * self.fps) |
| |
| yield {"type": "info", "total_frames": num_frames, "fps": self.fps, "width": output_width, "height": output_height} |
| for i in range(num_frames): |
| frame_idx = i % len(self.full_frames) |
| frame = self.full_frames[frame_idx].copy() |
| yield {"type": "frame", "frame": frame, "index": i, "total": num_frames} |
| return |
|
|
| try: |
| import torch |
| from musetalk.utils.blending import get_image_blending |
|
|
| logger.info(f"[MuseTalk] Processing audio: {audio_path}") |
|
|
| |
| whisper_feature = self.audio_processor.audio2feat(audio_path) |
| whisper_chunks = self.audio_processor.feature2chunks( |
| feature_array=whisper_feature, |
| fps=self.fps |
| ) |
|
|
| total_frames = len(whisper_chunks) |
| logger.info(f"[MuseTalk] Generating {total_frames} frames (output: {output_width}x{output_height})") |
|
|
| |
| yield {"type": "info", "total_frames": total_frames, "fps": self.fps, "width": output_width, "height": output_height} |
|
|
| |
| num_avatar_frames = len(self.full_frames) |
| frame_list_cycle = self.full_frames + self.full_frames[::-1] |
| coord_list_cycle = self.coord_list + self.coord_list[::-1] |
| latent_list_cycle = self.input_latent_list + self.input_latent_list[::-1] |
| mask_list_cycle = self.mask_list + self.mask_list[::-1] if self.mask_list else [] |
| mask_coords_list_cycle = self.mask_coords_list + self.mask_coords_list[::-1] if self.mask_coords_list else [] |
|
|
| |
| for i, whisper_batch in enumerate(whisper_chunks): |
| try: |
| |
| cycle_idx = i % len(frame_list_cycle) |
| latent_idx = i % len(latent_list_cycle) |
|
|
| latent = latent_list_cycle[latent_idx] |
| bbox = coord_list_cycle[cycle_idx] |
| original_frame = copy.deepcopy(frame_list_cycle[cycle_idx]) |
|
|
| |
| audio_feat = torch.from_numpy(whisper_batch).unsqueeze(0).half().to(self.device) |
| audio_feat = self.pe(audio_feat) |
|
|
| |
| with torch.no_grad(): |
| latent_input = latent.half().to(self.device) |
| pred = self.unet.model( |
| latent_input, |
| self.timesteps, |
| encoder_hidden_states=audio_feat |
| ).sample |
|
|
| |
| pred_faces = self.vae.decode_latents(pred) |
| pred_face = pred_faces[0] |
|
|
| |
| x1, y1, x2, y2 = bbox |
| try: |
| pred_face_resized = cv2.resize(pred_face.astype(np.uint8), (x2 - x1, y2 - y1)) |
| except: |
| pred_face_resized = pred_face |
|
|
| |
| pred_face_resized = cv2.cvtColor(pred_face_resized, cv2.COLOR_RGB2BGR) |
|
|
| |
| if mask_list_cycle and mask_coords_list_cycle: |
| mask = mask_list_cycle[cycle_idx] |
| mask_crop_box = mask_coords_list_cycle[cycle_idx] |
| combined_frame = get_image_blending( |
| original_frame, |
| pred_face_resized, |
| bbox, |
| mask, |
| mask_crop_box |
| ) |
| else: |
| |
| combined_frame = original_frame.copy() |
| combined_frame[y1:y2, x1:x2] = pred_face_resized |
|
|
| yield {"type": "frame", "frame": combined_frame, "index": i, "total": total_frames} |
|
|
| except Exception as e: |
| logger.error(f"[MuseTalk] Error generating frame {i}: {e}") |
| import traceback |
| traceback.print_exc() |
| |
| frame_idx = i % len(self.full_frames) |
| yield {"type": "frame", "frame": self.full_frames[frame_idx].copy(), "index": i, "total": total_frames} |
|
|
| logger.info("[MuseTalk] Frame generation complete") |
|
|
| except Exception as e: |
| logger.error(f"[MuseTalk] Error in streaming generation: {e}") |
| import traceback |
| traceback.print_exc() |
| |
| for i, frame in enumerate(self.full_frames[:30]): |
| yield {"type": "frame", "frame": frame.copy(), "index": i, "total": 30} |
|
|
| def _get_audio_duration(self, audio_path: str) -> float: |
| """Get audio duration in seconds.""" |
| try: |
| result = subprocess.run( |
| ["ffprobe", "-v", "error", "-show_entries", "format=duration", |
| "-of", "default=noprint_wrappers=1:nokey=1", audio_path], |
| capture_output=True, text=True |
| ) |
| return float(result.stdout.strip()) |
| except: |
| return 1.0 |
|
|
|
|
| |
| _engine: Optional[MuseTalkEngine] = None |
|
|
|
|
| def initialize_engine(avatar_path=None, resolution=256, fps=25) -> MuseTalkEngine: |
| """Initialize and return the global MuseTalk engine.""" |
| global _engine |
| if _engine is None: |
| _engine = MuseTalkEngine(avatar_video=avatar_path, resolution=resolution, fps=fps) |
| return _engine |
|
|
|
|
| def get_engine() -> Optional[MuseTalkEngine]: |
| """Get the global engine instance.""" |
| return _engine |
|
|
|
|
| def generate_frames_streaming(audio_path: str, resolution: int = None, batch_size: int = 4) -> Generator[dict, None, None]: |
| """Generate streaming frames from audio using the global engine.""" |
| engine = get_engine() |
| if engine is None: |
| logger.error("[MuseTalk] Engine not initialized") |
| res = resolution or 512 |
| yield {"type": "frame", "frame": np.zeros((res, res, 3), dtype=np.uint8), "index": 0, "total": 1} |
| return |
| yield from engine.generate_frames_streaming(audio_path, resolution, batch_size) |
|
|
|
|
| def get_idle_frames() -> List[np.ndarray]: |
| """Get idle animation frames (original resolution).""" |
| engine = get_engine() |
| if engine is None: |
| return [np.zeros((256, 256, 3), dtype=np.uint8)] |
| |
| if engine.full_frames: |
| return engine.full_frames |
| return engine.get_idle_frames() |
|
|
|
|
| if __name__ == "__main__": |
| engine = initialize_engine() |
| print(f"Avatar loaded: {engine.avatar_loaded}") |
| print(f"Models loaded: {engine._models_loaded}") |
| print(f"Idle frames: {len(engine.idle_frames)}") |
|
|