henrybit's picture
Add EchoMimicV2 Accelerated ZeroGPU app
9cf1c0d verified
Raw
History Blame Contribute Delete
13.2 kB
# -*- coding: utf-8 -*-
import gc
import os
import random
import subprocess
import sys
import uuid
from pathlib import Path
import gradio as gr
import numpy as np
import spaces
import torch
from huggingface_hub import snapshot_download
from moviepy.editor import AudioFileClip, VideoFileClip
from PIL import Image
ROOT = Path(__file__).resolve().parent
SOURCE_DIR = ROOT / "echomimic_v2"
WEIGHTS_DIR = ROOT / "pretrained_weights"
OUTPUT_DIR = ROOT / "outputs"
SOURCE_REPO = "https://github.com/antgroup/echomimic_v2.git"
WEIGHTS_REPO = "BadToBest/EchoMimicV2"
POSE_NAMES = ["01", "02", "03", "04", "fight", "good", "salute", "ultraman"]
os.environ.setdefault("GRADIO_SSR_MODE", "0")
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
os.environ.setdefault("FFMPEG_PATH", "/usr/bin")
def ensure_source():
if not (SOURCE_DIR / "src").is_dir():
print("[SETUP] Cloning official EchoMimicV2 source...", flush=True)
subprocess.run(
["git", "clone", "--depth", "1", SOURCE_REPO, str(SOURCE_DIR)],
check=True,
)
if str(SOURCE_DIR) not in sys.path:
sys.path.insert(0, str(SOURCE_DIR))
ensure_source()
from diffusers import AutoencoderKL, DDIMScheduler
from src.models.pose_encoder import PoseEncoder
from src.models.unet_2d_condition import UNet2DConditionModel
from src.models.unet_3d_emo import EMOUNet3DConditionModel
from src.models.whisper.audio2feature import load_audio_model
from src.pipelines.pipeline_echomimicv2_acc import EchoMimicV2Pipeline
from src.utils.dwpose_util import draw_pose_select_v2
from src.utils.util import save_videos_grid
pipeline = None
weight_dtype = torch.float16
device = "cuda"
def ensure_models():
WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)
required = [
"denoising_unet_acc.pth",
"motion_module_acc.pth",
"pose_encoder.pth",
"reference_unet.pth",
]
if not all((WEIGHTS_DIR / name).is_file() for name in required):
print("[SETUP] Downloading official EchoMimicV2 accelerated weights...", flush=True)
snapshot_download(
repo_id=WEIGHTS_REPO,
local_dir=WEIGHTS_DIR,
allow_patterns=required,
)
vae_dir = WEIGHTS_DIR / "sd-vae-ft-mse"
if not (vae_dir / "config.json").is_file():
print("[SETUP] Downloading SD VAE...", flush=True)
snapshot_download(repo_id="stabilityai/sd-vae-ft-mse", local_dir=vae_dir)
base_dir = WEIGHTS_DIR / "sd-image-variations-diffusers"
if not (base_dir / "unet" / "config.json").is_file():
print("[SETUP] Downloading SD image-variations base model...", flush=True)
snapshot_download(
repo_id="lambdalabs/sd-image-variations-diffusers",
local_dir=base_dir,
)
whisper_path = WEIGHTS_DIR / "audio_processor" / "tiny.pt"
if not whisper_path.is_file():
print("[SETUP] Downloading Whisper tiny checkpoint...", flush=True)
whisper_path.parent.mkdir(parents=True, exist_ok=True)
torch.hub.download_url_to_file(
"https://openaipublic.azureedge.net/main/whisper/models/"
"65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/"
"tiny.pt",
str(whisper_path),
)
def log_model(name, model, source):
first = next(model.parameters(), None) if isinstance(model, torch.nn.Module) else None
parameters = (
sum(parameter.numel() for parameter in model.parameters())
if isinstance(model, torch.nn.Module)
else None
)
print(
f"[MODEL] name={name}, class={model.__class__.__name__}, source={source}, "
f"parameters={parameters}, dtype={getattr(first, 'dtype', None)}, "
f"device={getattr(first, 'device', None)}",
flush=True,
)
def load_models():
global pipeline
if pipeline is not None:
return pipeline
if not torch.cuda.is_available():
raise RuntimeError("EchoMimicV2 Accelerated requires CUDA")
ensure_models()
base_dir = WEIGHTS_DIR / "sd-image-variations-diffusers"
print("[MODEL LOAD] VAE from_pretrained", flush=True)
vae = AutoencoderKL.from_pretrained(
WEIGHTS_DIR / "sd-vae-ft-mse",
torch_dtype=weight_dtype,
).to(device=device, dtype=weight_dtype)
log_model("vae", vae, WEIGHTS_DIR / "sd-vae-ft-mse")
print("[MODEL LOAD] Reference UNet from_pretrained", flush=True)
reference_unet = UNet2DConditionModel.from_pretrained(
base_dir,
subfolder="unet",
use_safetensors=False,
).to(device=device, dtype=weight_dtype)
state = torch.load(WEIGHTS_DIR / "reference_unet.pth", map_location="cpu", weights_only=True)
reference_unet.load_state_dict(state)
del state
log_model("reference-unet", reference_unet, WEIGHTS_DIR / "reference_unet.pth")
print("[MODEL LOAD] Accelerated denoising UNet from_pretrained_2d", flush=True)
unet_kwargs = {
"use_inflated_groupnorm": True,
"unet_use_cross_frame_attention": False,
"unet_use_temporal_attention": False,
"use_motion_module": True,
"cross_attention_dim": 384,
"motion_module_resolutions": [1, 2, 4, 8],
"motion_module_mid_block": True,
"motion_module_decoder_only": False,
"motion_module_type": "Vanilla",
"motion_module_kwargs": {
"num_attention_heads": 8,
"num_transformer_block": 1,
"attention_block_types": ["Temporal_Self", "Temporal_Self"],
"temporal_position_encoding": True,
"temporal_position_encoding_max_len": 32,
"temporal_attention_dim_div": 1,
},
}
denoising_unet = EMOUNet3DConditionModel.from_pretrained_2d(
str(base_dir),
str(WEIGHTS_DIR / "motion_module_acc.pth"),
subfolder="unet",
unet_additional_kwargs=unet_kwargs,
).to(device=device, dtype=weight_dtype)
state = torch.load(
WEIGHTS_DIR / "denoising_unet_acc.pth",
map_location="cpu",
weights_only=True,
)
missing, unexpected = denoising_unet.load_state_dict(state, strict=False)
del state
print(
f"[MODEL] accelerated-unet missing={len(missing)}, unexpected={len(unexpected)}",
flush=True,
)
log_model("accelerated-denoising-unet", denoising_unet, WEIGHTS_DIR / "denoising_unet_acc.pth")
pose_encoder = PoseEncoder(
320,
conditioning_channels=3,
block_out_channels=(16, 32, 96, 256),
).to(device=device, dtype=weight_dtype)
state = torch.load(WEIGHTS_DIR / "pose_encoder.pth", map_location="cpu", weights_only=True)
pose_encoder.load_state_dict(state)
del state
log_model("pose-encoder", pose_encoder, WEIGHTS_DIR / "pose_encoder.pth")
print("[MODEL LOAD] Whisper tiny audio guider", flush=True)
audio_guider = load_audio_model(
model_path=str(WEIGHTS_DIR / "audio_processor" / "tiny.pt"),
device=device,
)
log_model("audio-guider", audio_guider, WEIGHTS_DIR / "audio_processor" / "tiny.pt")
scheduler = DDIMScheduler(
beta_start=0.00085,
beta_end=0.012,
beta_schedule="linear",
clip_sample=False,
steps_offset=1,
prediction_type="v_prediction",
rescale_betas_zero_snr=True,
timestep_spacing="trailing",
)
pipeline = EchoMimicV2Pipeline(
vae=vae,
reference_unet=reference_unet,
denoising_unet=denoising_unet,
audio_guider=audio_guider,
pose_encoder=pose_encoder,
scheduler=scheduler,
).to(device, dtype=weight_dtype)
gc.collect()
torch.cuda.empty_cache()
print("[MODEL LOAD COMPLETE] EchoMimicV2 Accelerated is ready", flush=True)
return pipeline
def estimate_gpu_duration(_image, audio, *_args):
try:
clip = AudioFileClip(audio)
duration = float(clip.duration)
clip.close()
except Exception:
duration = 5.0
return max(180, min(900, int(150 + duration * 35)))
def resolve_pose_dir(name):
pose_dir = SOURCE_DIR / "assets" / "halfbody_demo" / "pose" / name
if not pose_dir.is_dir():
raise gr.Error(f"姿态模板不存在:{name}")
frames = sorted(pose_dir.glob("*.npy"), key=lambda path: int(path.stem))
if not frames:
raise gr.Error(f"姿态模板没有可用帧:{name}")
return pose_dir, len(frames)
@spaces.GPU(size="xlarge", duration=estimate_gpu_duration)
@torch.inference_mode()
def generate(image, audio, pose_name, max_frames, seed, progress=gr.Progress()):
if not image:
raise gr.Error("请上传参考图片")
if not audio:
raise gr.Error("请上传驱动音频")
progress(0.05, desc="加载 Accelerated 模型...")
pipe = load_models()
pose_dir, pose_frames = resolve_pose_dir(pose_name)
width = height = 768
fps = 24
steps = 6
cfg = 1.0
context_frames = 12
context_overlap = 3
max_frames = max(12, min(120, int(max_frames)))
audio_clip = AudioFileClip(audio)
audio_frames = int(audio_clip.duration * fps)
length = min(max_frames, audio_frames, pose_frames)
if length < 12:
audio_clip.close()
raise gr.Error("音频过短,至少需要约0.5秒")
audio_clip = audio_clip.set_duration(length / fps)
actual_seed = random.randint(1, 2**31 - 1) if seed is None or seed < 0 else int(seed)
generator = torch.Generator(device=device).manual_seed(actual_seed)
ref_image = Image.open(image).convert("RGB").resize((width, height))
progress(0.15, desc="准备姿态模板...")
pose_list = []
for index in range(length):
target = np.zeros((width, height, 3), dtype=np.uint8)
detected_pose = np.load(pose_dir / f"{index}.npy", allow_pickle=True).tolist()
imh_new, imw_new, rb, re, cb, ce = detected_pose["draw_pose_params"]
rendered = draw_pose_select_v2(detected_pose, imh_new, imw_new, ref_w=800)
rendered = np.transpose(np.asarray(rendered), (1, 2, 0))
target[rb:re, cb:ce, :] = rendered
pose_list.append(
torch.from_numpy(target).to(device=device, dtype=weight_dtype).permute(2, 0, 1) / 255.0
)
poses = torch.stack(pose_list, dim=1).unsqueeze(0)
progress(0.25, desc=f"生成 {length} 帧视频...")
video = pipe(
ref_image,
audio,
poses,
width,
height,
length,
steps,
cfg,
generator=generator,
audio_sample_rate=16000,
context_frames=context_frames,
fps=fps,
context_overlap=context_overlap,
start_idx=0,
).videos
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
job_id = uuid.uuid4().hex
silent_path = OUTPUT_DIR / f"{job_id}_silent.mp4"
result_path = OUTPUT_DIR / f"{job_id}.mp4"
save_videos_grid(video[:, :, :length], str(silent_path), n_rows=1, fps=fps)
progress(0.92, desc="合并音频...")
video_clip = VideoFileClip(str(silent_path)).set_audio(audio_clip)
video_clip.write_videofile(
str(result_path),
codec="libx264",
audio_codec="aac",
fps=fps,
threads=2,
logger=None,
)
video_clip.close()
audio_clip.close()
silent_path.unlink(missing_ok=True)
progress(1.0, desc="完成")
return str(result_path), actual_seed, f"生成 {length} 帧({length / fps:.1f} 秒),姿态模板:{pose_name}"
with gr.Blocks(title="EchoMimicV2 Accelerated") as demo:
gr.Markdown(
"# EchoMimicV2 Accelerated\n"
"上传半身参考图和音频,使用官方加速权重与预置姿态模板生成说话视频。"
)
with gr.Row():
with gr.Column():
image_input = gr.Image(label="半身参考图片", type="filepath", sources=["upload"])
audio_input = gr.Audio(label="驱动音频", type="filepath", sources=["upload"])
pose_input = gr.Dropdown(
choices=POSE_NAMES,
value="01",
label="动作/姿态模板",
)
max_frames_input = gr.Slider(
minimum=24,
maximum=120,
value=72,
step=12,
label="最大生成帧数(24fps)",
)
seed_input = gr.Number(value=-1, precision=0, label="随机种子(-1为随机)")
generate_button = gr.Button("生成视频", variant="primary")
with gr.Column():
video_output = gr.Video(label="生成结果", autoplay=False)
seed_output = gr.Number(label="实际种子", precision=0)
info_output = gr.Textbox(label="生成信息")
gr.Markdown(
"首次运行需下载约17GB模型。官方加速配置为768×768、6步;"
"姿态模板决定身体动作,音频主要控制口型和节奏。"
)
generate_button.click(
generate,
inputs=[image_input, audio_input, pose_input, max_frames_input, seed_input],
outputs=[video_output, seed_output, info_output],
)
demo.queue(default_concurrency_limit=1, max_size=6)
if __name__ == "__main__":
demo.launch()