AvatarChatbot / float_lipsync.py
J Z
Update float_lipsync.py
0a27e61 verified
Raw
History Blame Contribute Delete
17 kB
"""
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__)
# ---- Add FLOAT repo to Python path ----
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
# Workaround: transformers now requires torch>=2.6 for torch.load safety (CVE-2025-32434)
# but vLLM 0.7.3 pins torch 2.5.1. Disable the check since we only load trusted local checkpoints.
try:
import transformers.utils.import_utils as _tf_import
import transformers.modeling_utils as _tf_modeling
_noop = lambda: None
# Patch the source function
if hasattr(_tf_import, 'check_torch_load_is_safe'):
_tf_import.check_torch_load_is_safe = _noop
# Patch the local reference in modeling_utils (imported via 'from ... import')
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}")
# Workaround: newer transformers defaults wav2vec2 to SDPA attention which doesn't support
# output_attentions=True (needed by FLOAT's wav2vec2.py). Force eager attention.
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}")
# Import FLOAT model from cloned repo
from models.float.FLOAT import FLOAT
# ---- Paths (configurable via env or defaults) ----
CHECKPOINTS_DIR = "/app/checkpoints"
LIPSYNC_OUTPUT_DIR = "/tmp/lipsync_output"
REF_IMAGE_DIR = "/app/assets"
# Default config
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...")
# 1. Build options namespace (matches daemon's opt structure)
self.opt = self._build_options(config)
os.makedirs(LIPSYNC_OUTPUT_DIR, exist_ok=True)
# 2. Set device
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.")
# 3. Load face alignment model
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")
# 4. Load wav2vec preprocessor
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")
# 5. Image transform
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(),
])
# 6. Load FLOAT model
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")
# 7. Load checkpoint weights
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")
# 8. Move to device and eval mode
start = time.time()
self.model.to(self.device)
self.model.eval()
logger.info(f"[FLOAT] Model to device + eval: {time.time() - start:.2f}s")
# 9. Preprocess reference image
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()
# Paths
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
# Generation params
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)
# Device / system
opt.rank = 0
opt.ngpus = 1
opt.emo = 'S2E'
# Model architecture params (from FLOAT defaults)
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)}")
# Auto-generate output 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")
# 1. Load and preprocess audio
speech_array, sr = librosa.load(audio_path, sr=self.opt.sampling_rate)
# Pad with 0.5s silence so FLOAT eases the face back to neutral at the end
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}")
# 2. Run FLOAT inference
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")
# 3. Chain frames: use last frame as reference for next chunk
if chain_frames and d_hat.shape[0] > 0:
# d_hat is (T, C, H, W) in [-1, 1] range — same as preprocessed_ref_image
self.preprocessed_ref_image = d_hat[-1:].clone().to(self.device)
logger.info(f"[FLOAT] Chained last frame as next reference")
# 4. Save video with audio
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."""
# Prepare frames: (T, C, H, W) -> (T, H, W, C) uint8
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}")
# Use CPU h264 encoding
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
)
# Use communicate() with input data — this handles pipe buffering correctly
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
# ---- Module-level singleton ----
_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