| """ |
| FLOAT Lipsync - Direct Integration (no subprocess/daemon) |
| |
| Simplified from float_lipsync_daemon.py for single-process Docker deployment. |
| Loads FLOAT model once at startup, then generates lipsync videos on demand. |
| """ |
| import sys |
| import os |
| import types |
| import time |
| import datetime |
| import subprocess |
| import tempfile |
| import logging |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| FLOAT_REPO_PATH = "/app/float_repo" |
| if FLOAT_REPO_PATH not in sys.path: |
| sys.path.insert(0, FLOAT_REPO_PATH) |
|
|
| import torch |
| import cv2 |
| import numpy as np |
| import librosa |
| import face_alignment |
| import albumentations as A |
| import albumentations.pytorch.transforms as A_pytorch |
| from transformers import Wav2Vec2FeatureExtractor |
|
|
| |
| |
| try: |
| import transformers.utils.import_utils as _tf_import |
| import transformers.modeling_utils as _tf_modeling |
| _noop = lambda: None |
| |
| if hasattr(_tf_import, 'check_torch_load_is_safe'): |
| _tf_import.check_torch_load_is_safe = _noop |
| |
| if hasattr(_tf_modeling, 'check_torch_load_is_safe'): |
| _tf_modeling.check_torch_load_is_safe = _noop |
| logger.info("[FLOAT] Patched transformers torch.load safety check for torch 2.5 compat") |
| except Exception as e: |
| logger.warning(f"[FLOAT] Failed to patch torch.load safety check: {e}") |
|
|
| |
| |
| try: |
| from transformers import Wav2Vec2Config as _W2VConfig |
| _orig_init = _W2VConfig.__init__ |
| def _patched_init(self, *args, **kwargs): |
| _orig_init(self, *args, **kwargs) |
| self._attn_implementation = "eager" |
| _W2VConfig.__init__ = _patched_init |
| logger.info("[FLOAT] Patched Wav2Vec2Config to use eager attention") |
| except Exception as e: |
| logger.warning(f"[FLOAT] Failed to patch Wav2Vec2Config: {e}") |
|
|
| |
| from models.float.FLOAT import FLOAT |
|
|
| |
| CHECKPOINTS_DIR = "/app/checkpoints" |
| LIPSYNC_OUTPUT_DIR = "/tmp/lipsync_output" |
| REF_IMAGE_DIR = "/app/assets" |
|
|
| |
| DEFAULT_CONFIG = { |
| "ref_path": os.path.join(REF_IMAGE_DIR, "ref.png"), |
| "ckpt_path": os.path.join(CHECKPOINTS_DIR, "float.pth"), |
| "wav2vec_model_path": os.path.join(CHECKPOINTS_DIR, "wav2vec2-base-960h"), |
| "audio2emotion_path": os.path.join(CHECKPOINTS_DIR, "wav2vec-english-speech-emotion-recognition"), |
| "seed": 15, |
| "a_cfg_scale": 2.0, |
| "e_cfg_scale": 1.0, |
| "r_cfg_scale": 1.0, |
| "no_crop": False, |
| "nfe": 7, |
| "fps": 25.0, |
| } |
|
|
|
|
| class FloatLipsync: |
| """ |
| Direct FLOAT lipsync generator. |
| Call initialize() once at startup, then generate() for each audio clip. |
| """ |
|
|
| def __init__(self): |
| self.model = None |
| self.opt = None |
| self.device = None |
| self.fa = None |
| self.wav2vec_preprocessor = None |
| self.transform = None |
| self.preprocessed_ref_image = None |
| self.ready = False |
|
|
| def initialize(self, config: dict = None): |
| """ |
| Load all models and preprocess the reference image. |
| Call this once at startup. |
| """ |
| if config is None: |
| config = DEFAULT_CONFIG |
|
|
| init_start = time.time() |
| logger.info("[FLOAT] Initializing FLOAT lipsync system...") |
|
|
| |
| self.opt = self._build_options(config) |
| os.makedirs(LIPSYNC_OUTPUT_DIR, exist_ok=True) |
|
|
| |
| torch.cuda.empty_cache() |
| self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") |
| logger.info(f"[FLOAT] Using device: {self.device}") |
|
|
| if not torch.cuda.is_available(): |
| logger.warning("[FLOAT] WARNING: No CUDA GPU detected! FLOAT will be very slow on CPU.") |
|
|
| |
| start = time.time() |
| self.fa = face_alignment.FaceAlignment( |
| face_alignment.LandmarksType.TWO_D, flip_input=False, device=str(self.device) |
| ) |
| logger.info(f"[FLOAT] Face alignment loaded: {time.time() - start:.2f}s") |
|
|
| |
| start = time.time() |
| self.wav2vec_preprocessor = Wav2Vec2FeatureExtractor.from_pretrained( |
| self.opt.wav2vec_model_path, local_files_only=True |
| ) |
| logger.info(f"[FLOAT] Wav2Vec2 preprocessor loaded: {time.time() - start:.2f}s") |
|
|
| |
| self.transform = A.Compose([ |
| A.Resize(height=self.opt.input_size, width=self.opt.input_size, interpolation=cv2.INTER_AREA), |
| A.Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5)), |
| A_pytorch.ToTensorV2(), |
| ]) |
|
|
| |
| logger.info("[FLOAT] Loading FLOAT model architecture...") |
| start = time.time() |
| self.model = FLOAT(self.opt) |
| logger.info(f"[FLOAT] Architecture created: {time.time() - start:.2f}s") |
|
|
| |
| logger.info("[FLOAT] Loading checkpoint weights...") |
| start = time.time() |
| self._load_weights(self.opt.ckpt_path) |
| logger.info(f"[FLOAT] Weights loaded: {time.time() - start:.2f}s") |
|
|
| |
| start = time.time() |
| self.model.to(self.device) |
| self.model.eval() |
| logger.info(f"[FLOAT] Model to device + eval: {time.time() - start:.2f}s") |
|
|
| |
| logger.info(f"[FLOAT] Preprocessing reference image: {self.opt.ref_path}") |
| self._preload_reference_image(self.opt.ref_path) |
|
|
| self.ready = True |
| logger.info(f"[FLOAT] ✓ TOTAL INIT TIME: {time.time() - init_start:.2f}s") |
| logger.info("[FLOAT] ✓ System ready to generate.") |
|
|
| def _build_options(self, config: dict) -> types.SimpleNamespace: |
| """Build the options namespace matching FLOAT's expected format.""" |
| opt = types.SimpleNamespace() |
|
|
| |
| opt.ref_path = config.get("ref_path", DEFAULT_CONFIG["ref_path"]) |
| opt.ckpt_path = config.get("ckpt_path", DEFAULT_CONFIG["ckpt_path"]) |
| opt.wav2vec_model_path = config.get("wav2vec_model_path", DEFAULT_CONFIG["wav2vec_model_path"]) |
| opt.audio2emotion_path = config.get("audio2emotion_path", DEFAULT_CONFIG["audio2emotion_path"]) |
| opt.res_dir = LIPSYNC_OUTPUT_DIR |
|
|
| |
| opt.seed = config.get("seed", 15) |
| opt.a_cfg_scale = config.get("a_cfg_scale", 2.0) |
| opt.e_cfg_scale = config.get("e_cfg_scale", 1.0) |
| opt.r_cfg_scale = config.get("r_cfg_scale", 1.0) |
| opt.no_crop = config.get("no_crop", False) |
| opt.nfe = config.get("nfe", 7) |
| opt.fps = config.get("fps", 25.0) |
|
|
| |
| opt.rank = 0 |
| opt.ngpus = 1 |
| opt.emo = 'S2E' |
|
|
| |
| opt.fix_noise_seed = False |
| opt.input_size = 512 |
| opt.input_nc = 3 |
| opt.sampling_rate = 16000 |
| opt.audio_marcing = 2 |
| opt.wav2vec_sec = 2.0 |
| opt.attention_window = 2 |
| opt.only_last_features = False |
| opt.average_emotion = False |
| opt.audio_dropout_prob = 0.1 |
| opt.ref_dropout_prob = 0.1 |
| opt.emotion_dropout_prob = 0.1 |
| opt.style_dim = 512 |
| opt.dim_a = 512 |
| opt.dim_w = 512 |
| opt.dim_h = 1024 |
| opt.dim_m = 20 |
| opt.dim_e = 7 |
| opt.fmt_depth = 8 |
| opt.num_heads = 8 |
| opt.mlp_ratio = 4.0 |
| opt.no_learned_pe = False |
| opt.num_prev_frames = 10 |
| opt.max_grad_norm = 1.0 |
| opt.ode_atol = 1e-5 |
| opt.ode_rtol = 1e-5 |
| opt.torchdiffeq_ode_method = 'euler' |
| opt.n_diff_steps = 500 |
| opt.diff_schedule = 'cosine' |
| opt.diffusion_mode = 'sample' |
|
|
| return opt |
|
|
| def _load_weights(self, checkpoint_path: str): |
| """Load checkpoint weights into the model.""" |
| if not os.path.exists(checkpoint_path): |
| raise FileNotFoundError(f"Checkpoint not found: {checkpoint_path}") |
|
|
| state_dict = torch.load(checkpoint_path, map_location='cpu', weights_only=True) |
| param_count = 0 |
| with torch.no_grad(): |
| for model_name, model_param in self.model.named_parameters(): |
| if model_name in state_dict: |
| model_param.copy_(state_dict[model_name].to(self.device)) |
| param_count += 1 |
| elif "wav2vec2" not in model_name: |
| logger.warning(f"[FLOAT] Weight not in checkpoint: {model_name}") |
| del state_dict |
| logger.info(f"[FLOAT] Loaded {param_count} parameters from checkpoint") |
|
|
| def _preload_reference_image(self, ref_path: str): |
| """Load and preprocess the reference image.""" |
| if not os.path.exists(ref_path): |
| raise FileNotFoundError(f"Reference image not found: {ref_path}") |
|
|
| img = cv2.imread(ref_path) |
| if img is None: |
| raise IOError(f"Could not read image: {ref_path}") |
| img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) |
|
|
| if not self.opt.no_crop: |
| img = self._crop_face(img) |
|
|
| self.preprocessed_ref_image = self.transform(image=img)['image'].unsqueeze(0).to(self.device) |
| self._original_ref_image = self.preprocessed_ref_image.clone() |
| logger.info(f"[FLOAT] Reference image preprocessed: {self.preprocessed_ref_image.shape}") |
|
|
| def _crop_face(self, img: np.ndarray) -> np.ndarray: |
| """Detect and crop face from image.""" |
| mult = 360.0 / img.shape[0] |
| interp = cv2.INTER_AREA if mult < 1.0 else cv2.INTER_CUBIC |
| resized = cv2.resize(img, dsize=(0, 0), fx=mult, fy=mult, interpolation=interp) |
|
|
| bboxes = self.fa.face_detector.detect_from_image(resized) |
| bboxes = [ |
| (int(x1 / mult), int(y1 / mult), int(x2 / mult), int(y2 / mult), score) |
| for (x1, y1, x2, y2, score) in bboxes if score > 0.95 |
| ] |
|
|
| if not bboxes: |
| raise RuntimeError("No face detected in reference image") |
|
|
| bbox = bboxes[0] |
| bsy = int((bbox[3] - bbox[1]) / 2) |
| bsx = int((bbox[2] - bbox[0]) / 2) |
| my = int((bbox[1] + bbox[3]) / 2) |
| mx = int((bbox[0] + bbox[2]) / 2) |
| bs = int(max(bsy, bsx) * 1.6) |
|
|
| img = cv2.copyMakeBorder(img, bs, bs, bs, bs, cv2.BORDER_REPLICATE) |
| my, mx = my + bs, mx + bs |
| crop = img[my - bs:my + bs, mx - bs:mx + bs] |
|
|
| interp = cv2.INTER_AREA if mult < 1.0 else cv2.INTER_CUBIC |
| crop = cv2.resize(crop, (self.opt.input_size, self.opt.input_size), interpolation=interp) |
| return crop |
|
|
| def update_reference_image(self, ref_path: str) -> bool: |
| """Swap to a different reference image at runtime.""" |
| try: |
| self._preload_reference_image(ref_path) |
| self.opt.ref_path = ref_path |
| logger.info(f"[FLOAT] Reference updated: {ref_path}") |
| return True |
| except Exception as e: |
| logger.error(f"[FLOAT] Failed to update reference: {e}") |
| return False |
|
|
| @torch.no_grad() |
| def generate(self, audio_path: str, output_path: str = None, emo: str = 'S2E', chain_frames: bool = False) -> str: |
| """ |
| Generate a lipsync video from an audio file. |
| |
| Args: |
| audio_path: Path to the input WAV audio file |
| output_path: Where to save the output video (auto-generated if None) |
| emo: Emotion mode ('S2E' for speech-to-emotion auto-detect) |
| chain_frames: If True, use the last frame as reference for next call |
| |
| Returns: |
| Path to the generated video file |
| """ |
| if not self.ready: |
| raise RuntimeError("FLOAT system not initialized. Call initialize() first.") |
|
|
| inference_start = time.time() |
| logger.info(f"[FLOAT] Generating lipsync for: {os.path.basename(audio_path)}") |
|
|
| |
| if output_path is None: |
| timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") |
| output_path = os.path.join(LIPSYNC_OUTPUT_DIR, f"lipsync_{timestamp}.mp4") |
|
|
| |
| speech_array, sr = librosa.load(audio_path, sr=self.opt.sampling_rate) |
|
|
| |
| pad_samples = int(0.5 * sr) |
| speech_array = np.concatenate([speech_array, np.zeros(pad_samples, dtype=speech_array.dtype)]) |
|
|
| processed_audio = self.wav2vec_preprocessor( |
| speech_array, sampling_rate=sr, return_tensors='pt' |
| ).input_values[0].unsqueeze(0).to(self.device) |
|
|
| logger.info(f"[FLOAT] Audio preprocessed: {processed_audio.shape}") |
|
|
| |
| data = { |
| 's': self.preprocessed_ref_image, |
| 'a': processed_audio, |
| 'p': None, |
| 'e': None, |
| } |
|
|
| gen_start = time.time() |
| d_hat = self.model.inference( |
| data=data, |
| a_cfg_scale=self.opt.a_cfg_scale, |
| r_cfg_scale=self.opt.r_cfg_scale, |
| e_cfg_scale=self.opt.e_cfg_scale, |
| emo=emo, |
| nfe=self.opt.nfe, |
| seed=self.opt.seed, |
| )['d_hat'] |
| logger.info(f"[FLOAT] Model inference: {time.time() - gen_start:.2f}s") |
|
|
| |
| if chain_frames and d_hat.shape[0] > 0: |
| |
| self.preprocessed_ref_image = d_hat[-1:].clone().to(self.device) |
| logger.info(f"[FLOAT] Chained last frame as next reference") |
|
|
| |
| self._save_video(d_hat, output_path, audio_path) |
|
|
| logger.info(f"[FLOAT] ✓ Total generation: {time.time() - inference_start:.2f}s") |
| logger.info(f"[FLOAT] ✓ Output: {output_path}") |
| return output_path |
|
|
| def reset_reference(self): |
| """Reset the reference image back to the original ref.png.""" |
| if hasattr(self, '_original_ref_image') and self._original_ref_image is not None: |
| self.preprocessed_ref_image = self._original_ref_image.clone() |
| logger.info("[FLOAT] Reference reset to original") |
|
|
| def _save_video(self, vid_tensor: torch.Tensor, video_path: str, audio_path: str): |
| """Save video frames tensor to mp4 with audio using ffmpeg.""" |
| |
| vid = vid_tensor.permute(0, 2, 3, 1).detach().clamp(-1, 1).contiguous() |
| vid = ((vid + 1) / 2 * 255).to(torch.uint8).cpu().numpy() |
|
|
| height, width = vid.shape[1], vid.shape[2] |
| logger.info(f"[FLOAT] Saving video: {vid.shape[0]} frames, {width}x{height}") |
|
|
| |
| ffmpeg_cmd = [ |
| 'ffmpeg', '-y', |
| '-f', 'rawvideo', '-vcodec', 'rawvideo', |
| '-s', f'{width}x{height}', '-pix_fmt', 'rgb24', |
| '-r', str(self.opt.fps), |
| '-i', 'pipe:0', |
| ] |
|
|
| if audio_path and os.path.exists(audio_path): |
| ffmpeg_cmd += ['-i', audio_path] |
|
|
| ffmpeg_cmd += [ |
| '-c:v', 'libx264', |
| '-preset', 'fast', |
| '-crf', '23', |
| '-pix_fmt', 'yuv420p', |
| ] |
|
|
| if audio_path and os.path.exists(audio_path): |
| ffmpeg_cmd += ['-c:a', 'aac', '-b:a', '128k', '-shortest'] |
|
|
| ffmpeg_cmd.append(video_path) |
|
|
| try: |
| process = subprocess.Popen( |
| ffmpeg_cmd, stdin=subprocess.PIPE, |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE |
| ) |
| |
| stdout, stderr = process.communicate(input=vid.tobytes()) |
|
|
| if process.returncode != 0: |
| logger.error(f"[FLOAT] ffmpeg error (rc={process.returncode}): {stderr.decode()[:500]}") |
| raise RuntimeError("ffmpeg encoding failed") |
|
|
| file_size = os.path.getsize(video_path) if os.path.exists(video_path) else 0 |
| logger.info(f"[FLOAT] Video saved: {video_path} ({file_size / 1024:.1f} KB)") |
|
|
| except Exception as e: |
| logger.error(f"[FLOAT] Video save failed: {e}") |
| raise |
|
|
|
|
| |
| _lipsync_instance = None |
|
|
|
|
| def get_lipsync() -> FloatLipsync: |
| """Get or create the singleton FloatLipsync instance.""" |
| global _lipsync_instance |
| if _lipsync_instance is None: |
| _lipsync_instance = FloatLipsync() |
| return _lipsync_instance |