Files
2025-12-23 22:37:04 +09:00

233 lines
11 KiB
Python

import os
import sys
import cv2
import gc
import numpy as np
import torch
import torch.distributed as dist
from PIL import Image
from omegaconf import OmegaConf
from transformers import AutoTokenizer
from safetensors.torch import load_file
# Wan & FlashPortrait modules
from diffusers import FlowMatchEulerDiscreteScheduler
from wan.models.face_align import FaceAlignment
from wan.models.face_model import FaceModel
from wan.models.pdf import FanEncoder, det_landmarks, get_drive_expression_pd_fgc
from wan.models.portrait_encoder import PortraitEncoder
from wan.pipeline.pipeline_wan_long import WanI2VLongPipeline
from wan.models import AutoencoderKLWan, CLIPModel, WanT5EncoderModel, WanTransformer3DModel
from wan.utils.utils import simple_save_videos_grid, filter_kwargs
from wan.utils.fm_solvers import FlowDPMSolverMultistepScheduler
from wan.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
from xfuser.core.distributed import (get_world_group, init_distributed_environment,
initialize_model_parallel, get_sp_group)
# ==========================================================================================
# 1. Configuration
# ==========================================================================================
class GlobalConfig:
wan_model_name = "./checkpoints/Wan2.1-I2V-14B-720P"
transformer_path = "./checkpoints/FlashPortrait/transformer.pt"
portrait_encoder_path = "./checkpoints/FlashPortrait/portrait_encoder.pt"
det_model_path = "./checkpoints/FlashPortrait/face_det.onnx"
alignment_model_path = "./checkpoints/FlashPortrait/face_landmark.onnx"
pd_fpg_model_path = "./checkpoints/FlashPortrait/pd_fpg.pth"
config_path = "config/wan2.1/wan_civitai.yaml"
ulysses_degree = 1
ring_degree = 1
weight_dtype = torch.bfloat16
sampler_name = "Flow"
shift = 5
max_size = 720
sub_num_frames = 201
latents_num_frames = 51
context_overlap = 30
context_size = 51
num_inference_steps = 30
seed = 42
prompt = "The man is singing"
negative_prompt = "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
validation_image_start = "./examples/doecii/chim.png"
validation_driven_video_path = "./examples/doecii/doecii.mp4"
save_path = "samples/wan-videos-i2v"
# ==========================================================================================
# 2. Utility & Helper Functions
# ==========================================================================================
def setup_distributed(cfg):
"""분산 처리 환경 설정 함수"""
if cfg.ulysses_degree > 1 or cfg.ring_degree > 1:
if get_sp_group is None:
raise RuntimeError("xfuser is not installed.")
if not dist.is_initialized():
dist.init_process_group("nccl")
init_distributed_environment(rank=dist.get_rank(), world_size=dist.get_world_size())
initialize_model_parallel(
sequence_parallel_degree=dist.get_world_size(),
ring_degree=cfg.ring_degree,
ulysses_degree=cfg.ulysses_degree
)
return torch.device(f"cuda:{get_world_group().local_rank}")
return torch.device("cuda")
def find_replacement(a):
"""프레임 수를 4의 배수로 맞추는 함수 (원본 로직)"""
while a > 0:
if (a - 1) % 4 == 0: return a
a -= 1
return 0
def get_emo_feature(video_path, face_aligner, pd_fpg_motion, device):
"""드라이빙 비디오에서 감정 및 포즈 특징 추출"""
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
frame_list = []
while True:
ret, frame = cap.read()
if not ret: break
frame_list.append(frame.copy())
cap.release()
num_frames = find_replacement(len(frame_list))
frame_list = frame_list[:num_frames]
# 랜드마크 및 감정 추출
landmark_list = det_landmarks(face_aligner, frame_list)[1]
emo_list = get_drive_expression_pd_fgc(pd_fpg_motion, frame_list, landmark_list, device)
ef_list, hef_list = [], []
for emo in emo_list:
ef = torch.cat([emo["eye_embed"], emo["emo_embed"], emo["mouth_feat"]], dim=1)
hef = torch.cat([emo["headpose_emb"], ef], dim=1)
ef_list.append(ef)
hef_list.append(hef)
return torch.cat(ef_list, dim=0), torch.cat(hef_list, dim=0), fps, num_frames
# ==========================================================================================
# 3. Memory-Safe Loading Functions
# ==========================================================================================
def load_wan_pipeline_models(cfg, config, device):
"""메인 생성 모델들을 순차적으로 로드하여 VRAM 안착"""
print(">>> Loading Transformer...")
transformer = WanTransformer3DModel.from_pretrained(
os.path.join(cfg.wan_model_name, config['transformer_additional_kwargs'].get('transformer_subpath', 'transformer')),
transformer_additional_kwargs=OmegaConf.to_container(config['transformer_additional_kwargs']),
low_cpu_mem_usage=True, torch_dtype=cfg.weight_dtype
)
st = load_file(cfg.transformer_path) if cfg.transformer_path.endswith("safetensors") else torch.load(cfg.transformer_path, map_location="cpu", weights_only=True)
transformer.load_state_dict(st.get("state_dict", st), strict=False)
del st
gc.collect()
transformer.to(device)
print(">>> Loading VAE & Encoders...")
vae = AutoencoderKLWan.from_pretrained(
os.path.join(cfg.wan_model_name, config['vae_kwargs'].get('vae_subpath', 'vae')),
additional_kwargs=OmegaConf.to_container(config['vae_kwargs'])
).to(device=device, dtype=cfg.weight_dtype)
tokenizer = AutoTokenizer.from_pretrained(
os.path.join(cfg.wan_model_name, config['text_encoder_kwargs'].get('tokenizer_subpath', 'tokenizer'))
)
text_encoder = WanT5EncoderModel.from_pretrained(
os.path.join(cfg.wan_model_name, config['text_encoder_kwargs'].get('text_encoder_subpath', 'text_encoder')),
additional_kwargs=OmegaConf.to_container(config['text_encoder_kwargs']),
low_cpu_mem_usage=True, torch_dtype=cfg.weight_dtype
).to(device).eval()
clip_image_encoder = CLIPModel.from_pretrained(
os.path.join(cfg.wan_model_name, config['image_encoder_kwargs'].get('image_encoder_subpath', 'image_encoder'))
).to(device=device, dtype=cfg.weight_dtype).eval()
print(">>> Loading Portrait Encoder...")
pe_st = torch.load(cfg.portrait_encoder_path, map_location="cpu", weights_only=True)
portrait_encoder = PortraitEncoder(adapter_in_dim=768, adapter_proj_dim=2048)
for p, sub_m in [("proj_model.", portrait_encoder.proj_model),
("mouth_proj_model.", portrait_encoder.mouth_proj_model),
("emo_proj_model.", portrait_encoder.emo_proj_model)]:
sub_m.load_state_dict({k[len(p):]: v for k, v in pe_st.items() if k.startswith(p)})
del pe_st
gc.collect()
portrait_encoder.to(device).eval()
return transformer, vae, tokenizer, text_encoder, clip_image_encoder, portrait_encoder
# ==========================================================================================
# 4. Main Execution Logic
# ==========================================================================================
def main():
cfg = GlobalConfig()
device = setup_distributed(cfg)
config = OmegaConf.load(cfg.config_path)
# --- Phase 1: Pre-processing (전처리 후 즉시 메모리 해제) ---
print(">>> Phase 1: Video Pre-processing...")
fa = FaceModel(FaceAlignment(None, cfg.alignment_model_path, cfg.det_model_path), reset=False)
fm = FanEncoder().to(device).eval()
fm.load_state_dict(torch.load(cfg.pd_fpg_model_path, map_location=device), strict=False)
with torch.no_grad():
_, head_emo, fps, raw_num = get_emo_feature(cfg.validation_driven_video_path, fa, fm, device)
# Stride(21) 보정
stride = cfg.context_size - cfg.context_overlap
num_frames = cfg.context_size + ((raw_num - cfg.context_size) // stride) * stride if raw_num > cfg.context_size else cfg.context_size
head_emo = head_emo[:num_frames].unsqueeze(0).to(device)
del fa, fm
torch.cuda.empty_cache()
gc.collect()
# --- Phase 2: Load Main Pipeline ---
print(">>> Phase 2: Loading Heavy Models...")
transformer, vae, tokenizer, text_encoder, clip_image_encoder, portrait_encoder = load_wan_pipeline_models(cfg, config, device)
sc_cls = {"Flow": FlowMatchEulerDiscreteScheduler, "Flow_Unipc": FlowUniPCMultistepScheduler, "Flow_DPM++": FlowDPMSolverMultistepScheduler}[cfg.sampler_name]
scheduler = sc_cls(**filter_kwargs(sc_cls, OmegaConf.to_container(config['scheduler_kwargs'])))
pipeline = WanI2VLongPipeline(
transformer=transformer, vae=vae, tokenizer=tokenizer,
text_encoder=text_encoder, scheduler=scheduler,
clip_image_encoder=clip_image_encoder, portrait_encoder=portrait_encoder
)
# --- Phase 3: Inference ---
print(">>> Phase 3: Starting Generation...")
img = Image.open(cfg.validation_image_start).convert("RGB")
scale = cfg.max_size / max(img.size)
w, h = (int(img.size[0] * scale) // 16 * 16, int(img.size[1] * scale) // 16 * 16)
img = img.resize((w, h), Image.LANCZOS)
input_video = torch.tile(torch.from_numpy(np.array(img)).permute(2,0,1).unsqueeze(1).unsqueeze(0), [1, 1, cfg.sub_num_frames, 1, 1]).to(cfg.weight_dtype) / 255.0
input_video_mask = torch.zeros_like(input_video[:, :1])
input_video_mask[:, :, 1:] = 255
with torch.no_grad():
sample = pipeline(
prompt=cfg.prompt, negative_prompt=cfg.negative_prompt, num_frames=num_frames,
height=h, width=w, generator=torch.Generator(device=device).manual_seed(cfg.seed),
video=input_video, mask_video=input_video_mask, clip_image=img, shift=cfg.shift,
context_size=cfg.context_size, context_overlap=cfg.context_overlap,
latents_num_frames=cfg.latents_num_frames, sub_num_frames=cfg.sub_num_frames,
head_emo_feat_all=head_emo, guidance_scale=4.0, num_inference_steps=30
).videos
sample = sample[:, :, 1:]
# --- Phase 4: Save Result ---
if not dist.is_initialized() or dist.get_rank() == 0:
os.makedirs(cfg.save_path, exist_ok=True)
out_path = os.path.join(cfg.save_path, f"{len(os.listdir(cfg.save_path))+1:08d}.mp4")
simple_save_videos_grid(sample, out_path, fps=fps)
print(f">>> Process Completed. Saved to: {out_path}")
if __name__ == "__main__":
main()