Spaces:
Running on Zero
Running on Zero
Upload 7 files
Browse files- README.md +62 -5
- app.py +673 -0
- captioner.py +148 -0
- masking.py +339 -0
- pose.py +387 -0
- requirements.txt +18 -0
- wan_animate.py +246 -0
README.md
CHANGED
|
@@ -1,13 +1,70 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.31.0
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Wan-Animate Character Replacement
|
| 3 |
+
emoji: 🎭
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.31.0
|
| 8 |
+
python_version: "3.12"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
license: apache-2.0
|
| 12 |
+
short_description: Replace a person in a video with the character from a photo
|
| 13 |
+
startup_duration_timeout: 1h
|
| 14 |
+
suggested_hardware: zero-a10g
|
| 15 |
+
models:
|
| 16 |
+
- Wan-AI/Wan2.2-Animate-14B
|
| 17 |
+
- Wan-AI/Wan2.2-Animate-14B-Diffusers
|
| 18 |
+
- Kijai/WanVideo_comfy
|
| 19 |
+
- facebook/sam2.1-hiera-large
|
| 20 |
+
- PekingU/rtdetr_v2_r50vd
|
| 21 |
+
- facebook/mask2former-swin-base-coco-panoptic
|
| 22 |
+
- Qwen/Qwen3.5-4B
|
| 23 |
---
|
| 24 |
|
| 25 |
+
# Wan2.2-Animate character replacement on ZeroGPU
|
| 26 |
+
|
| 27 |
+
Photo + video → the person in the video is replaced by the character from the photo. The new character follows the
|
| 28 |
+
original person's **skeleton** (body proportions, position, every movement) and **face** (expressions, lips, head
|
| 29 |
+
turns) frame by frame; only the person's region is redrawn, the scene and objects stay those of the video.
|
| 30 |
+
|
| 31 |
+
* **Model:** [Wan2.2-Animate-14B](https://github.com/Wan-Video/Wan2.2) in replacement mode through diffusers'
|
| 32 |
+
`WanAnimatePipeline`, with the official Relighting LoRA and the Lightx2v distillation LoRA fused at startup. LoRA
|
| 33 |
+
file and strength (1.2), sampler (DPM++ SDE), 6 steps and shift 5 follow Kijai's Wan-Animate example workflow. The
|
| 34 |
+
transformer is read from the official bf16 checkpoint (34.5 GB; the diffusers copy is fp32, 69 GB) and renamed with
|
| 35 |
+
diffusers' own converter (`wan_animate.py`).
|
| 36 |
+
* **Pose and face** (`pose.py`): ViTPose-H whole-body, the checkpoint of the official preprocessing, run in PyTorch on
|
| 37 |
+
the GPU; skeleton drawing, face crops and keypoint decoding are ported from the official code and checked against
|
| 38 |
+
it (identical images, keypoints within 0.001 px). The skeleton is not retargeted, as in the official replacement
|
| 39 |
+
mode, so the proportions stay those of the original person. Face crops come from up to 2x sharper frames.
|
| 40 |
+
* **Masks** (`masking.py`): RT-DETRv2 finds the person on the first frame (or the one you click), SAM 2.1 tracks it,
|
| 41 |
+
Mask2Former's "person" segmentation cuts off what SAM 2.1 glues to it (chair, wall, room). The redrawn region is
|
| 42 |
+
that mask grown by 10 px and cut into 32-px blocks (Kijai's GrowMask + BlockifyMask) aligned with the latent grid:
|
| 43 |
+
a mask edge running through a latent leaves blacked-out pixels in a "kept" latent, which shows as a dark outline.
|
| 44 |
+
* **Reference:** the character is cut out of the photo (or taken from a PNG's transparency) and pasted onto the video's
|
| 45 |
+
own first frame, blurred and with the person removed; the Relighting LoRA was trained on characters pasted onto
|
| 46 |
+
other scenes, and a flat black backdrop comes back as a dark outline.
|
| 47 |
+
* **"Keep the original pixels"** pastes the generated person (feathered, around the original person only) over the
|
| 48 |
+
untouched source frames.
|
| 49 |
+
* **AI scene reading** (`captioner.py`, Qwen3.5-4B): before the replacement it looks at the photo and four frames,
|
| 50 |
+
names the action ("licks a lollipop"), finds what the person holds or has at the mouth (with boxes, checked against
|
| 51 |
+
Mask2Former so a box on a hand or a face is dropped) and writes the prompt: the character from the photo doing
|
| 52 |
+
exactly that. Wan-Animate's skeleton and face crops carry the body and the expression, not a tongue on a lollipop.
|
| 53 |
+
* **Objects to keep:** what the AI finds, and anything clicked on the first frame. SAM 2.1
|
| 54 |
+
tracks them as separate objects; latents that are mostly object are given to the model as they are, and the
|
| 55 |
+
original object, plus a few pixels of what touches it (lips, tongue, fingers), is pasted back on top. Otherwise the
|
| 56 |
+
model has to redraw the object it cannot see, which comes out doubled or bent.
|
| 57 |
+
* **Expression strength** scales what the face adapter adds to the video (Kijai's `face_strength`).
|
| 58 |
+
* **ZeroGPU:** one `xlarge` call (RTX Pro 6000 Blackwell, 96 GB). ≈2.5 min of GPU for 5 s at 480p / 30 fps.
|
| 59 |
+
* All files sit in the repository root, so the Space can be uploaded file by file from the web UI.
|
| 60 |
+
|
| 61 |
+
## Settings (Space → Settings → Variables)
|
| 62 |
+
|
| 63 |
+
| Variable | Default | Meaning |
|
| 64 |
+
|---|---|---|
|
| 65 |
+
| `WAN_LIGHTX2V` / `WAN_RELIGHT` | `1.2` / `1.0` | LoRA strengths (`WAN_LIGHTX2V=0` → original 20 steps with DPM++) |
|
| 66 |
+
| `WAN_ATTENTION` | empty (SDPA) | a diffusers attention backend, e.g. `sage_hub` (SageAttention, unverified publisher) |
|
| 67 |
+
| `WAN_PERSON_PARSER` | `facebook/mask2former-swin-base-coco-panoptic` | `none` turns the mask refinement off |
|
| 68 |
+
| `WAN_SCENE_MODEL` | `Qwen/Qwen3.5-4B` | vision-language model of the AI scene reading; `none` turns it off |
|
| 69 |
+
| `WAN_MAX_SECONDS` / `WAN_MAX_GPU_SECONDS` | `15` / `900` | input length / per-call GPU limits |
|
| 70 |
+
| `WAN_TFLOPS_LINEAR` / `WAN_TFLOPS_ATTENTION` / `WAN_STARTUP_SECONDS` | `330` / `260` / `25` | cost model for the requested ZeroGPU duration |
|
app.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Character replacement on Hugging Face ZeroGPU with Wan2.2-Animate.
|
| 2 |
+
|
| 3 |
+
The person in a video is replaced by the character from a photo. Wan-Animate's replacement mode follows the original
|
| 4 |
+
person's skeleton (body proportions, positions, motion) and face (expressions, lips, head turns) frame by frame and
|
| 5 |
+
regenerates only that person's region; the rest of every frame is the original video.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import spaces # ZeroGPU: import before torch
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
import os
|
| 12 |
+
import random
|
| 13 |
+
import ssl
|
| 14 |
+
import subprocess
|
| 15 |
+
import tempfile
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
import urllib.request
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import certifi
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import imageio_ffmpeg
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
from PIL import Image, ImageDraw, ImageOps
|
| 27 |
+
|
| 28 |
+
from captioner import SceneReader
|
| 29 |
+
from masking import PersonMasker, adjust, blockify, blocky, feathered, mostly, place_character, scene_backdrop
|
| 30 |
+
from pose import PoseExtractor
|
| 31 |
+
from wan_animate import (
|
| 32 |
+
DEFAULT_PROMPT,
|
| 33 |
+
PREV_SEGMENT_FRAMES,
|
| 34 |
+
SAMPLERS,
|
| 35 |
+
SEGMENT_FRAMES,
|
| 36 |
+
load_pipeline,
|
| 37 |
+
segment_count,
|
| 38 |
+
set_face_strength,
|
| 39 |
+
set_sampler,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Read when a ZeroGPU worker first allocates; limits fragmentation of the large activation buffers.
|
| 43 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 44 |
+
|
| 45 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 46 |
+
# Configuration (environment variables can be set in the Space settings)
|
| 47 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
LORA_STRENGTHS = {
|
| 50 |
+
"lightx2v": float(os.getenv("WAN_LIGHTX2V", "1.2")), # step distillation (1.2 as in Kijai's workflow); 0 = off
|
| 51 |
+
"relight": float(os.getenv("WAN_RELIGHT", "1.0")), # official replacement LoRA: scene lighting on the character
|
| 52 |
+
}
|
| 53 |
+
DISTILLED = LORA_STRENGTHS["lightx2v"] > 0
|
| 54 |
+
DEFAULT_STEPS = 6 if DISTILLED else 20
|
| 55 |
+
DEFAULT_SHIFT = 5.0 # official sample_shift, also Kijai's
|
| 56 |
+
DEFAULT_SAMPLER = "DPM++ SDE" if DISTILLED else "DPM++"
|
| 57 |
+
DEVICE = os.getenv("WAN_DEVICE", "cuda")
|
| 58 |
+
GPU_SIZE = os.getenv("WAN_GPU_SIZE", "xlarge") # 52 GB of weights do not fit a `large` (48 GB) slice
|
| 59 |
+
ATTENTION = os.getenv("WAN_ATTENTION", "") # a diffusers attention backend, e.g. "sage_hub"; empty = PyTorch SDPA
|
| 60 |
+
MAX_SECONDS = float(os.getenv("WAN_MAX_SECONDS", "15"))
|
| 61 |
+
CONCURRENCY = int(os.getenv("WAN_CONCURRENCY", "2"))
|
| 62 |
+
|
| 63 |
+
# Rough throughput of a full RTX Pro 6000 Blackwell, only used to request a ZeroGPU duration. The log prints the
|
| 64 |
+
# measured times; tune these if requests are cut off or wait too long in the queue.
|
| 65 |
+
TFLOPS_LINEAR = float(os.getenv("WAN_TFLOPS_LINEAR", "330"))
|
| 66 |
+
TFLOPS_ATTENTION = float(os.getenv("WAN_TFLOPS_ATTENTION", "260"))
|
| 67 |
+
VAE_SECONDS_PER_MEGAPIXEL_FRAME = float(os.getenv("WAN_VAE_SEC_PER_MPX_FRAME", "0.15"))
|
| 68 |
+
STARTUP_SECONDS = float(os.getenv("WAN_STARTUP_SECONDS", "25")) # worker spawn + moving 52 GB to the GPU
|
| 69 |
+
MAX_GPU_SECONDS = int(os.getenv("WAN_MAX_GPU_SECONDS", "900"))
|
| 70 |
+
SCENE_MODEL = os.getenv("WAN_SCENE_MODEL", "Qwen/Qwen3.5-4B") # "none" turns the AI scene reading off
|
| 71 |
+
SCENE_SECONDS = 25.0 # moving the reader to the GPU and back + one answer
|
| 72 |
+
# Weights applied to every video token per step: the 40 blocks without the cross-attention K/V projections (those
|
| 73 |
+
# only see the 769 text/image tokens) plus the face adapter.
|
| 74 |
+
ACTIVE_PARAMS = 12.4e9
|
| 75 |
+
|
| 76 |
+
FPS_CHOICES = [16, 24, 30]
|
| 77 |
+
DEFAULT_FPS = 30 # Wan-Animate's frame rate; a 5 s clip takes 2 windows at 16 fps as well as at 30 fps
|
| 78 |
+
# Target areas as (height, width); sides follow the video's aspect ratio and are multiples of 16.
|
| 79 |
+
QUALITIES = {
|
| 80 |
+
"480p · recommended": (480, 832),
|
| 81 |
+
"720p · sharper, ~4x slower": (720, 1280),
|
| 82 |
+
}
|
| 83 |
+
DEFAULT_QUALITY = "480p · recommended"
|
| 84 |
+
# Redrawn region: the person grown by MASK_GROWTH, then cut into blocks of this many pixels at 480p (scaled with the
|
| 85 |
+
# resolution); None = the person's whole box (the official default). Kijai's workflow: grow 10 px, 32 px blocks.
|
| 86 |
+
MASK_SHAPES = {
|
| 87 |
+
"Blocks (recommended)": 32,
|
| 88 |
+
"Small blocks: less of the background redrawn": 16,
|
| 89 |
+
"Box: most freedom, redraws the box around the person": None,
|
| 90 |
+
}
|
| 91 |
+
DEFAULT_MASK_SHAPE = next(iter(MASK_SHAPES))
|
| 92 |
+
MASK_GROWTH = 10 / 480
|
| 93 |
+
|
| 94 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 95 |
+
# Models (loaded once at startup; ZeroGPU moves everything placed on "cuda" to the GPU of each request)
|
| 96 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
print("Loading Wan2.2-Animate ...")
|
| 99 |
+
pipe = load_pipeline(LORA_STRENGTHS).to(DEVICE)
|
| 100 |
+
if ATTENTION:
|
| 101 |
+
try:
|
| 102 |
+
pipe.transformer.set_attention_backend(ATTENTION)
|
| 103 |
+
print(f"Attention backend: {ATTENTION}")
|
| 104 |
+
except Exception as e: # noqa: BLE001 - SDPA still works
|
| 105 |
+
print(f"Attention backend {ATTENTION} unavailable, using SDPA: {e!r}")
|
| 106 |
+
masker = PersonMasker().to(DEVICE)
|
| 107 |
+
poser = PoseExtractor().to(DEVICE)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def load_reader() -> SceneReader | None:
|
| 111 |
+
if SCENE_MODEL.lower() == "none":
|
| 112 |
+
return None
|
| 113 |
+
try:
|
| 114 |
+
return SceneReader(SCENE_MODEL) # stays on the CPU until used
|
| 115 |
+
except Exception as e: # noqa: BLE001 - the app still works with clicks and hand-written prompts
|
| 116 |
+
print(f"Scene reader {SCENE_MODEL} unavailable: {e!r}")
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
reader = load_reader()
|
| 121 |
+
print("Models loaded.")
|
| 122 |
+
|
| 123 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 124 |
+
# Geometry and cost model
|
| 125 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def frame_size(video_width: int, video_height: int, quality: str) -> tuple[int, int]:
|
| 129 |
+
"""(height, width): the video's aspect ratio at the target area, both sides multiples of 16."""
|
| 130 |
+
area_h, area_w = QUALITIES[quality]
|
| 131 |
+
aspect = video_height / video_width
|
| 132 |
+
height = max(16, round(math.sqrt(area_h * area_w * aspect) / 16) * 16)
|
| 133 |
+
width = max(16, round(math.sqrt(area_h * area_w / aspect) / 16) * 16)
|
| 134 |
+
return height, width
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def map_click(point, source_size, width: int, height: int) -> list[float]:
|
| 138 |
+
"""Normalised click on the source frame -> pixel position in the frame after `prepare_driving_video`'s
|
| 139 |
+
cover-resize + centre crop."""
|
| 140 |
+
src_w, src_h = source_size
|
| 141 |
+
scale = max(width / src_w, height / src_h)
|
| 142 |
+
return [point[0] * src_w * scale - (src_w * scale - width) / 2, point[1] * src_h * scale - (src_h * scale - height) / 2]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def estimate_gpu_seconds(num_frames: int, height: int, width: int, steps: int, scene: bool = False) -> float:
|
| 146 |
+
cfg = pipe.transformer.config
|
| 147 |
+
dim = cfg.num_attention_heads * cfg.attention_head_dim
|
| 148 |
+
tokens = (1 + (SEGMENT_FRAMES - 1) // 4 + 1) * (height // 16) * (width // 16) # reference + window latents
|
| 149 |
+
forward = (
|
| 150 |
+
2 * ACTIVE_PARAMS * tokens / (TFLOPS_LINEAR * 1e12)
|
| 151 |
+
+ 4 * dim * cfg.num_layers * tokens**2 / (TFLOPS_ATTENTION * 1e12)
|
| 152 |
+
)
|
| 153 |
+
vae = VAE_SECONDS_PER_MEGAPIXEL_FRAME * height * width / 1e6 * SEGMENT_FRAMES * 3 # pose + scene in, video out
|
| 154 |
+
per_segment = steps * forward + vae
|
| 155 |
+
analysis = 5.0 + 0.12 * num_frames # SAM 2.1 tracking + Mask2Former + ViTPose per frame
|
| 156 |
+
if scene and reader is not None:
|
| 157 |
+
analysis += SCENE_SECONDS
|
| 158 |
+
return STARTUP_SECONDS + analysis + 5.0 + segment_count(num_frames) * per_segment
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 162 |
+
# Media helpers (CPU)
|
| 163 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
FFMPEG = imageio_ffmpeg.get_ffmpeg_exe()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def ffmpeg(*args: str) -> None:
|
| 169 |
+
subprocess.run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error", *args], check=True, capture_output=True)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def temp_path(suffix: str) -> str:
|
| 173 |
+
return tempfile.NamedTemporaryFile(suffix=suffix, delete=False).name
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def video_metadata(path: str) -> dict:
|
| 177 |
+
reader = imageio_ffmpeg.read_frames(path)
|
| 178 |
+
meta = next(reader)
|
| 179 |
+
reader.close()
|
| 180 |
+
return meta
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def first_frame(path: str) -> np.ndarray:
|
| 184 |
+
reader = imageio_ffmpeg.read_frames(path)
|
| 185 |
+
width, height = next(reader)["size"]
|
| 186 |
+
frame = np.frombuffer(next(reader), np.uint8).reshape(height, width, 3).copy()
|
| 187 |
+
reader.close()
|
| 188 |
+
return frame
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def prepare_image(path: str) -> str:
|
| 192 |
+
"""EXIF-rotated, at most 2048 px, PNG (keeping an alpha channel, which doubles as the character mask)."""
|
| 193 |
+
image = ImageOps.exif_transpose(Image.open(path))
|
| 194 |
+
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
|
| 195 |
+
image.thumbnail((2048, 2048), Image.Resampling.LANCZOS)
|
| 196 |
+
out = temp_path(".png")
|
| 197 |
+
image.save(out)
|
| 198 |
+
return out
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def prepare_driving_video(path: str, max_seconds: float, fps: int, width: int, height: int,
|
| 202 |
+
audio: bool = True) -> tuple[str, int, str | None]:
|
| 203 |
+
"""Trims, resamples and cover-crops the video to `width` x `height` (on the CPU, before a GPU is held);
|
| 204 |
+
extracts its audio track if there is one and `audio` is set."""
|
| 205 |
+
out = temp_path(".mp4")
|
| 206 |
+
resize = f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=bicubic,crop={width}:{height}"
|
| 207 |
+
seconds = f"{max_seconds:.3f}"
|
| 208 |
+
ffmpeg("-i", path, "-t", seconds, "-vf", f"fps={fps},{resize}", "-an",
|
| 209 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "10", "-pix_fmt", "yuv444p", out)
|
| 210 |
+
num_frames = imageio_ffmpeg.count_frames_and_secs(out)[0]
|
| 211 |
+
track = None
|
| 212 |
+
if audio and video_metadata(path).get("audio_codec"):
|
| 213 |
+
track = temp_path(".m4a")
|
| 214 |
+
try:
|
| 215 |
+
ffmpeg("-i", path, "-t", seconds, "-vn", "-c:a", "aac", "-b:a", "192k", track)
|
| 216 |
+
except subprocess.CalledProcessError:
|
| 217 |
+
track = None
|
| 218 |
+
return out, num_frames, track
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def read_frames(path: str) -> list[np.ndarray]:
|
| 222 |
+
reader = imageio_ffmpeg.read_frames(path)
|
| 223 |
+
width, height = next(reader)["size"]
|
| 224 |
+
return [np.frombuffer(frame, np.uint8).reshape(height, width, 3) for frame in reader]
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def write_video(frames: np.ndarray, fps: int, audio_path: str | None = None) -> str:
|
| 228 |
+
"""uint8 [T, H, W, 3] -> mp4 (cropped to even sides for yuv420p), muxed with `audio_path` if given."""
|
| 229 |
+
height, width = frames.shape[1] // 2 * 2, frames.shape[2] // 2 * 2
|
| 230 |
+
silent = temp_path(".mp4")
|
| 231 |
+
writer = imageio_ffmpeg.write_frames(
|
| 232 |
+
silent, (width, height), fps=fps, codec="libx264", quality=None, macro_block_size=2,
|
| 233 |
+
output_params=["-crf", "16", "-preset", "veryfast", "-movflags", "+faststart"],
|
| 234 |
+
)
|
| 235 |
+
writer.send(None)
|
| 236 |
+
for frame in frames:
|
| 237 |
+
writer.send(np.ascontiguousarray(frame[:height, :width]))
|
| 238 |
+
writer.close()
|
| 239 |
+
if audio_path is None:
|
| 240 |
+
return silent
|
| 241 |
+
final = temp_path(".mp4")
|
| 242 |
+
try:
|
| 243 |
+
ffmpeg("-i", silent, "-i", audio_path, "-map", "0:v:0", "-map", "1:a:0",
|
| 244 |
+
"-c:v", "copy", "-c:a", "copy", "-shortest", "-movflags", "+faststart", final)
|
| 245 |
+
return final
|
| 246 |
+
except subprocess.CalledProcessError:
|
| 247 |
+
return silent
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def flatten(image: Image.Image) -> Image.Image:
|
| 251 |
+
"""RGB with transparent areas on black."""
|
| 252 |
+
if image.mode in ("RGBA", "LA", "P"):
|
| 253 |
+
rgba = image.convert("RGBA")
|
| 254 |
+
return Image.alpha_composite(Image.new("RGBA", rgba.size, (0, 0, 0, 255)), rgba).convert("RGB")
|
| 255 |
+
return image.convert("RGB")
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def near(box, person, slack: float = 0.25) -> bool:
|
| 259 |
+
"""An object box overlapping the person's box grown by `slack` of its size, and not bigger than it."""
|
| 260 |
+
if person is None:
|
| 261 |
+
return False
|
| 262 |
+
w, h = person[2] - person[0], person[3] - person[1]
|
| 263 |
+
grown = (person[0] - slack * w, person[1] - slack * h, person[2] + slack * w, person[3] + slack * h)
|
| 264 |
+
overlaps = box[0] < grown[2] and box[2] > grown[0] and box[1] < grown[3] and box[3] > grown[1]
|
| 265 |
+
return overlaps and (box[2] - box[0]) * (box[3] - box[1]) <= w * h
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 269 |
+
# GPU work
|
| 270 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def gpu_duration(image_path, video_path, face_path, num_frames, fps, prompt, steps, shift, sampler, seed,
|
| 274 |
+
keep_background, click, objects, mask_edge, mask_shape, contact, face_strength, use_ai, audio_path,
|
| 275 |
+
progress=None):
|
| 276 |
+
try:
|
| 277 |
+
width, height = video_metadata(video_path)["size"]
|
| 278 |
+
seconds = estimate_gpu_seconds(num_frames, height, width, steps, scene=use_ai)
|
| 279 |
+
return min(MAX_GPU_SECONDS, int(1.2 * seconds) + 10)
|
| 280 |
+
except Exception: # noqa: BLE001 - never fail the scheduling call
|
| 281 |
+
return 300
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@spaces.GPU(duration=gpu_duration, size=GPU_SIZE)
|
| 285 |
+
def replace_on_gpu(image_path, video_path, face_path, num_frames, fps, prompt, steps, shift, sampler, seed,
|
| 286 |
+
keep_background, click, objects, mask_edge, mask_shape, contact, face_strength, use_ai,
|
| 287 |
+
audio_path, progress=gr.Progress(track_tqdm=True)):
|
| 288 |
+
timings = {}
|
| 289 |
+
started = time.perf_counter()
|
| 290 |
+
if torch.cuda.is_available():
|
| 291 |
+
torch.cuda.reset_peak_memory_stats() # workers are reused across requests
|
| 292 |
+
|
| 293 |
+
def lap(name):
|
| 294 |
+
nonlocal started
|
| 295 |
+
timings[name] = time.perf_counter() - started
|
| 296 |
+
started = time.perf_counter()
|
| 297 |
+
|
| 298 |
+
reference = Image.open(image_path)
|
| 299 |
+
frames = read_frames(video_path)[:num_frames] # already at the generation size
|
| 300 |
+
face_frames = read_frames(face_path)[: len(frames)] if face_path else None # the same frames, sharper
|
| 301 |
+
if face_frames is not None and len(face_frames) < len(frames):
|
| 302 |
+
face_frames = None
|
| 303 |
+
height, width = frames[0].shape[:2]
|
| 304 |
+
pil_frames = [Image.fromarray(f) for f in frames]
|
| 305 |
+
lap("load inputs")
|
| 306 |
+
|
| 307 |
+
# The AI looks at the photo and a few frames: the action (for the prompt) and what the person holds or has at
|
| 308 |
+
# the mouth (kept from the original video like clicked objects, after a check that it is not the person).
|
| 309 |
+
ai = {"action": "", "objects": [], "prompt": ""}
|
| 310 |
+
person_box = masker.person_box(pil_frames[0], click)
|
| 311 |
+
if use_ai and reader is not None and person_box is not None:
|
| 312 |
+
picks = np.linspace(0, len(frames) - 1, num=min(4, len(frames))).round().astype(int)
|
| 313 |
+
try:
|
| 314 |
+
ai = reader(reference, [pil_frames[i] for i in picks], person_box, DEVICE)
|
| 315 |
+
print("Scene:", {k: v for k, v in ai.items() if k != "raw"})
|
| 316 |
+
except Exception as e: # noqa: BLE001 - the replacement works without it
|
| 317 |
+
print(f"Scene reading failed: {e!r}")
|
| 318 |
+
lap("scene")
|
| 319 |
+
found = [{"box": o["box"], "name": o["name"], "check": True}
|
| 320 |
+
for o in ai["objects"] if near(o["box"], person_box)][:MAX_OBJECTS]
|
| 321 |
+
clicked = [{"point": xy, "name": "clicked object"} for xy in objects]
|
| 322 |
+
|
| 323 |
+
try:
|
| 324 |
+
# The person's outline on every frame, and the objects to keep (minus from the person).
|
| 325 |
+
person, things, box, kept = masker.video_masks(pil_frames, click, clicked + found)
|
| 326 |
+
ref_mask = masker.image_mask(reference) # uses the alpha channel when the photo is a cut-out
|
| 327 |
+
# The character alone, on the video's own scene: no trace of the photo's room, and no flat black around
|
| 328 |
+
# her (which comes back as a dark outline).
|
| 329 |
+
ref_image = place_character(flatten(reference), ref_mask, scene_backdrop(frames[0], person[0]))
|
| 330 |
+
except ValueError as e:
|
| 331 |
+
raise gr.Error(str(e)) from e
|
| 332 |
+
if person.mean() < 1e-4:
|
| 333 |
+
raise gr.Error("The person to replace was lost on every frame: click on them on the first frame.")
|
| 334 |
+
lap("masks")
|
| 335 |
+
|
| 336 |
+
skeletons, faces, _ = poser(frames, person, face_frames) # the original person's pose and face, frame by frame
|
| 337 |
+
del face_frames
|
| 338 |
+
lap("pose")
|
| 339 |
+
|
| 340 |
+
side = min(height, width)
|
| 341 |
+
grow = max(2, round(MASK_GROWTH * side) + int(mask_edge))
|
| 342 |
+
region = adjust(person, grow, masker.device) # what Wan-Animate redraws; everything else is given to it
|
| 343 |
+
block = MASK_SHAPES[mask_shape]
|
| 344 |
+
if block is None:
|
| 345 |
+
region = blocky(region, 1, 1)
|
| 346 |
+
else: # whole 16-px patches only, so no latent is half person, half kept background
|
| 347 |
+
region = blockify(region, max(16, round(block * side / 480 / 16) * 16), masker.device)
|
| 348 |
+
if things.any(): # latents that are mostly a clicked object stay as they are: the model draws around it
|
| 349 |
+
region &= ~mostly(things, 8, 0.5, masker.device)
|
| 350 |
+
scene = [Image.fromarray(np.where(r[..., None], 0, f)) for f, r in zip(frames, region)]
|
| 351 |
+
region_images = [Image.fromarray(r.astype(np.uint8) * 255) for r in region]
|
| 352 |
+
|
| 353 |
+
set_sampler(pipe, sampler, float(shift))
|
| 354 |
+
set_face_strength(pipe, face_strength)
|
| 355 |
+
torch.manual_seed(seed) # the SDE sampler draws fresh noise at every step
|
| 356 |
+
try:
|
| 357 |
+
video = pipe(
|
| 358 |
+
image=ref_image,
|
| 359 |
+
pose_video=[Image.fromarray(s) for s in skeletons],
|
| 360 |
+
face_video=[Image.fromarray(f) for f in faces],
|
| 361 |
+
background_video=scene,
|
| 362 |
+
mask_video=region_images,
|
| 363 |
+
prompt=prompt or ai["prompt"] or DEFAULT_PROMPT,
|
| 364 |
+
height=height,
|
| 365 |
+
width=width,
|
| 366 |
+
segment_frame_length=SEGMENT_FRAMES,
|
| 367 |
+
prev_segment_conditioning_frames=PREV_SEGMENT_FRAMES,
|
| 368 |
+
num_inference_steps=steps,
|
| 369 |
+
guidance_scale=1.0,
|
| 370 |
+
mode="replace",
|
| 371 |
+
generator=torch.Generator("cpu").manual_seed(seed),
|
| 372 |
+
output_type="pt",
|
| 373 |
+
).frames[0] # [T, 3, H, W] in [0, 1]
|
| 374 |
+
except torch.OutOfMemoryError as e:
|
| 375 |
+
raise gr.Error("Out of GPU memory: pick 480p or a shorter clip.") from e
|
| 376 |
+
lap("generate")
|
| 377 |
+
|
| 378 |
+
device = video.device
|
| 379 |
+
original = torch.from_numpy(np.stack(frames)).to(device).permute(0, 3, 1, 2).float() / 255
|
| 380 |
+
alpha = None
|
| 381 |
+
if keep_background: # paste the generated person over the untouched source frames
|
| 382 |
+
# Around the person only (fading out within the margin the region adds), so the blocks of redrawn
|
| 383 |
+
# background are not used; with the box, everything up to near its edge.
|
| 384 |
+
feather = max(2, grow // 2)
|
| 385 |
+
inner = person if block is not None else adjust(region, -2 * feather, masker.device)
|
| 386 |
+
alpha = feathered(inner, grow=feather, device=device)
|
| 387 |
+
if things.any(): # the clicked objects stay the original ones, with what touches them (lips, tongue, fingers)
|
| 388 |
+
front = feathered(adjust(things, int(contact), masker.device), grow=1, device=device)
|
| 389 |
+
alpha = 1 - front if alpha is None else alpha * (1 - front)
|
| 390 |
+
del front
|
| 391 |
+
if alpha is not None:
|
| 392 |
+
video = video.float() * alpha + original * (1 - alpha)
|
| 393 |
+
del alpha
|
| 394 |
+
result = (video.clamp(0, 1) * 255).round().to(torch.uint8).permute(0, 2, 3, 1).cpu().numpy()
|
| 395 |
+
out_path = write_video(result, fps, audio_path)
|
| 396 |
+
|
| 397 |
+
preview = np.stack(frames).copy()
|
| 398 |
+
preview[region] = preview[region] // 2 + np.array([0, 0, 127], np.uint8)
|
| 399 |
+
preview[things] = preview[things] // 2 + np.array([0, 127, 0], np.uint8)
|
| 400 |
+
drawn = np.stack(skeletons)
|
| 401 |
+
preview = np.where(drawn.any(-1, keepdims=True), drawn, preview)
|
| 402 |
+
preview_path = write_video(preview, fps)
|
| 403 |
+
face_path = write_video(np.stack([np.asarray(Image.fromarray(f).resize((256, 256))) for f in faces]), fps)
|
| 404 |
+
ref_path = temp_path(".png")
|
| 405 |
+
ref_image.save(ref_path)
|
| 406 |
+
lap("encode mp4")
|
| 407 |
+
|
| 408 |
+
vram = torch.cuda.max_memory_allocated() / 2**30 if torch.cuda.is_available() else 0.0
|
| 409 |
+
info = (
|
| 410 |
+
f"**{width // 2 * 2}×{height // 2 * 2}**, {len(frames)} frames @ {fps} fps, "
|
| 411 |
+
f"{segment_count(len(frames))} window(s), {steps} steps, seed `{seed}`, expression {face_strength:g} · "
|
| 412 |
+
f"peak VRAM {vram:.1f} GB \n"
|
| 413 |
+
+ " · ".join(f"{name} {seconds:.1f}s" for name, seconds in timings.items())
|
| 414 |
+
)
|
| 415 |
+
if ai["action"]:
|
| 416 |
+
info += f" \n**AI:** {ai['action']}"
|
| 417 |
+
if kept:
|
| 418 |
+
info += " \n**Kept from the original:** " + ", ".join(o["name"] for o in kept)
|
| 419 |
+
info += f" \n**Prompt:** {prompt or ai['prompt'] or DEFAULT_PROMPT}"
|
| 420 |
+
print(info.replace("**", "").replace(" \n", " | "), "| person box:", box)
|
| 421 |
+
return out_path, preview_path, face_path, ref_path, info
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 425 |
+
# UI
|
| 426 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def replace(image_path, video_path, prompt, quality, fps, max_seconds, steps, shift, sampler, seed, keep_background,
|
| 430 |
+
keep_audio, mask_edge, mask_shape, contact, face_strength, use_ai, clicks,
|
| 431 |
+
progress=gr.Progress(track_tqdm=True)):
|
| 432 |
+
if not image_path:
|
| 433 |
+
raise gr.Error("Upload a photo of the new character.")
|
| 434 |
+
if not video_path:
|
| 435 |
+
raise gr.Error("Upload the video.")
|
| 436 |
+
prompt = (prompt or "").strip()
|
| 437 |
+
fps = int(fps)
|
| 438 |
+
image_path = prepare_image(image_path)
|
| 439 |
+
source_size = video_metadata(video_path)["size"]
|
| 440 |
+
height, width = frame_size(*source_size, quality)
|
| 441 |
+
frames_path, num_frames, audio_path = prepare_driving_video(video_path, float(max_seconds), fps, width, height)
|
| 442 |
+
if num_frames < 2:
|
| 443 |
+
raise gr.Error("Could not read at least two frames from the video.")
|
| 444 |
+
# Up to 2x sharper frames for the face crops, never upscaled beyond the source.
|
| 445 |
+
face_factor = min(2.0, 1 / max(width / source_size[0], height / source_size[1]))
|
| 446 |
+
face_path = None
|
| 447 |
+
if face_factor > 1.2:
|
| 448 |
+
face_w, face_h = round(width * face_factor / 2) * 2, round(height * face_factor / 2) * 2
|
| 449 |
+
face_path = prepare_driving_video(video_path, float(max_seconds), fps, face_w, face_h, audio=False)[0]
|
| 450 |
+
clicks = clicks or {}
|
| 451 |
+
click = None if clicks.get("person") is None else map_click(clicks["person"], source_size, width, height)
|
| 452 |
+
objects = [map_click(p, source_size, width, height) for p in clicks.get("objects", [])]
|
| 453 |
+
expected = estimate_gpu_seconds(num_frames, height, width, int(steps), scene=bool(use_ai))
|
| 454 |
+
if 1.2 * expected + 10 > MAX_GPU_SECONDS:
|
| 455 |
+
raise gr.Error(
|
| 456 |
+
f"This needs ≈{expected / 60:.0f} min of GPU in one call (limit {MAX_GPU_SECONDS / 60:.0f} min): "
|
| 457 |
+
"use fewer seconds, a lower FPS or 480p."
|
| 458 |
+
)
|
| 459 |
+
seed = random.randint(0, 2**31 - 1) if seed is None or int(seed) < 0 else int(seed)
|
| 460 |
+
return replace_on_gpu(
|
| 461 |
+
image_path, frames_path, face_path, num_frames, fps, prompt, int(steps), float(shift), sampler, seed,
|
| 462 |
+
bool(keep_background), click, objects, int(mask_edge), mask_shape, int(contact), float(face_strength),
|
| 463 |
+
bool(use_ai), audio_path if keep_audio else None, progress,
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def describe_cost(image_path, video_path, quality, fps, max_seconds, steps, use_ai=True):
|
| 468 |
+
if not image_path or not video_path:
|
| 469 |
+
return "Upload a photo and a video to see the expected GPU time."
|
| 470 |
+
try:
|
| 471 |
+
meta = video_metadata(video_path)
|
| 472 |
+
height, width = frame_size(*meta["size"], quality)
|
| 473 |
+
seconds = min(float(max_seconds), float(meta.get("duration") or max_seconds))
|
| 474 |
+
num_frames = max(2, int(seconds * int(fps)))
|
| 475 |
+
gpu = estimate_gpu_seconds(num_frames, height, width, int(steps), scene=bool(use_ai))
|
| 476 |
+
except Exception as e: # noqa: BLE001
|
| 477 |
+
return f"Could not estimate the cost: {e}"
|
| 478 |
+
quota = gpu * (2 if GPU_SIZE == "xlarge" else 1)
|
| 479 |
+
return (
|
| 480 |
+
f"Output **{width}×{height}**, {num_frames} frames ({num_frames / int(fps):.1f} s at {fps} fps) → "
|
| 481 |
+
f"{segment_count(num_frames)} window(s) of {SEGMENT_FRAMES} frames. Expected GPU time ≈ **{gpu / 60:.1f} min** "
|
| 482 |
+
f"(≈ {quota / 60:.1f} min of ZeroGPU quota on `{GPU_SIZE}`)."
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def show_first_frame(video_path):
|
| 487 |
+
if not video_path:
|
| 488 |
+
return None, None, None
|
| 489 |
+
frame = first_frame(video_path)
|
| 490 |
+
return frame, frame, None
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
CLICK_MODES = ["The person to replace", "An object to keep (lollipop, cup, phone…)"]
|
| 494 |
+
MAX_OBJECTS = 6
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def draw_clicks(frame, clicks):
|
| 498 |
+
"""The person's click in red, the objects to keep in green."""
|
| 499 |
+
if frame is None or not clicks:
|
| 500 |
+
return frame
|
| 501 |
+
image = Image.fromarray(frame)
|
| 502 |
+
draw = ImageDraw.Draw(image)
|
| 503 |
+
r = max(6, image.width // 80)
|
| 504 |
+
marks = [(clicks.get("person"), (255, 40, 40))] + [(p, (40, 220, 60)) for p in clicks.get("objects", [])]
|
| 505 |
+
for point, color in marks:
|
| 506 |
+
if point is not None:
|
| 507 |
+
x, y = point[0] * image.width, point[1] * image.height
|
| 508 |
+
draw.ellipse([x - r, y - r, x + r, y + r], fill=color, outline=(255, 255, 255), width=max(2, r // 3))
|
| 509 |
+
return np.asarray(image)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def select_point(frame, mode, clicks, evt: gr.SelectData):
|
| 513 |
+
if frame is None:
|
| 514 |
+
return None, None
|
| 515 |
+
point = [evt.index[0] / frame.shape[1], evt.index[1] / frame.shape[0]]
|
| 516 |
+
clicks = {"person": (clicks or {}).get("person"), "objects": list((clicks or {}).get("objects", []))}
|
| 517 |
+
if mode == CLICK_MODES[0]:
|
| 518 |
+
clicks["person"] = point
|
| 519 |
+
else:
|
| 520 |
+
clicks["objects"] = (clicks["objects"] + [point])[-MAX_OBJECTS:]
|
| 521 |
+
return clicks, draw_clicks(frame, clicks)
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def tolerate_empty(slider: gr.Slider) -> gr.Slider:
|
| 525 |
+
"""Lets the slider's number box be cleared: an empty value falls back to the initial one.
|
| 526 |
+
|
| 527 |
+
(Patched on the instance: subclassing a Gradio component makes Gradio write an `app.pyi` stub next to app.py.)
|
| 528 |
+
"""
|
| 529 |
+
preprocess = slider.preprocess
|
| 530 |
+
slider.preprocess = lambda payload: preprocess(slider.value if payload is None else payload)
|
| 531 |
+
return slider
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def fetch_examples(time_budget: float = 20.0) -> list[list[str]]:
|
| 535 |
+
"""Wan-Animate's official replacement example (Apache-2.0), downloaded once. Never holds up the startup for
|
| 536 |
+
longer than `time_budget`: a stalled download keeps going in the background and the example appears after a
|
| 537 |
+
restart."""
|
| 538 |
+
folder = Path(__file__).parent / "examples"
|
| 539 |
+
base = "https://raw.githubusercontent.com/Wan-Video/Wan2.2/main/examples/wan_animate/replace"
|
| 540 |
+
files = {"wan_replace_image.jpeg": "image.jpeg", "wan_replace_video.mp4": "video.mp4"}
|
| 541 |
+
|
| 542 |
+
def download():
|
| 543 |
+
try:
|
| 544 |
+
context = ssl.create_default_context(cafile=certifi.where())
|
| 545 |
+
folder.mkdir(exist_ok=True)
|
| 546 |
+
for local, remote in files.items():
|
| 547 |
+
target = folder / local
|
| 548 |
+
if not target.exists():
|
| 549 |
+
with urllib.request.urlopen(f"{base}/{remote}", context=context, timeout=15) as r:
|
| 550 |
+
data = r.read()
|
| 551 |
+
partial = target.with_name(target.name + ".part")
|
| 552 |
+
partial.write_bytes(data)
|
| 553 |
+
partial.replace(target)
|
| 554 |
+
except Exception as e: # noqa: BLE001 - examples are optional
|
| 555 |
+
print(f"Examples unavailable: {e!r}")
|
| 556 |
+
|
| 557 |
+
worker = threading.Thread(target=download, daemon=True)
|
| 558 |
+
worker.start()
|
| 559 |
+
worker.join(time_budget)
|
| 560 |
+
if all((folder / name).exists() for name in files):
|
| 561 |
+
return [[str(folder / name) for name in files]]
|
| 562 |
+
print("Examples skipped: the download did not finish in time")
|
| 563 |
+
return []
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
HEADER = f"""
|
| 567 |
+
# Character replacement · Wan2.2-Animate
|
| 568 |
+
Upload a **photo of the new character** and a **video**: the person in the video is replaced by the character from the
|
| 569 |
+
photo. The new character takes the original person's **skeleton** (body proportions, position, every movement) and
|
| 570 |
+
**face** (expressions, lips, head turns) frame by frame; only that person's region is redrawn, the rest of the video
|
| 571 |
+
stays as it is. The character is cut out of the photo automatically (a PNG with transparency is used as is).
|
| 572 |
+
An AI (Qwen3.5) looks at the video first: it writes what the person does into the prompt and finds what they hold
|
| 573 |
+
or have at the mouth (a lollipop, a cup, a phone), which then stays the original one, together with the lips or
|
| 574 |
+
fingers touching it. Things it misses can be clicked on the first frame with "An object to keep".
|
| 575 |
+
Model: [Wan2.2-Animate-14B](https://github.com/Wan-Video/Wan2.2) replacement mode + Relighting LoRA{
|
| 576 |
+
" + Lightx2v (6 steps)" if DISTILLED else ""}; masks by SAM 2.1 + Mask2Former, pose by ViTPose-H.
|
| 577 |
+
"""
|
| 578 |
+
|
| 579 |
+
CSS = """
|
| 580 |
+
#col-container { max-width: 1400px; margin: 0 auto; }
|
| 581 |
+
#run-button { min-height: 52px; font-size: 18px; }
|
| 582 |
+
"""
|
| 583 |
+
|
| 584 |
+
with gr.Blocks(title="Wan-Animate Character Replacement") as demo:
|
| 585 |
+
point_state = gr.State(None)
|
| 586 |
+
frame_state = gr.State(None)
|
| 587 |
+
with gr.Column(elem_id="col-container"):
|
| 588 |
+
gr.Markdown(HEADER)
|
| 589 |
+
with gr.Row(equal_height=True):
|
| 590 |
+
ref_image = gr.Image(label="1 · Photo of the new character", type="filepath", height=420,
|
| 591 |
+
image_mode=None)
|
| 592 |
+
driving_video = gr.Video(label="2 · Video with the person to replace", height=420, sources=["upload"])
|
| 593 |
+
output_video = gr.Video(label="Result", height=420, autoplay=True, interactive=False)
|
| 594 |
+
with gr.Row():
|
| 595 |
+
with gr.Column(scale=1):
|
| 596 |
+
picker = gr.Image(label="3 · Optional: click the person to replace (default: the largest one) "
|
| 597 |
+
"and things they hold that must stay as they are", type="numpy",
|
| 598 |
+
interactive=False, height=300)
|
| 599 |
+
click_mode = gr.Radio(CLICK_MODES, value=CLICK_MODES[0], label="A click marks")
|
| 600 |
+
use_ai = gr.Checkbox(
|
| 601 |
+
value=reader is not None, interactive=reader is not None,
|
| 602 |
+
label=f"AI scene reading ({SCENE_MODEL.split('/')[-1]}): writes the action into the prompt and "
|
| 603 |
+
"finds what the person holds or has at the mouth",
|
| 604 |
+
)
|
| 605 |
+
clear_click = gr.Button("Clear the clicks", size="sm")
|
| 606 |
+
with gr.Column(scale=2):
|
| 607 |
+
with gr.Row():
|
| 608 |
+
quality = gr.Radio(list(QUALITIES), value=DEFAULT_QUALITY, label="Resolution")
|
| 609 |
+
fps = gr.Radio(FPS_CHOICES, value=DEFAULT_FPS, label="FPS (30 = smoothest; lower = longer clips)")
|
| 610 |
+
max_seconds = tolerate_empty(
|
| 611 |
+
gr.Slider(1, MAX_SECONDS, value=5, step=0.5, label="Use the first N seconds of the video")
|
| 612 |
+
)
|
| 613 |
+
keep_background = gr.Checkbox(
|
| 614 |
+
value=True, label="Keep the original pixels outside the person (scene and objects stay exact)"
|
| 615 |
+
)
|
| 616 |
+
mask_edge = tolerate_empty(
|
| 617 |
+
gr.Slider(-6, 16, value=0, step=1, label="Mask margin, px: − tighter / + looser (if hair or "
|
| 618 |
+
"clothes of the new character get cut)")
|
| 619 |
+
)
|
| 620 |
+
with gr.Accordion("Advanced", open=False):
|
| 621 |
+
mask_shape = gr.Radio(list(MASK_SHAPES), value=DEFAULT_MASK_SHAPE, label="Redrawn region")
|
| 622 |
+
prompt = gr.Textbox(
|
| 623 |
+
label="Prompt (optional)", lines=2,
|
| 624 |
+
placeholder="Empty = written by the AI scene reading (the character from the photo doing what the "
|
| 625 |
+
"person in the video does); with it off, the official prompt.",
|
| 626 |
+
)
|
| 627 |
+
with gr.Row():
|
| 628 |
+
sampler = gr.Radio(list(SAMPLERS), value=DEFAULT_SAMPLER, label="Sampler")
|
| 629 |
+
steps = tolerate_empty(gr.Slider(4, 40, value=DEFAULT_STEPS, step=1, label="Steps"))
|
| 630 |
+
shift = tolerate_empty(gr.Slider(1.0, 10.0, value=DEFAULT_SHIFT, step=0.5, label="Sampling shift"))
|
| 631 |
+
seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
|
| 632 |
+
keep_audio = gr.Checkbox(value=True, label="Keep the video's audio")
|
| 633 |
+
with gr.Row():
|
| 634 |
+
face_strength = tolerate_empty(gr.Slider(
|
| 635 |
+
0.5, 2.0, value=1.0, step=0.05,
|
| 636 |
+
label="Expression strength: > 1 = the mouth, eyes and brows follow the video more strongly",
|
| 637 |
+
))
|
| 638 |
+
contact = tolerate_empty(gr.Slider(
|
| 639 |
+
0, 24, value=4, step=1,
|
| 640 |
+
label="Original pixels kept around the marked objects, px (lips, tongue or fingers on them)",
|
| 641 |
+
))
|
| 642 |
+
cost = gr.Markdown(describe_cost(None, None, DEFAULT_QUALITY, DEFAULT_FPS, 5, DEFAULT_STEPS))
|
| 643 |
+
run = gr.Button("Replace the person", variant="primary", elem_id="run-button")
|
| 644 |
+
info = gr.Markdown()
|
| 645 |
+
with gr.Accordion("What the model got: skeleton and redrawn region, face, cut-out character", open=False):
|
| 646 |
+
with gr.Row():
|
| 647 |
+
preview_video = gr.Video(label="Skeleton, redrawn region (blue), kept objects (green)", height=300,
|
| 648 |
+
interactive=False)
|
| 649 |
+
face_video = gr.Video(label="Face (expressions)", height=300, interactive=False)
|
| 650 |
+
ref_preview = gr.Image(label="Character cut out of the photo", height=300, interactive=False)
|
| 651 |
+
examples = fetch_examples()
|
| 652 |
+
if examples:
|
| 653 |
+
gr.Examples(examples=examples, inputs=[ref_image, driving_video], label="Wan-Animate example")
|
| 654 |
+
|
| 655 |
+
cost_inputs = [ref_image, driving_video, quality, fps, max_seconds, steps, use_ai]
|
| 656 |
+
for component in cost_inputs:
|
| 657 |
+
component.change(describe_cost, cost_inputs, cost, show_progress="hidden", api_visibility="private")
|
| 658 |
+
driving_video.change(show_first_frame, driving_video, [picker, frame_state, point_state], api_visibility="private")
|
| 659 |
+
picker.select(select_point, [frame_state, click_mode, point_state], [point_state, picker],
|
| 660 |
+
api_visibility="private")
|
| 661 |
+
clear_click.click(lambda f: (None, f), frame_state, [point_state, picker], api_visibility="private")
|
| 662 |
+
run.click(
|
| 663 |
+
replace,
|
| 664 |
+
[ref_image, driving_video, prompt, quality, fps, max_seconds, steps, shift, sampler, seed, keep_background,
|
| 665 |
+
keep_audio, mask_edge, mask_shape, contact, face_strength, use_ai, point_state],
|
| 666 |
+
[output_video, preview_video, face_video, ref_preview, info],
|
| 667 |
+
api_name="replace",
|
| 668 |
+
concurrency_limit=CONCURRENCY,
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
if __name__ == "__main__":
|
| 673 |
+
demo.queue(max_size=30).launch(theme=gr.themes.Soft(), css=CSS, ssr_mode=False)
|
captioner.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reads the source video with a local vision-language model (Qwen3.5-4B) before the replacement:
|
| 2 |
+
|
| 3 |
+
* what the person to replace is doing and how (e.g. "licking a red lollipop with her tongue, holding its stick"),
|
| 4 |
+
* the things they hold or touch (with boxes on the first frame), which are then tracked and kept from the original
|
| 5 |
+
video instead of being redrawn,
|
| 6 |
+
* an English prompt for Wan-Animate in which the character from the photo does exactly that.
|
| 7 |
+
|
| 8 |
+
Wan-Animate's motion comes from the skeleton and the face crops, which carry neither a tongue on a lollipop nor a
|
| 9 |
+
cup at the lips; the prompt and the kept objects fill that gap.
|
| 10 |
+
|
| 11 |
+
Two separate questions: the photo alone (the new character's looks) and the video frames alone (action, objects,
|
| 12 |
+
scene). The prompt is put together from both answers, so the original person's looks cannot end up in it.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import itertools
|
| 18 |
+
import json
|
| 19 |
+
import re
|
| 20 |
+
from contextlib import contextmanager
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from PIL import Image, ImageDraw
|
| 24 |
+
|
| 25 |
+
APPEARANCE = """Describe only how the person in this photo looks, in one English sentence of at most 35 words: gender, approximate age, hair (colour, length, style), face and the visible clothing. Do not mention the pose, what they do, the background or the photo. Start with "A" (for example "A young woman with long wavy blonde hair and a pink top"). Output only the sentence."""
|
| 26 |
+
|
| 27 |
+
VIDEO = """These are frames of a video in chronological order. In the first frame, the person to follow is marked with a red box.
|
| 28 |
+
|
| 29 |
+
Answer with one JSON object and nothing else:
|
| 30 |
+
{
|
| 31 |
+
"action": "what the marked person does, in a few words (e.g. licks a lollipop, drinks from a cup, pets a snake)",
|
| 32 |
+
"motion": "one English sentence on exactly how they do it across the frames: the hands, mouth, tongue, head and body, and the objects involved. Start with a verb ending in -ing, e.g. sitting on a sofa, licking a big red lollipop with the tongue while holding its stick in the right hand",
|
| 33 |
+
"scene": "one short English sentence about the place, the light and the camera",
|
| 34 |
+
"objects": [{"name": "...", "box": [x0, y0, x1, y1]}]
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
"objects": every thing the marked person holds in the hands, has at or in the mouth, or plays with (a lollipop, a cup, a phone, a snake, a guitar), with its box in the FIRST frame as integers from 0 to 1000 relative to that frame's width and height. Do not list clothing, hair, glasses, jewellery, furniture or the background. Use [] if there is none.
|
| 38 |
+
Never describe the marked person's looks (gender, age, face, hair, clothing) and never name them: "motion" only says what is done, e.g. "sitting down, playing the violin with the bow in the right hand"."""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@contextmanager
|
| 42 |
+
def visiting(module: torch.nn.Module, device: str):
|
| 43 |
+
"""Move `module` to `device` for the duration of the block, then point it back at its original CPU storage.
|
| 44 |
+
|
| 45 |
+
Restoring the original tensors (instead of copying back with `.to("cpu")`) keeps the weights shared with the
|
| 46 |
+
parent process, so ZeroGPU's forked workers do not each end up with a private CPU copy.
|
| 47 |
+
"""
|
| 48 |
+
original = {name: t.data for name, t in itertools.chain(module.named_parameters(), module.named_buffers())}
|
| 49 |
+
module.to(device)
|
| 50 |
+
try:
|
| 51 |
+
yield module
|
| 52 |
+
finally:
|
| 53 |
+
for name, t in itertools.chain(module.named_parameters(), module.named_buffers()):
|
| 54 |
+
t.data = original[name]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def mark(frame: Image.Image, box, width: int = 4) -> Image.Image:
|
| 58 |
+
frame = frame.convert("RGB").copy()
|
| 59 |
+
if box is not None:
|
| 60 |
+
ImageDraw.Draw(frame).rectangle([round(v) for v in box], outline=(255, 0, 0), width=width)
|
| 61 |
+
return frame
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def clean(text: str) -> str:
|
| 65 |
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
|
| 66 |
+
return re.sub(r"\s+", " ", text.replace("*", "").replace("#", "")).strip().strip('"').strip()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def parse_video(text: str, width: int, height: int) -> dict:
|
| 70 |
+
"""The JSON answer about the video, with object boxes in pixels of the first frame; empty fields if unusable."""
|
| 71 |
+
match = re.search(r"\{.*\}", re.sub(r"<think>.*?</think>", "", text, flags=re.S), flags=re.S)
|
| 72 |
+
try:
|
| 73 |
+
data = json.loads(match.group(0)) if match else {}
|
| 74 |
+
except json.JSONDecodeError:
|
| 75 |
+
data = {}
|
| 76 |
+
if not isinstance(data, dict):
|
| 77 |
+
data = {}
|
| 78 |
+
out = {key: clean(str(data.get(key) or "")) for key in ("action", "motion", "scene")}
|
| 79 |
+
out["objects"] = []
|
| 80 |
+
for obj in data.get("objects") or []:
|
| 81 |
+
try:
|
| 82 |
+
x0, y0, x1, y1 = (float(v) for v in obj["box"])
|
| 83 |
+
except (KeyError, TypeError, ValueError):
|
| 84 |
+
continue
|
| 85 |
+
x0, x1 = sorted((min(max(x0, 0), 1000), min(max(x1, 0), 1000)))
|
| 86 |
+
y0, y1 = sorted((min(max(y0, 0), 1000), min(max(y1, 0), 1000)))
|
| 87 |
+
if x1 - x0 < 2 or y1 - y0 < 2:
|
| 88 |
+
continue
|
| 89 |
+
box = [x0 * width / 1000, y0 * height / 1000, x1 * width / 1000, y1 * height / 1000]
|
| 90 |
+
out["objects"].append({"name": clean(str(obj.get("name") or "object")), "box": box})
|
| 91 |
+
return out
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def compose(appearance: str, motion: str, scene: str) -> str:
|
| 95 |
+
""""A young woman with ..., licking a lollipop .... The room is ..." from the two answers."""
|
| 96 |
+
appearance = appearance.rstrip(". ")
|
| 97 |
+
motion = motion.rstrip(". ")
|
| 98 |
+
if appearance and motion:
|
| 99 |
+
continues = motion.split()[0].lower().endswith("ing") # "..., licking a lollipop" reads on
|
| 100 |
+
text = f"{appearance}, {motion[0].lower() + motion[1:]}." if continues else f"{appearance}. {motion}."
|
| 101 |
+
elif appearance or motion:
|
| 102 |
+
text = (appearance or motion)[0].upper() + (appearance or motion)[1:] + "."
|
| 103 |
+
else:
|
| 104 |
+
text = ""
|
| 105 |
+
if scene:
|
| 106 |
+
text = f"{text} {scene.rstrip('.')}.".strip()
|
| 107 |
+
return text
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class SceneReader:
|
| 111 |
+
def __init__(self, model_id: str):
|
| 112 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 113 |
+
|
| 114 |
+
self.processor = AutoProcessor.from_pretrained(model_id)
|
| 115 |
+
self.model = AutoModelForImageTextToText.from_pretrained(model_id, dtype=torch.bfloat16).eval()
|
| 116 |
+
|
| 117 |
+
def _ask(self, images: list[Image.Image], question: str, max_new_tokens: int, device: str) -> str:
|
| 118 |
+
messages = [{"role": "user", "content": [
|
| 119 |
+
*({"type": "image", "image": im} for im in images), {"type": "text", "text": question},
|
| 120 |
+
]}]
|
| 121 |
+
inputs = self.processor.apply_chat_template(
|
| 122 |
+
messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt",
|
| 123 |
+
enable_thinking=False,
|
| 124 |
+
).to(device)
|
| 125 |
+
output = self.model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False,
|
| 126 |
+
repetition_penalty=1.05)
|
| 127 |
+
return self.processor.decode(output[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 128 |
+
|
| 129 |
+
@torch.no_grad()
|
| 130 |
+
def __call__(self, reference: Image.Image, frames: list[Image.Image], person_box, device: str) -> dict:
|
| 131 |
+
"""`frames`: a few video frames in order, the person to replace in `person_box` on the first one.
|
| 132 |
+
Returns action, motion, scene, objects (boxes in first-frame pixels), prompt and the raw answers."""
|
| 133 |
+
width, height = frames[0].size
|
| 134 |
+
|
| 135 |
+
def fit(image: Image.Image, side: int) -> Image.Image:
|
| 136 |
+
image = image.convert("RGB").copy()
|
| 137 |
+
image.thumbnail((side, side), Image.Resampling.LANCZOS)
|
| 138 |
+
return image
|
| 139 |
+
|
| 140 |
+
video = [fit(mark(frames[0], person_box), 1024), *(fit(f, 640) for f in frames[1:])]
|
| 141 |
+
with visiting(self.model, device):
|
| 142 |
+
looks = self._ask([fit(reference, 768)], APPEARANCE, 80, device)
|
| 143 |
+
answer = self._ask(video, VIDEO, 360, device)
|
| 144 |
+
result = parse_video(answer, width, height)
|
| 145 |
+
appearance = clean(looks)
|
| 146 |
+
result["prompt"] = compose(appearance, result["motion"], result["scene"])
|
| 147 |
+
result["raw"] = {"appearance": looks, "video": answer}
|
| 148 |
+
return result
|
masking.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Person masks for Wan-Animate replacement.
|
| 2 |
+
|
| 3 |
+
The official preprocessing prompts SAM 2 with pose keypoints. Here RT-DETRv2 finds the person on the first frame (or
|
| 4 |
+
the one that was clicked), SAM 2.1 tracks it through the video, and a Mask2Former "person" segmentation removes
|
| 5 |
+
whatever SAM 2.1 glued to it (the chair under it, the wall or room behind it). All three are ungated.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
from PIL import Image, ImageFilter
|
| 17 |
+
from transformers import (
|
| 18 |
+
AutoImageProcessor,
|
| 19 |
+
Mask2FormerForUniversalSegmentation,
|
| 20 |
+
RTDetrV2ForObjectDetection,
|
| 21 |
+
Sam2VideoModel,
|
| 22 |
+
Sam2VideoProcessor,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
DETECTOR = os.getenv("WAN_DETECTOR", "PekingU/rtdetr_v2_r50vd")
|
| 26 |
+
SEGMENTER = os.getenv("WAN_SEGMENTER", "facebook/sam2.1-hiera-large")
|
| 27 |
+
PERSON_PARSER = os.getenv("WAN_PERSON_PARSER", "facebook/mask2former-swin-base-coco-panoptic") # "none" = off
|
| 28 |
+
# Below this share of SAM 2.1's mask being "person" pixels, the subject is taken to be non-human (a cartoon, an
|
| 29 |
+
# animal) and the person refinement is skipped rather than erasing it.
|
| 30 |
+
MIN_PERSON_SHARE = 0.4
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _area(box):
|
| 34 |
+
return max(0.0, box[2] - box[0]) * max(0.0, box[3] - box[1])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _person_label(config) -> int:
|
| 38 |
+
return next(i for i, name in config.id2label.items() if name == "person")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PersonMasker:
|
| 42 |
+
def __init__(self, detector_id: str = DETECTOR, segmenter_id: str = SEGMENTER, parser_id: str = PERSON_PARSER):
|
| 43 |
+
self.det_processor = AutoImageProcessor.from_pretrained(detector_id)
|
| 44 |
+
self.detector = RTDetrV2ForObjectDetection.from_pretrained(detector_id).eval()
|
| 45 |
+
self.person_label = _person_label(self.detector.config)
|
| 46 |
+
self.sam_processor = Sam2VideoProcessor.from_pretrained(segmenter_id)
|
| 47 |
+
self.sam = Sam2VideoModel.from_pretrained(segmenter_id).eval()
|
| 48 |
+
self.parser = None
|
| 49 |
+
if parser_id.lower() != "none":
|
| 50 |
+
self.parser_processor = AutoImageProcessor.from_pretrained(parser_id)
|
| 51 |
+
self.parser = Mask2FormerForUniversalSegmentation.from_pretrained(parser_id).eval()
|
| 52 |
+
self.parser_person = _person_label(self.parser.config)
|
| 53 |
+
|
| 54 |
+
def to(self, device):
|
| 55 |
+
for model in (self.detector, self.sam, self.parser):
|
| 56 |
+
if model is not None:
|
| 57 |
+
model.to(device)
|
| 58 |
+
return self
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def device(self) -> torch.device:
|
| 62 |
+
return next(self.sam.parameters()).device
|
| 63 |
+
|
| 64 |
+
@torch.no_grad()
|
| 65 |
+
def find_people(self, image: Image.Image, threshold: float = 0.4) -> list[tuple[list[float], float]]:
|
| 66 |
+
inputs = self.det_processor(images=image, return_tensors="pt").to(self.device)
|
| 67 |
+
result = self.det_processor.post_process_object_detection(
|
| 68 |
+
self.detector(**inputs), target_sizes=[(image.height, image.width)], threshold=threshold
|
| 69 |
+
)[0]
|
| 70 |
+
return [
|
| 71 |
+
(box.tolist(), float(score))
|
| 72 |
+
for box, score, label in zip(result["boxes"], result["scores"], result["labels"])
|
| 73 |
+
if int(label) == self.person_label
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def pick(people, point=None, size=None):
|
| 78 |
+
"""The clicked person (smallest box containing the click), else the largest confident, fully visible one.
|
| 79 |
+
|
| 80 |
+
Each side cut by the frame border halves a candidate's weight: passers-by crossing close to the camera are
|
| 81 |
+
big but cropped, the subject of the shot usually is not.
|
| 82 |
+
"""
|
| 83 |
+
if point is not None:
|
| 84 |
+
inside = [b for b, _ in people if b[0] <= point[0] <= b[2] and b[1] <= point[1] <= b[3]]
|
| 85 |
+
return min(inside, key=_area) if inside else None
|
| 86 |
+
if not people:
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
def weight(person):
|
| 90 |
+
box, score = person
|
| 91 |
+
cut = 0
|
| 92 |
+
if size is not None:
|
| 93 |
+
cut = sum([box[0] <= 2, box[1] <= 2, box[2] >= size[0] - 2, box[3] >= size[1] - 2])
|
| 94 |
+
return _area(box) * score * 0.5**cut
|
| 95 |
+
|
| 96 |
+
return max(people, key=weight)[0]
|
| 97 |
+
|
| 98 |
+
@torch.no_grad()
|
| 99 |
+
def track(self, frames: list[Image.Image], box=None, point=None, objects=()) -> tuple[np.ndarray, list]:
|
| 100 |
+
"""Masks [T, H, W] of the subject prompted on frame 0 by a box and/or a positive click, and one mask per
|
| 101 |
+
object of `objects` (dicts with a "point" [x, y] or a "box" [x0, y0, x1, y1] on frame 0), each tracked as a
|
| 102 |
+
separate SAM 2.1 object."""
|
| 103 |
+
if box is None and point is None:
|
| 104 |
+
raise ValueError("Need a box or a point on the first frame")
|
| 105 |
+
session = self.sam_processor.init_video_session(
|
| 106 |
+
video=frames, inference_device=self.device, video_storage_device="cpu", dtype=torch.float32
|
| 107 |
+
)
|
| 108 |
+
prompt = {}
|
| 109 |
+
if box is not None:
|
| 110 |
+
prompt["input_boxes"] = [[list(map(float, box))]]
|
| 111 |
+
if point is not None:
|
| 112 |
+
prompt["input_points"] = [[[list(map(float, point))]]]
|
| 113 |
+
prompt["input_labels"] = [[[1]]]
|
| 114 |
+
self.sam_processor.add_inputs_to_inference_session(
|
| 115 |
+
inference_session=session, frame_idx=0, obj_ids=1, **prompt
|
| 116 |
+
)
|
| 117 |
+
self.sam(inference_session=session, frame_idx=0)
|
| 118 |
+
for i, obj in enumerate(objects): # each prompt is run on frame 0 before the next is added
|
| 119 |
+
if "box" in obj:
|
| 120 |
+
hint = {"input_boxes": [[list(map(float, obj["box"]))]]}
|
| 121 |
+
else:
|
| 122 |
+
hint = {"input_points": [[[list(map(float, obj["point"]))]]], "input_labels": [[[1]]]}
|
| 123 |
+
self.sam_processor.add_inputs_to_inference_session(
|
| 124 |
+
inference_session=session, frame_idx=0, obj_ids=2 + i, **hint
|
| 125 |
+
)
|
| 126 |
+
self.sam(inference_session=session, frame_idx=0)
|
| 127 |
+
height, width = frames[0].height, frames[0].width
|
| 128 |
+
subject = np.zeros((len(frames), height, width), dtype=bool)
|
| 129 |
+
things = [np.zeros((len(frames), height, width), dtype=bool) for _ in objects]
|
| 130 |
+
for output in self.sam.propagate_in_video_iterator(session):
|
| 131 |
+
frame_masks = self.sam_processor.post_process_masks(
|
| 132 |
+
[output.pred_masks], original_sizes=[[height, width]], binarize=True
|
| 133 |
+
)[0].reshape(-1, height, width).cpu().numpy()
|
| 134 |
+
ids = list(output.object_ids) if output.object_ids is not None else list(range(1, len(frame_masks) + 1))
|
| 135 |
+
for obj_id, mask in zip(ids, frame_masks):
|
| 136 |
+
if obj_id == 1:
|
| 137 |
+
subject[output.frame_idx] = mask
|
| 138 |
+
else:
|
| 139 |
+
things[obj_id - 2][output.frame_idx] = mask
|
| 140 |
+
return subject, things
|
| 141 |
+
|
| 142 |
+
@torch.no_grad()
|
| 143 |
+
def person_pixels(self, frames: list[Image.Image], batch: int = 8, short_side: int = 512) -> np.ndarray:
|
| 144 |
+
"""Semantic "person" masks [T, H, W] (Mask2Former COCO panoptic at `short_side`, upsampled)."""
|
| 145 |
+
height, width = frames[0].height, frames[0].width
|
| 146 |
+
out = np.zeros((len(frames), height, width), dtype=bool)
|
| 147 |
+
for start in range(0, len(frames), batch):
|
| 148 |
+
chunk = [f.convert("RGB") for f in frames[start : start + batch]]
|
| 149 |
+
size = {"shortest_edge": short_side, "longest_edge": 2 * short_side}
|
| 150 |
+
inputs = self.parser_processor(images=chunk, size=size, return_tensors="pt")
|
| 151 |
+
outputs = self.parser(**inputs.to(self.device))
|
| 152 |
+
maps = self.parser_processor.post_process_semantic_segmentation(
|
| 153 |
+
outputs, target_sizes=[(height, width)] * len(chunk)
|
| 154 |
+
)
|
| 155 |
+
for i, semantic in enumerate(maps):
|
| 156 |
+
out[start + i] = (semantic == self.parser_person).cpu().numpy()
|
| 157 |
+
return out
|
| 158 |
+
|
| 159 |
+
def refine(self, frames: list[Image.Image], masks: np.ndarray, tolerance: float = 0.012) -> np.ndarray:
|
| 160 |
+
"""Keeps the tracked mask only where Mask2Former also sees a person (dilated a little, so hair and loose
|
| 161 |
+
clothing at the boundary survive). Skipped for subjects that are mostly not "person" pixels, and on frames
|
| 162 |
+
where it would remove most of the mask: there the person is usually only partly visible (occluded), which
|
| 163 |
+
Mask2Former misses, rather than SAM 2.1 having leaked."""
|
| 164 |
+
if self.parser is None or not masks.any():
|
| 165 |
+
return masks
|
| 166 |
+
person = self.person_pixels(frames)
|
| 167 |
+
tracked = masks.reshape(len(masks), -1).sum(1)
|
| 168 |
+
visible = tracked > 0
|
| 169 |
+
if np.median((masks & person).reshape(len(masks), -1).sum(1)[visible] / tracked[visible]) < MIN_PERSON_SHARE:
|
| 170 |
+
return masks
|
| 171 |
+
refined = masks & adjust(person, max(1, round(tolerance * min(masks.shape[1:]))), self.device)
|
| 172 |
+
kept = refined.reshape(len(masks), -1).sum(1) / np.maximum(tracked, 1)
|
| 173 |
+
refined[kept < 0.5] = masks[kept < 0.5]
|
| 174 |
+
return refined
|
| 175 |
+
|
| 176 |
+
def video_masks(self, frames: list[Image.Image], point=None, objects=()):
|
| 177 |
+
"""(person masks, union of the objects' masks, person box on frame 0, the objects kept). Where an object
|
| 178 |
+
covers the person (a lollipop at the mouth, a cup in the hand), the pixels belong to the object.
|
| 179 |
+
|
| 180 |
+
Objects marked "check" (found by the scene reader rather than clicked) are dropped when their frame-0 mask is
|
| 181 |
+
mostly "person" pixels for Mask2Former: a box that landed on a hand or a face, not on what it holds."""
|
| 182 |
+
box = self.pick(self.find_people(frames[0]), point, frames[0].size)
|
| 183 |
+
if box is None and point is None:
|
| 184 |
+
raise ValueError("No person found on the first frame of the video: click on the person to replace.")
|
| 185 |
+
person, masks = self.track(frames, box=box, point=point, objects=objects)
|
| 186 |
+
body = None
|
| 187 |
+
if self.parser is not None and any(o.get("check") for o in objects):
|
| 188 |
+
body = self.person_pixels(frames[:1])[0]
|
| 189 |
+
things = np.zeros_like(person)
|
| 190 |
+
kept = []
|
| 191 |
+
for obj, mask in zip(objects, masks):
|
| 192 |
+
first = mask[0]
|
| 193 |
+
if obj.get("check") and body is not None and first.any() and (first & body).sum() > 0.6 * first.sum():
|
| 194 |
+
continue
|
| 195 |
+
things |= mask
|
| 196 |
+
kept.append(obj)
|
| 197 |
+
return self.refine(frames, person) & ~things, things, box, kept
|
| 198 |
+
|
| 199 |
+
def person_box(self, frame: Image.Image, point=None):
|
| 200 |
+
"""The box `video_masks` will track on the first frame (the clicked person, else the main one)."""
|
| 201 |
+
return self.pick(self.find_people(frame), point, frame.size)
|
| 202 |
+
|
| 203 |
+
def image_mask(self, image: Image.Image) -> np.ndarray:
|
| 204 |
+
"""Character mask of the reference: its alpha channel if it has a cut-out, else detection + SAM 2.1."""
|
| 205 |
+
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
| 206 |
+
alpha = np.asarray(image.convert("RGBA"))[..., 3] > 127
|
| 207 |
+
if 0.02 < alpha.mean() < 0.98:
|
| 208 |
+
return alpha
|
| 209 |
+
rgb = image.convert("RGB")
|
| 210 |
+
box = self.pick(self.find_people(rgb), size=rgb.size)
|
| 211 |
+
if box is None: # e.g. a stylised character the detector does not see as a person
|
| 212 |
+
return self.track([rgb], point=[rgb.width / 2, rgb.height / 2])[0][0]
|
| 213 |
+
return self.refine([rgb], self.track([rgb], box=box)[0])[0]
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 217 |
+
# Conditioning tensors
|
| 218 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def adjust(masks: np.ndarray, pixels: int, device="cpu") -> np.ndarray:
|
| 222 |
+
"""Grows (`pixels` > 0) or shrinks (< 0) bool masks [T, H, W] by about `pixels` px."""
|
| 223 |
+
if pixels == 0 or not masks.any():
|
| 224 |
+
return masks
|
| 225 |
+
m = torch.from_numpy(masks).to(device=device, dtype=torch.float16 if str(device) != "cpu" else torch.float32)
|
| 226 |
+
k, pad = 2 * abs(pixels) + 1, abs(pixels)
|
| 227 |
+
out = []
|
| 228 |
+
for chunk in m.split(32):
|
| 229 |
+
chunk = chunk[:, None]
|
| 230 |
+
if pixels > 0:
|
| 231 |
+
chunk = F.max_pool2d(chunk, k, stride=1, padding=pad)
|
| 232 |
+
else:
|
| 233 |
+
chunk = 1 - F.max_pool2d(1 - chunk, k, stride=1, padding=pad)
|
| 234 |
+
out.append(chunk[:, 0] > 0.5)
|
| 235 |
+
return torch.cat(out).cpu().numpy()
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def blocky(masks: np.ndarray, w_len: int, h_len: int) -> np.ndarray:
|
| 239 |
+
"""The official `get_aug_mask`, per frame: the person's box is cut into a w_len x h_len grid and every cell the
|
| 240 |
+
mask touches is filled. 1 x 1 = the whole box (the official default), larger grids follow the outline closer."""
|
| 241 |
+
out = masks.copy()
|
| 242 |
+
for mask in out:
|
| 243 |
+
ys, xs = np.nonzero(mask)
|
| 244 |
+
if len(xs) == 0:
|
| 245 |
+
continue
|
| 246 |
+
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
|
| 247 |
+
w_step, h_step = max(1, int((x1 - x0) / w_len)), max(1, int((y1 - y0) / h_len))
|
| 248 |
+
for x in range(x0, x1, w_step):
|
| 249 |
+
for y in range(y0, y1, h_step):
|
| 250 |
+
cell = mask[y : min(y + h_step, y1), x : min(x + w_step, x1)]
|
| 251 |
+
if cell.any():
|
| 252 |
+
cell[...] = True
|
| 253 |
+
return out
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def blockify(masks: np.ndarray, block: int, device="cpu") -> np.ndarray:
|
| 257 |
+
"""Every block x block cell of the image grid that the mask touches, filled.
|
| 258 |
+
|
| 259 |
+
KJNodes' BlockifyMask (32 px in Kijai's Wan-Animate workflow), but on the image grid: with `block` a multiple of
|
| 260 |
+
16 each cell is a whole number of VAE latents and transformer patches, redrawn or kept entirely. A mask edge that
|
| 261 |
+
cuts through a latent leaves part of the blacked-out person inside a latent marked as kept, which comes back as
|
| 262 |
+
a dark outline around the new character."""
|
| 263 |
+
if not masks.any():
|
| 264 |
+
return masks
|
| 265 |
+
height, width = masks.shape[1:]
|
| 266 |
+
m = torch.from_numpy(masks).to(device=device, dtype=torch.float16 if str(device) != "cpu" else torch.float32)
|
| 267 |
+
out = []
|
| 268 |
+
for chunk in m.split(32):
|
| 269 |
+
cells = F.max_pool2d(chunk[:, None], block, stride=block, ceil_mode=True)
|
| 270 |
+
out.append(F.interpolate(cells, scale_factor=block, mode="nearest")[:, 0, :height, :width] > 0.5)
|
| 271 |
+
return torch.cat(out).cpu().numpy()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def mostly(masks: np.ndarray, cell: int = 8, share: float = 0.5, device="cpu") -> np.ndarray:
|
| 275 |
+
"""The cell x cell cells of the image grid that the mask covers at least `share` of, filled (8 px = one VAE
|
| 276 |
+
latent)."""
|
| 277 |
+
if not masks.any():
|
| 278 |
+
return masks
|
| 279 |
+
height, width = masks.shape[1:]
|
| 280 |
+
m = torch.from_numpy(masks).to(device=device, dtype=torch.float32)
|
| 281 |
+
out = []
|
| 282 |
+
for chunk in m.split(32):
|
| 283 |
+
cover = F.avg_pool2d(chunk[:, None], cell, stride=cell, ceil_mode=True)
|
| 284 |
+
out.append(F.interpolate(cover, scale_factor=cell, mode="nearest")[:, 0, :height, :width] >= share)
|
| 285 |
+
return torch.cat(out).cpu().numpy()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def scene_backdrop(frame: np.ndarray, person: np.ndarray) -> np.ndarray:
|
| 289 |
+
"""The frame without the person (its area filled in from the surroundings by a normalised Gaussian blur), softly
|
| 290 |
+
blurred: what the reference character is pasted onto.
|
| 291 |
+
|
| 292 |
+
The Relighting LoRA was trained on references cut out of their video and pasted onto other scenes. The photo's
|
| 293 |
+
own room would show up around the new character, and a flat black backdrop comes back as a dark outline."""
|
| 294 |
+
height, width = person.shape
|
| 295 |
+
known = (~adjust(person[None], max(2, round(0.01 * min(height, width))))[0]).astype(np.float32)
|
| 296 |
+
image = frame.astype(np.float32)
|
| 297 |
+
mean = (image * known[..., None]).sum((0, 1)) / max(float(known.sum()), 1.0)
|
| 298 |
+
sigma = 0.08 * max(height, width)
|
| 299 |
+
num = cv2.GaussianBlur(image * known[..., None], (0, 0), sigma)
|
| 300 |
+
den = cv2.GaussianBlur(known, (0, 0), sigma)[..., None]
|
| 301 |
+
eps = 1e-3 # far from any visible background: the average background colour
|
| 302 |
+
filled = np.where(known[..., None] > 0, image, (num + eps * mean) / (den + eps))
|
| 303 |
+
return np.clip(cv2.GaussianBlur(filled, (0, 0), 0.015 * max(height, width)), 0, 255).astype(np.uint8)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def place_character(image: Image.Image, mask: np.ndarray, backdrop: np.ndarray, margin: float = 0.1) -> Image.Image:
|
| 307 |
+
"""The reference frame: the character cropped from the photo (plus margin), fitted into the frame and pasted onto
|
| 308 |
+
`backdrop` (uint8 [H, W, 3]) through its mask, so nothing of the photo's own background is left."""
|
| 309 |
+
height, width = backdrop.shape[:2]
|
| 310 |
+
ys, xs = np.nonzero(mask)
|
| 311 |
+
if len(xs) == 0:
|
| 312 |
+
raise ValueError("Could not find the character on the reference image.")
|
| 313 |
+
x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1
|
| 314 |
+
mx, my = int((x1 - x0) * margin), int((y1 - y0) * margin)
|
| 315 |
+
box = (max(0, x0 - mx), max(0, y0 - my), min(image.width, x1 + mx), min(image.height, y1 + my))
|
| 316 |
+
crop = image.convert("RGB").crop(box)
|
| 317 |
+
scale = min(width / crop.width, height / crop.height)
|
| 318 |
+
size = (max(1, round(crop.width * scale)), max(1, round(crop.height * scale)))
|
| 319 |
+
offset = ((width - size[0]) // 2, (height - size[1]) // 2)
|
| 320 |
+
alpha = Image.fromarray(mask.astype(np.uint8) * 255).crop(box).resize(size, Image.Resampling.BILINEAR)
|
| 321 |
+
alpha = alpha.filter(ImageFilter.MinFilter(3)) # 1 px inwards: no rim of the photo's background
|
| 322 |
+
canvas = Image.fromarray(backdrop).convert("RGB")
|
| 323 |
+
canvas.paste(crop.resize(size, Image.Resampling.LANCZOS), offset, alpha)
|
| 324 |
+
return canvas
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def feathered(masks: np.ndarray, grow: int, device) -> torch.Tensor:
|
| 328 |
+
"""bool [T, H, W] -> soft [T, 1, H, W]: dilated by `grow` px, then blurred over about `grow` px."""
|
| 329 |
+
m = torch.from_numpy(masks).to(device=device, dtype=torch.float32)[:, None]
|
| 330 |
+
if grow > 0:
|
| 331 |
+
m = F.max_pool2d(m, 2 * grow + 1, stride=1, padding=grow)
|
| 332 |
+
sigma = grow / 2
|
| 333 |
+
radius = int(3 * sigma)
|
| 334 |
+
x = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32)
|
| 335 |
+
k = torch.exp(-(x**2) / (2 * sigma**2))
|
| 336 |
+
k = k / k.sum()
|
| 337 |
+
m = F.conv2d(F.pad(m, (radius, radius, 0, 0), mode="replicate"), k.view(1, 1, 1, -1))
|
| 338 |
+
m = F.conv2d(F.pad(m, (0, 0, radius, radius), mode="replicate"), k.view(1, 1, -1, 1))
|
| 339 |
+
return m.clamp_(0, 1)
|
pose.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wan-Animate's driving signals, extracted from the video the way its official preprocessing does it
|
| 2 |
+
(wan/modules/animate/preprocess in Wan-Video/Wan2.2, Apache-2.0):
|
| 3 |
+
|
| 4 |
+
* whole-body keypoints (COCO-WholeBody, 133 points) from ViTPose-H, one top-down crop per frame around the person;
|
| 5 |
+
* the skeleton image Wan-Animate was trained on (`draw_aapose_by_meta_new`): body limbs, feet and hands;
|
| 6 |
+
* 512x512 face crops for its motion encoder, which carries expressions, lips, gaze and head turns.
|
| 7 |
+
|
| 8 |
+
ViTPose-H is the checkpoint the official preprocessing ships (process_checkpoint/pose2d/vitpose_h_wholebody.onnx in
|
| 9 |
+
Wan-AI/Wan2.2-Animate-14B). Its weights are read from the ONNX external-data files into the PyTorch module below, so
|
| 10 |
+
it runs on the ZeroGPU GPU without onnxruntime.
|
| 11 |
+
|
| 12 |
+
In replacement mode the skeleton is not retargeted (the official default), so the new character gets the original
|
| 13 |
+
person's limb lengths, positions and motion.
|
| 14 |
+
|
| 15 |
+
One deliberate difference: the official `Pose2d.load_images` converts the already-RGB frames "BGR to RGB", so its
|
| 16 |
+
ViTPose sees channel-swapped images. ViTPose was trained on RGB, which is what it gets here.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import colorsys
|
| 22 |
+
import math
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
import cv2
|
| 26 |
+
import numpy as np
|
| 27 |
+
import onnx
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
import torch.nn.functional as F
|
| 31 |
+
from huggingface_hub import snapshot_download
|
| 32 |
+
|
| 33 |
+
POSE_REPO = os.getenv("WAN_POSE_REPO", "Wan-AI/Wan2.2-Animate-14B")
|
| 34 |
+
POSE_DIR = "process_checkpoint/pose2d/vitpose_h_wholebody.onnx"
|
| 35 |
+
CROP_SIZE = (256, 192) # (height, width) of ViTPose's input
|
| 36 |
+
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406])
|
| 37 |
+
IMAGENET_STD = np.array([0.229, 0.224, 0.225])
|
| 38 |
+
THRESHOLD = 0.5 # keypoint confidence below which a limb or point is not drawn
|
| 39 |
+
FACE_SIZE = 512
|
| 40 |
+
|
| 41 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 42 |
+
# ViTPose-H (mmpose layout): ViT-H/16 on a 256x192 crop, two deconvolutions, 133 heatmaps at 64x48
|
| 43 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class _Block(nn.Module):
|
| 47 |
+
def __init__(self, dim: int, heads: int, hidden: int):
|
| 48 |
+
super().__init__()
|
| 49 |
+
self.heads = heads
|
| 50 |
+
self.norm1 = nn.LayerNorm(dim, eps=1e-6)
|
| 51 |
+
self.qkv = nn.Linear(dim, 3 * dim)
|
| 52 |
+
self.proj = nn.Linear(dim, dim)
|
| 53 |
+
self.norm2 = nn.LayerNorm(dim, eps=1e-6)
|
| 54 |
+
self.fc1 = nn.Linear(dim, hidden)
|
| 55 |
+
self.fc2 = nn.Linear(hidden, dim)
|
| 56 |
+
|
| 57 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 58 |
+
b, n, c = x.shape
|
| 59 |
+
q, k, v = self.qkv(self.norm1(x)).view(b, n, 3, self.heads, c // self.heads).permute(2, 0, 3, 1, 4)
|
| 60 |
+
x = x + self.proj(F.scaled_dot_product_attention(q, k, v).transpose(1, 2).reshape(b, n, c))
|
| 61 |
+
return x + self.fc2(F.gelu(self.fc1(self.norm2(x))))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ViTPose(nn.Module):
|
| 65 |
+
def __init__(self, dim: int = 1280, depth: int = 32, heads: int = 16, hidden: int = 5120, keypoints: int = 133):
|
| 66 |
+
super().__init__()
|
| 67 |
+
self.patch_embed = nn.Conv2d(3, dim, kernel_size=16, stride=16, padding=2)
|
| 68 |
+
self.pos_embed = nn.Parameter(torch.zeros(1, 16 * 12, dim)) # patch positions + the (unused) cls position
|
| 69 |
+
self.blocks = nn.ModuleList(_Block(dim, heads, hidden) for _ in range(depth))
|
| 70 |
+
self.last_norm = nn.LayerNorm(dim, eps=1e-6)
|
| 71 |
+
self.deconv1 = nn.ConvTranspose2d(dim, 256, 4, stride=2, padding=1, bias=False)
|
| 72 |
+
self.bn1 = nn.BatchNorm2d(256)
|
| 73 |
+
self.deconv2 = nn.ConvTranspose2d(256, 256, 4, stride=2, padding=1, bias=False)
|
| 74 |
+
self.bn2 = nn.BatchNorm2d(256)
|
| 75 |
+
self.final = nn.Conv2d(256, keypoints, 1)
|
| 76 |
+
|
| 77 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 78 |
+
x = self.patch_embed(x)
|
| 79 |
+
b, c, h, w = x.shape
|
| 80 |
+
x = x.flatten(2).transpose(1, 2) + self.pos_embed
|
| 81 |
+
for block in self.blocks:
|
| 82 |
+
x = block(x)
|
| 83 |
+
x = self.last_norm(x).transpose(1, 2).reshape(b, c, h, w)
|
| 84 |
+
x = F.relu(self.bn1(self.deconv1(x)))
|
| 85 |
+
x = F.relu(self.bn2(self.deconv2(x)))
|
| 86 |
+
return self.final(x)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _onnx_weights(folder: str) -> dict[str, np.ndarray]:
|
| 90 |
+
"""Initializers of end2end.onnx by name; anonymous MatMul/Add constants are named after the node using them."""
|
| 91 |
+
graph = onnx.load(os.path.join(folder, "end2end.onnx"), load_external_data=False).graph
|
| 92 |
+
users = {}
|
| 93 |
+
for node in graph.node:
|
| 94 |
+
for name in node.input:
|
| 95 |
+
users.setdefault(name, node)
|
| 96 |
+
weights = {}
|
| 97 |
+
for init in graph.initializer:
|
| 98 |
+
if init.data_location == onnx.TensorProto.EXTERNAL:
|
| 99 |
+
info = {e.key: e.value for e in init.external_data}
|
| 100 |
+
dtype = onnx.helper.tensor_dtype_to_np_dtype(init.data_type)
|
| 101 |
+
count = int(info["length"]) // np.dtype(dtype).itemsize if "length" in info else -1
|
| 102 |
+
array = np.fromfile(os.path.join(folder, info["location"]), dtype=dtype, count=count,
|
| 103 |
+
offset=int(info.get("offset", 0))).reshape(tuple(init.dims))
|
| 104 |
+
else:
|
| 105 |
+
array = onnx.numpy_helper.to_array(init)
|
| 106 |
+
name = init.name
|
| 107 |
+
if name.startswith("onnx::"):
|
| 108 |
+
node = users[name].name # e.g. /backbone/blocks.3/mlp/fc1/MatMul, /backbone/Add_1
|
| 109 |
+
name = node.strip("/").replace("/", ".")
|
| 110 |
+
weights[name] = array
|
| 111 |
+
return weights
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def load_vitpose(repo: str = POSE_REPO) -> ViTPose:
|
| 115 |
+
return vitpose_from_onnx(os.path.join(snapshot_download(repo, allow_patterns=[f"{POSE_DIR}/*"]), POSE_DIR))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def vitpose_from_onnx(folder: str) -> ViTPose:
|
| 119 |
+
w = _onnx_weights(folder)
|
| 120 |
+
with torch.device("meta"): # skip the random init of 632M parameters; every tensor comes from the checkpoint
|
| 121 |
+
model = ViTPose()
|
| 122 |
+
state = {
|
| 123 |
+
"patch_embed.weight": w["backbone.patch_embed.proj.weight"],
|
| 124 |
+
"patch_embed.bias": w["backbone.patch_embed.proj.bias"],
|
| 125 |
+
# The export folded x + pos_embed[:, 1:] + pos_embed[:, :1] into two constants.
|
| 126 |
+
"pos_embed": w["backbone.Add"] + w["backbone.Add_1"],
|
| 127 |
+
"last_norm.weight": w["backbone.last_norm.weight"],
|
| 128 |
+
"last_norm.bias": w["backbone.last_norm.bias"],
|
| 129 |
+
"deconv1.weight": w["keypoint_head.deconv_layers.0.weight"],
|
| 130 |
+
"deconv2.weight": w["keypoint_head.deconv_layers.3.weight"],
|
| 131 |
+
"final.weight": w["keypoint_head.final_layer.weight"],
|
| 132 |
+
"final.bias": w["keypoint_head.final_layer.bias"],
|
| 133 |
+
}
|
| 134 |
+
for ours, theirs in (("bn1", "1"), ("bn2", "4")):
|
| 135 |
+
for p in ("weight", "bias", "running_mean", "running_var"):
|
| 136 |
+
state[f"{ours}.{p}"] = w[f"keypoint_head.deconv_layers.{theirs}.{p}"]
|
| 137 |
+
for i in range(len(model.blocks)):
|
| 138 |
+
src = f"backbone.blocks.{i}"
|
| 139 |
+
for ours, theirs in (("norm1", "norm1"), ("norm2", "norm2")):
|
| 140 |
+
state[f"blocks.{i}.{ours}.weight"] = w[f"{src}.{theirs}.weight"]
|
| 141 |
+
state[f"blocks.{i}.{ours}.bias"] = w[f"{src}.{theirs}.bias"]
|
| 142 |
+
for ours, theirs in (("qkv", "attn.qkv"), ("proj", "attn.proj"), ("fc1", "mlp.fc1"), ("fc2", "mlp.fc2")):
|
| 143 |
+
state[f"blocks.{i}.{ours}.weight"] = w[f"{src}.{theirs}.MatMul"].T # ONNX MatMul stores W^T
|
| 144 |
+
state[f"blocks.{i}.{ours}.bias"] = w[f"{src}.{theirs}.bias"]
|
| 145 |
+
state = {k: torch.from_numpy(np.require(v, np.float32, ["C", "W"])) for k, v in state.items()}
|
| 146 |
+
state["bn1.num_batches_tracked"] = torch.tensor(0)
|
| 147 |
+
state["bn2.num_batches_tracked"] = torch.tensor(0)
|
| 148 |
+
model.load_state_dict(state, strict=True, assign=True)
|
| 149 |
+
return model.eval().requires_grad_(False)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 153 |
+
# Top-down crop and heatmap decoding (pose2d.py / pose2d_utils.py)
|
| 154 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _box_center_scale(box, rescale: float = 1.25):
|
| 158 |
+
"""`bbox_from_detector`: centre and scale (in units of 200 px) of a 3:4 crop around the box."""
|
| 159 |
+
aspect = CROP_SIZE[0] / CROP_SIZE[1]
|
| 160 |
+
center = np.array([(box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0])
|
| 161 |
+
size = max((box[2] - box[0]) * aspect, box[3] - box[1])
|
| 162 |
+
return center, np.array([size / aspect, size]) / 200.0 * rescale
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _transform(point, center, scale: float, invert: bool):
|
| 166 |
+
"""`transform` with `get_transform` (no rotation), including its integer rounding."""
|
| 167 |
+
rows, cols = CROP_SIZE
|
| 168 |
+
h = 200 * scale
|
| 169 |
+
w = h / (rows / cols)
|
| 170 |
+
t = np.array([[cols / w, 0, cols * (-center[0] / w + 0.5)], [0, rows / h, rows * (-center[1] / h + 0.5)], [0, 0, 1]])
|
| 171 |
+
if invert:
|
| 172 |
+
t = np.linalg.inv(t)
|
| 173 |
+
p = t @ np.array([point[0] - 1, point[1] - 1, 1.0])
|
| 174 |
+
return np.array([round(p[0]), round(p[1])], dtype=int) + 1
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _crop(image: np.ndarray, center, scale) -> np.ndarray:
|
| 178 |
+
"""`crop`: the crop box (padded with black outside the image) resized to 192x256."""
|
| 179 |
+
rows, cols = CROP_SIZE
|
| 180 |
+
ul = _transform([1, 1], center, max(scale), invert=True) - 1
|
| 181 |
+
br = _transform([cols + 1, rows + 1], center, max(scale), invert=True) - 1
|
| 182 |
+
out = np.zeros((br[1] - ul[1], br[0] - ul[0], 3), dtype=np.float32)
|
| 183 |
+
new_x = max(0, -ul[0]), min(br[0], image.shape[1]) - ul[0]
|
| 184 |
+
new_y = max(0, -ul[1]), min(br[1], image.shape[0]) - ul[1]
|
| 185 |
+
old_x = max(0, ul[0]), min(image.shape[1], br[0])
|
| 186 |
+
old_y = max(0, ul[1]), min(image.shape[0], br[1])
|
| 187 |
+
if new_x[1] > new_x[0] and new_y[1] > new_y[0]:
|
| 188 |
+
out[new_y[0] : new_y[1], new_x[0] : new_x[1]] = image[old_y[0] : old_y[1], old_x[0] : old_x[1]]
|
| 189 |
+
return cv2.resize(out, (cols, rows))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _decode(heatmaps: np.ndarray, centers: np.ndarray, scales: np.ndarray, kernel: int = 11) -> np.ndarray:
|
| 193 |
+
"""`keypoints_from_heatmaps(unbiased=True, use_udp=False)` (DARK), vectorised: [N, K, H, W] -> [N, K, 3]."""
|
| 194 |
+
n, k, h, w = heatmaps.shape
|
| 195 |
+
flat = heatmaps.reshape(n, k, -1)
|
| 196 |
+
idx = flat.argmax(-1)
|
| 197 |
+
maxvals = flat.max(-1)
|
| 198 |
+
preds = np.stack([idx % w, idx // w], -1).astype(np.float64)
|
| 199 |
+
preds[maxvals <= 0] = -1
|
| 200 |
+
# Gaussian modulation (zero borders, peak height kept), then a second-order Taylor step on log-heatmaps.
|
| 201 |
+
border = (kernel - 1) // 2
|
| 202 |
+
padded = np.zeros((n * k, h + 2 * border, w + 2 * border), dtype=np.float32)
|
| 203 |
+
padded[:, border:-border, border:-border] = heatmaps.reshape(n * k, h, w)
|
| 204 |
+
blurred = np.stack([cv2.GaussianBlur(p, (kernel, kernel), 0) for p in padded])[:, border:-border, border:-border]
|
| 205 |
+
peak = heatmaps.reshape(n * k, -1).max(-1)
|
| 206 |
+
blurred = blurred * (peak / np.maximum(blurred.reshape(n * k, -1).max(-1), 1e-12))[:, None, None]
|
| 207 |
+
logh = np.log(np.maximum(blurred, 1e-10)).astype(np.float64)
|
| 208 |
+
px, py = preds[..., 0].reshape(-1).astype(int), preds[..., 1].reshape(-1).astype(int)
|
| 209 |
+
ok = (px > 1) & (px < w - 2) & (py > 1) & (py < h - 2)
|
| 210 |
+
j = np.arange(n * k)
|
| 211 |
+
pxc, pyc = np.clip(px, 2, w - 3), np.clip(py, 2, h - 3)
|
| 212 |
+
|
| 213 |
+
def at(dy, dx):
|
| 214 |
+
return logh[j, pyc + dy, pxc + dx]
|
| 215 |
+
|
| 216 |
+
dx = 0.5 * (at(0, 1) - at(0, -1))
|
| 217 |
+
dy = 0.5 * (at(1, 0) - at(-1, 0))
|
| 218 |
+
dxx = 0.25 * (at(0, 2) - 2 * at(0, 0) + at(0, -2))
|
| 219 |
+
dxy = 0.25 * (at(1, 1) - at(-1, 1) - at(1, -1) + at(-1, -1))
|
| 220 |
+
dyy = 0.25 * (at(2, 0) - 2 * at(0, 0) + at(-2, 0))
|
| 221 |
+
det = dxx * dyy - dxy**2
|
| 222 |
+
ok &= det != 0
|
| 223 |
+
safe = np.where(ok, det, 1.0)
|
| 224 |
+
preds = preds.reshape(-1, 2)
|
| 225 |
+
preds[ok, 0] -= ((dyy * dx - dxy * dy) / safe)[ok] # coord -= H^-1 g
|
| 226 |
+
preds[ok, 1] -= ((dxx * dy - dxy * dx) / safe)[ok]
|
| 227 |
+
preds = preds.reshape(n, k, 2)
|
| 228 |
+
# Back to image pixels (`transform_preds`, use_udp=False).
|
| 229 |
+
size = scales * 200.0
|
| 230 |
+
xy = preds * (size[:, None, :] / np.array([w, h])) + centers[:, None, :] - size[:, None, :] * 0.5
|
| 231 |
+
return np.concatenate([xy, maxvals[..., None]], -1)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 235 |
+
# Keypoint layout (AAPoseMeta) and drawing (human_visualization.py)
|
| 236 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 237 |
+
|
| 238 |
+
# 20 body points from the 133 COCO-WholeBody ones: OpenPose-18 (neck = mean of the shoulders) + one toe per foot.
|
| 239 |
+
_BODY_A = [0, 6, 6, 8, 10, 5, 7, 9, 12, 14, 16, 11, 13, 15, 2, 1, 4, 3, 17, 20]
|
| 240 |
+
_BODY_B = [0, 5, 6, 8, 10, 5, 7, 9, 12, 14, 16, 11, 13, 15, 2, 1, 4, 3, 18, 21]
|
| 241 |
+
_LIMBS = [[2, 3], [2, 6], [3, 4], [4, 5], [6, 7], [7, 8], [2, 9], [9, 10], [10, 11], [2, 12], [12, 13], [13, 14],
|
| 242 |
+
[2, 1], [1, 15], [15, 17], [1, 16], [16, 18], [14, 19], [11, 20]]
|
| 243 |
+
_COLORS = [[255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], [170, 255, 0], [85, 255, 0], [0, 255, 0],
|
| 244 |
+
[0, 255, 85], [0, 255, 170], [0, 255, 255], [0, 170, 255], [0, 85, 255], [0, 0, 255], [85, 0, 255],
|
| 245 |
+
[170, 0, 255], [255, 0, 255], [255, 0, 170], [255, 0, 85], [200, 200, 0], [100, 100, 0]]
|
| 246 |
+
_HAND_EDGES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [0, 9], [9, 10], [10, 11], [11, 12],
|
| 247 |
+
[0, 13], [13, 14], [14, 15], [15, 16], [0, 17], [17, 18], [18, 19], [19, 20]]
|
| 248 |
+
_HAND_COLORS = [tuple(c * 255 for c in colorsys.hsv_to_rgb(i / len(_HAND_EDGES), 1.0, 1.0)) for i in range(20)]
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def split_keypoints(kps: np.ndarray, last_body: np.ndarray | None = None) -> dict:
|
| 252 |
+
"""`load_pose_metas_from_kp2ds_seq` for one frame: [133, 3] pixels -> body (20), hands (21 + 21) and face (69)
|
| 253 |
+
in pixels. A frame whose body points all fall outside the image keeps the previous frame's body."""
|
| 254 |
+
body = (kps[_BODY_A] + kps[_BODY_B]) / 2
|
| 255 |
+
if last_body is not None and body[:, :2].min(axis=1).max() < 0:
|
| 256 |
+
body = last_body
|
| 257 |
+
return {"body": body, "left_hand": kps[91:112], "right_hand": kps[112:133], "face": kps[22:91]}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _draw_hand(canvas: np.ndarray, kps: np.ndarray, stick: int) -> None:
|
| 261 |
+
eps = 0.01
|
| 262 |
+
for (a, b), color in zip(_HAND_EDGES, _HAND_COLORS):
|
| 263 |
+
k1, k2 = kps[a], kps[b]
|
| 264 |
+
if k1[2] < THRESHOLD or k2[2] < THRESHOLD:
|
| 265 |
+
continue
|
| 266 |
+
x1, y1, x2, y2 = int(k1[0]), int(k1[1]), int(k2[0]), int(k2[1])
|
| 267 |
+
if x1 > eps and y1 > eps and x2 > eps and y2 > eps:
|
| 268 |
+
cv2.line(canvas, (x1, y1), (x2, y2), color, thickness=stick)
|
| 269 |
+
for k in kps:
|
| 270 |
+
if k[2] >= THRESHOLD and int(k[0]) > eps and int(k[1]) > eps:
|
| 271 |
+
cv2.circle(canvas, (int(k[0]), int(k[1])), stick, (0, 0, 255), thickness=-1)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def draw_skeleton(meta: dict, height: int, width: int) -> np.ndarray:
|
| 275 |
+
"""`draw_aapose_by_meta_new` (stickwidth v2, hands and head drawn): uint8 RGB [H, W, 3] on black."""
|
| 276 |
+
canvas = np.zeros((height, width, 3), np.uint8)
|
| 277 |
+
stick = max(int(min(height, width) / 200) - 1, 1)
|
| 278 |
+
body = meta["body"]
|
| 279 |
+
for (a, b), color in zip(_LIMBS, _COLORS):
|
| 280 |
+
k1, k2 = body[a - 1], body[b - 1]
|
| 281 |
+
if k1[2] < THRESHOLD or k2[2] < THRESHOLD:
|
| 282 |
+
continue
|
| 283 |
+
cx, cy = (k1[0] + k2[0]) / 2, (k1[1] + k2[1]) / 2
|
| 284 |
+
length = math.hypot(k1[0] - k2[0], k1[1] - k2[1])
|
| 285 |
+
angle = math.degrees(math.atan2(k1[1] - k2[1], k1[0] - k2[0]))
|
| 286 |
+
polygon = cv2.ellipse2Poly((int(cx), int(cy)), (int(length / 2), stick), int(angle), 0, 360, 1)
|
| 287 |
+
cv2.fillConvexPoly(canvas, polygon, [int(float(c) * 0.6) for c in color])
|
| 288 |
+
for k, color in zip(body, _COLORS):
|
| 289 |
+
if k[2] >= THRESHOLD:
|
| 290 |
+
cv2.circle(canvas, (int(k[0]), int(k[1])), stick, color, thickness=-1)
|
| 291 |
+
hand_stick = max(max(int(min(height, width) / 200) - 1, 1) // 2, 1)
|
| 292 |
+
_draw_hand(canvas, meta["left_hand"], hand_stick)
|
| 293 |
+
_draw_hand(canvas, meta["right_hand"], hand_stick)
|
| 294 |
+
return canvas
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def face_box(meta: dict, height: int, width: int, scale: float = 1.3):
|
| 298 |
+
"""`get_face_bboxes` (the version used for replacement): the 68 face landmarks' box, grown to 1.3x its area,
|
| 299 |
+
mostly upwards (forehead). Returns (x0, x1, y0, y1) or None when the landmarks collapse to a point."""
|
| 300 |
+
face = meta["face"][1:, :2]
|
| 301 |
+
min_x, min_y = face.min(axis=0)
|
| 302 |
+
max_x, max_y = face.max(axis=0)
|
| 303 |
+
w0, h0 = max_x - min_x, max_y - min_y
|
| 304 |
+
if w0 < 2 or h0 < 2:
|
| 305 |
+
return None
|
| 306 |
+
area = w0 * h0 * scale
|
| 307 |
+
dw = (math.sqrt(area * w0 / h0) - w0) / 2
|
| 308 |
+
dh = (math.sqrt(area * h0 / w0) - h0) / 4
|
| 309 |
+
box = int(max(min_x - dw, 0)), int(min(max_x + dw, width)), int(max(min_y - 3 * dh, 0)), int(min(max_y + dh, height))
|
| 310 |
+
return box if box[1] - box[0] >= 2 and box[3] - box[2] >= 2 else None
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 314 |
+
# Whole video
|
| 315 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
class PoseExtractor:
|
| 319 |
+
def __init__(self, repo: str = POSE_REPO):
|
| 320 |
+
self.model = load_vitpose(repo)
|
| 321 |
+
|
| 322 |
+
def to(self, device):
|
| 323 |
+
self.model.to(device)
|
| 324 |
+
return self
|
| 325 |
+
|
| 326 |
+
@property
|
| 327 |
+
def device(self) -> torch.device:
|
| 328 |
+
return next(self.model.parameters()).device
|
| 329 |
+
|
| 330 |
+
@torch.no_grad()
|
| 331 |
+
def keypoints(self, frames: list[np.ndarray], boxes: list, batch: int = 32) -> np.ndarray:
|
| 332 |
+
"""uint8 RGB frames + one person box (x0, y0, x1, y1) per frame -> [T, 133, 3] (x, y in pixels, score)."""
|
| 333 |
+
crops, centers, scales = [], [], []
|
| 334 |
+
for frame, box in zip(frames, boxes):
|
| 335 |
+
if box[2] - box[0] < 10 or box[3] - box[1] < 10: # as in `ViTPose.preprocess`: then the whole frame
|
| 336 |
+
box = [0, 0, frame.shape[1], frame.shape[0]]
|
| 337 |
+
center, scale = _box_center_scale(box)
|
| 338 |
+
crop = (_crop(frame, center, scale) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
|
| 339 |
+
crops.append(crop.transpose(2, 0, 1).astype(np.float32))
|
| 340 |
+
centers.append(center)
|
| 341 |
+
scales.append(scale)
|
| 342 |
+
heatmaps = []
|
| 343 |
+
for start in range(0, len(crops), batch):
|
| 344 |
+
x = torch.from_numpy(np.stack(crops[start : start + batch])).to(self.device)
|
| 345 |
+
heatmaps.append(self.model(x).float().cpu().numpy())
|
| 346 |
+
return _decode(np.concatenate(heatmaps), np.stack(centers), np.stack(scales))
|
| 347 |
+
|
| 348 |
+
def __call__(self, frames: list[np.ndarray], masks: np.ndarray, face_frames: list[np.ndarray] | None = None):
|
| 349 |
+
"""Skeleton images, 512x512 face crops and per-frame keypoint metas for the person covered by `masks`.
|
| 350 |
+
|
| 351 |
+
The person box of each frame is its mask's box (the official code uses a YOLO person box); frames where the
|
| 352 |
+
person is not visible reuse the nearest visible frame's box. `face_frames`, the same frames at a higher
|
| 353 |
+
resolution, give sharper face crops: the official preprocessing crops faces from 720p frames, and a face of a
|
| 354 |
+
480p frame blown up to 512x512 carries less of the expression."""
|
| 355 |
+
height, width = frames[0].shape[:2]
|
| 356 |
+
boxes = [_mask_box(m) for m in masks]
|
| 357 |
+
visible = [i for i, b in enumerate(boxes) if b is not None]
|
| 358 |
+
if not visible:
|
| 359 |
+
raise ValueError("The person is not visible in any frame.")
|
| 360 |
+
boxes = [b if b is not None else boxes[min(visible, key=lambda j: abs(j - i))] for i, b in enumerate(boxes)]
|
| 361 |
+
kps = self.keypoints(frames, boxes)
|
| 362 |
+
metas, last_body = [], None
|
| 363 |
+
for k in kps:
|
| 364 |
+
meta = split_keypoints(k, last_body)
|
| 365 |
+
last_body = meta["body"]
|
| 366 |
+
metas.append(meta)
|
| 367 |
+
skeletons = [draw_skeleton(m, height, width) for m in metas]
|
| 368 |
+
sources = face_frames if face_frames is not None else frames
|
| 369 |
+
fy, fx = sources[0].shape[0] / height, sources[0].shape[1] / width
|
| 370 |
+
faces, last = [], None
|
| 371 |
+
for source, meta in zip(sources, metas):
|
| 372 |
+
box = face_box(meta, height, width) or last
|
| 373 |
+
if box is None: # no usable face so far: the top of the person box
|
| 374 |
+
x0, y0, x1, y1 = _mask_box(masks[visible[0]])
|
| 375 |
+
side = max(2, min(x1 - x0, y1 - y0) // 2)
|
| 376 |
+
box = (int((x0 + x1 - side) / 2), int((x0 + x1 + side) / 2), int(y0), int(y0 + side))
|
| 377 |
+
last = box
|
| 378 |
+
x0, x1, y0, y1 = (int(v * f) for v, f in zip(box, (fx, fx, fy, fy)))
|
| 379 |
+
faces.append(cv2.resize(source[y0 : max(y1, y0 + 2), x0 : max(x1, x0 + 2)], (FACE_SIZE, FACE_SIZE)))
|
| 380 |
+
return skeletons, faces, metas
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _mask_box(mask: np.ndarray):
|
| 384 |
+
ys, xs = np.nonzero(mask)
|
| 385 |
+
if len(xs) == 0:
|
| 386 |
+
return None
|
| 387 |
+
return [float(xs.min()), float(ys.min()), float(xs.max() + 1), float(ys.max() + 1)]
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.10.0
|
| 2 |
+
torchvision==0.25.0
|
| 3 |
+
diffusers==0.42.1
|
| 4 |
+
transformers==5.21.1
|
| 5 |
+
accelerate==1.15.0
|
| 6 |
+
huggingface_hub
|
| 7 |
+
safetensors
|
| 8 |
+
sentencepiece
|
| 9 |
+
ftfy
|
| 10 |
+
scipy
|
| 11 |
+
onnx
|
| 12 |
+
opencv-python-headless
|
| 13 |
+
tqdm
|
| 14 |
+
imageio-ffmpeg
|
| 15 |
+
numpy
|
| 16 |
+
pillow
|
| 17 |
+
certifi
|
| 18 |
+
kernels==0.18.0
|
wan_animate.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wan2.2-Animate-14B (replacement mode) through diffusers' WanAnimatePipeline.
|
| 2 |
+
|
| 3 |
+
The diffusers repository stores the transformer in fp32 (69 GB). Here the transformer comes from the official bf16
|
| 4 |
+
checkpoint instead (Wan-AI/Wan2.2-Animate-14B, 34.5 GB): the LoRAs are fused into it in the original key layout,
|
| 5 |
+
then its keys are renamed with diffusers' own single-file converter. Everything else (UMT5, CLIP, VAE, tokenizer,
|
| 6 |
+
scheduler) is read from Wan-AI/Wan2.2-Animate-14B-Diffusers.
|
| 7 |
+
|
| 8 |
+
LoRAs:
|
| 9 |
+
* Relighting LoRA (official, PEFT layout, rank 128): matches the new character's lighting, shadows and colour tone to
|
| 10 |
+
the scene. The official code enables it for replacement.
|
| 11 |
+
* Lightx2v step/CFG distillation for Wan2.1-I2V-14B (ComfyUI layout from Kijai/WanVideo_comfy): 6 steps instead of
|
| 12 |
+
20. Wan-Animate is built on Wan2.1-I2V; the file, strength (1.2) and sampler (DPM++ SDE, shift 5) are those of
|
| 13 |
+
Kijai's Wan-Animate example workflow.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
from accelerate import init_empty_weights
|
| 25 |
+
from diffusers import (
|
| 26 |
+
AutoencoderKLWan,
|
| 27 |
+
DPMSolverMultistepScheduler,
|
| 28 |
+
UniPCMultistepScheduler,
|
| 29 |
+
WanAnimatePipeline,
|
| 30 |
+
WanAnimateTransformer3DModel,
|
| 31 |
+
)
|
| 32 |
+
from diffusers.loaders.single_file_utils import convert_wan_transformer_to_diffusers
|
| 33 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 34 |
+
from PIL import Image
|
| 35 |
+
from safetensors import safe_open
|
| 36 |
+
from safetensors.torch import load_file
|
| 37 |
+
|
| 38 |
+
ORIGINAL_REPO = os.getenv("WAN_ORIGINAL_REPO", "Wan-AI/Wan2.2-Animate-14B")
|
| 39 |
+
DIFFUSERS_REPO = os.getenv("WAN_DIFFUSERS_REPO", "Wan-AI/Wan2.2-Animate-14B-Diffusers")
|
| 40 |
+
LORA_FILES = {
|
| 41 |
+
"relight": (ORIGINAL_REPO, "relighting_lora/adapter_model.safetensors"),
|
| 42 |
+
"lightx2v": ("Kijai/WanVideo_comfy", "Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors"),
|
| 43 |
+
}
|
| 44 |
+
# name -> DPMSolver algorithm (None = UniPC, the diffusers default). The official code samples with DPM++, Kijai's
|
| 45 |
+
# workflow with its stochastic variant, which suits the distilled model's few large steps.
|
| 46 |
+
SAMPLERS = {
|
| 47 |
+
"DPM++ SDE": "sde-dpmsolver++",
|
| 48 |
+
"DPM++": "dpmsolver++",
|
| 49 |
+
"UniPC": None,
|
| 50 |
+
}
|
| 51 |
+
# The official default prompt ("the person in the video is doing actions"); Wan-Animate is driven by the pose, face
|
| 52 |
+
# and reference, and its authors advise against custom prompts.
|
| 53 |
+
DEFAULT_PROMPT = "视频中的人在做动作"
|
| 54 |
+
SEGMENT_FRAMES = 77 # official clip length per inference window
|
| 55 |
+
PREV_SEGMENT_FRAMES = 1 # official refert_num: generated frames carried into the next window
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 59 |
+
# Transformer: official bf16 checkpoint + fused LoRAs -> diffusers WanAnimateTransformer3DModel
|
| 60 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _lora_updates(path: str, adapter_config: str | None = None) -> dict[str, list]:
|
| 64 |
+
"""Target parameter (original key layout) -> what to add to it, for one LoRA file.
|
| 65 |
+
|
| 66 |
+
PEFT layout: base_model.model.<module>.lora_A/lora_B.weight, scaled by lora_alpha / r from adapter_config.json.
|
| 67 |
+
ComfyUI layout: diffusion_model.<module>.lora_down/lora_up.weight (+ optional .alpha), plus full differences
|
| 68 |
+
`.diff` (norm weights) and `.diff_b` (biases), which ComfyUI adds as they are.
|
| 69 |
+
"""
|
| 70 |
+
peft_scale = 1.0
|
| 71 |
+
if adapter_config is not None:
|
| 72 |
+
with open(adapter_config) as f:
|
| 73 |
+
cfg = json.load(f)
|
| 74 |
+
peft_scale = cfg["lora_alpha"] / cfg["r"]
|
| 75 |
+
updates: dict[str, list] = {}
|
| 76 |
+
with safe_open(path, framework="pt") as f:
|
| 77 |
+
keys = set(f.keys())
|
| 78 |
+
for key in sorted(keys):
|
| 79 |
+
if key.endswith(".lora_A.weight"):
|
| 80 |
+
module = key[: -len(".lora_A.weight")].removeprefix("base_model.model.")
|
| 81 |
+
updates.setdefault(module + ".weight", []).append(("lora", key, key.replace(".lora_A.", ".lora_B."), peft_scale))
|
| 82 |
+
elif key.endswith(".lora_down.weight"):
|
| 83 |
+
prefix = key[: -len(".lora_down.weight")]
|
| 84 |
+
module = prefix.removeprefix("diffusion_model.")
|
| 85 |
+
alpha = prefix + ".alpha" if prefix + ".alpha" in keys else None
|
| 86 |
+
updates.setdefault(module + ".weight", []).append(("lora", key, prefix + ".lora_up.weight", alpha))
|
| 87 |
+
elif key.endswith(".diff_b"):
|
| 88 |
+
updates.setdefault(key[: -len(".diff_b")].removeprefix("diffusion_model.") + ".bias", []).append(("diff", key))
|
| 89 |
+
elif key.endswith(".diff"):
|
| 90 |
+
updates.setdefault(key[: -len(".diff")].removeprefix("diffusion_model.") + ".weight", []).append(("diff", key))
|
| 91 |
+
return updates
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@torch.no_grad()
|
| 95 |
+
def fuse_loras(state: dict[str, torch.Tensor], loras: list[tuple[str, str | None, float]]) -> dict[str, int]:
|
| 96 |
+
"""Adds strength * delta of each LoRA to `state` (original key layout) in place.
|
| 97 |
+
|
| 98 |
+
All LoRAs touching a weight are summed in fp32 and rounded to the checkpoint's dtype once.
|
| 99 |
+
`loras`: (safetensors path, PEFT adapter_config.json path or None, strength).
|
| 100 |
+
"""
|
| 101 |
+
plans = [(path, _lora_updates(path, config), strength) for path, config, strength in loras if strength]
|
| 102 |
+
handles = [safe_open(path, framework="pt") for path, _, _ in plans]
|
| 103 |
+
targets = sorted(set().union(*(updates for _, updates, _ in plans))) if plans else []
|
| 104 |
+
counts = {os.path.basename(path): 0 for path, _, _ in plans}
|
| 105 |
+
missing = [t for t in targets if t not in state]
|
| 106 |
+
if missing:
|
| 107 |
+
raise KeyError(f"LoRA targets missing from the checkpoint: {missing[:5]} ...")
|
| 108 |
+
for target in targets:
|
| 109 |
+
weight = state[target]
|
| 110 |
+
total = weight.float()
|
| 111 |
+
for (path, updates, strength), f in zip(plans, handles):
|
| 112 |
+
for update in updates.get(target, []):
|
| 113 |
+
if update[0] == "lora":
|
| 114 |
+
_, down_key, up_key, scale = update
|
| 115 |
+
down, up = f.get_tensor(down_key).float(), f.get_tensor(up_key).float()
|
| 116 |
+
if isinstance(scale, str): # ComfyUI alpha key
|
| 117 |
+
scale = f.get_tensor(scale).item() / down.shape[0]
|
| 118 |
+
total += (strength * (scale if scale is not None else 1.0)) * (up @ down).view_as(total)
|
| 119 |
+
else:
|
| 120 |
+
total += strength * f.get_tensor(update[1]).float().view_as(total)
|
| 121 |
+
counts[os.path.basename(path)] += 1
|
| 122 |
+
state[target] = total.to(weight.dtype)
|
| 123 |
+
return counts
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def build_transformer(state: dict[str, torch.Tensor], config: dict) -> WanAnimateTransformer3DModel:
|
| 127 |
+
"""Original-layout state dict -> WanAnimateTransformer3DModel, without a second copy of the weights."""
|
| 128 |
+
return assemble_transformer(convert_wan_transformer_to_diffusers(state), config)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def assemble_transformer(state: dict[str, torch.Tensor], config: dict) -> WanAnimateTransformer3DModel:
|
| 132 |
+
"""diffusers-layout state dict -> model. Dtypes follow `from_pretrained(torch_dtype=torch.bfloat16)`: bf16,
|
| 133 |
+
except the modules diffusers keeps in fp32 (time embedding, modulation tables, norms)."""
|
| 134 |
+
with init_empty_weights():
|
| 135 |
+
model = WanAnimateTransformer3DModel.from_config(config)
|
| 136 |
+
expected = set(model.state_dict())
|
| 137 |
+
# The original stores the motion encoder's fixed blur kernels; diffusers recomputes them as buffers.
|
| 138 |
+
extra = sorted(set(state) - expected)
|
| 139 |
+
unknown = [k for k in extra if not k.endswith(".kernel")]
|
| 140 |
+
if unknown:
|
| 141 |
+
raise KeyError(f"Unexpected transformer weights: {unknown[:5]}")
|
| 142 |
+
keep_fp32 = WanAnimateTransformer3DModel._keep_in_fp32_modules
|
| 143 |
+
state = {
|
| 144 |
+
k: v.to(torch.float32 if any(m in k.split(".") for m in keep_fp32) else torch.bfloat16)
|
| 145 |
+
for k, v in state.items()
|
| 146 |
+
if k in expected
|
| 147 |
+
}
|
| 148 |
+
model.load_state_dict(state, strict=True, assign=True)
|
| 149 |
+
meta = [n for n, p in model.named_parameters() if p.is_meta]
|
| 150 |
+
if meta:
|
| 151 |
+
raise RuntimeError(f"Transformer parameters left uninitialised: {meta[:5]}")
|
| 152 |
+
return model.eval().requires_grad_(False)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def load_transformer(lora_strengths: dict[str, float]) -> WanAnimateTransformer3DModel:
|
| 156 |
+
folder = snapshot_download(ORIGINAL_REPO, allow_patterns=["diffusion_pytorch_model*.safetensors*"])
|
| 157 |
+
with open(os.path.join(folder, "diffusion_pytorch_model.safetensors.index.json")) as f:
|
| 158 |
+
shards = sorted(set(json.load(f)["weight_map"].values()))
|
| 159 |
+
state: dict[str, torch.Tensor] = {}
|
| 160 |
+
for shard in shards:
|
| 161 |
+
state.update(load_file(os.path.join(folder, shard)))
|
| 162 |
+
loras = []
|
| 163 |
+
for name, strength in lora_strengths.items():
|
| 164 |
+
if strength:
|
| 165 |
+
repo, filename = LORA_FILES[name]
|
| 166 |
+
config = None
|
| 167 |
+
if name == "relight":
|
| 168 |
+
config = hf_hub_download(repo, os.path.join(os.path.dirname(filename), "adapter_config.json"))
|
| 169 |
+
loras.append((hf_hub_download(repo, filename), config, strength))
|
| 170 |
+
if loras:
|
| 171 |
+
print("Fused LoRA tensors:", fuse_loras(state, loras))
|
| 172 |
+
config = WanAnimateTransformer3DModel.load_config(DIFFUSERS_REPO, subfolder="transformer")
|
| 173 |
+
return build_transformer(state, config)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 177 |
+
# Pipeline
|
| 178 |
+
# --------------------------------------------------------------------------------------------------------------
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _wan_clip_encoder(pipe: WanAnimatePipeline):
|
| 182 |
+
"""`encode_image` as in the official code: the whole reference frame (the character letterboxed into the video
|
| 183 |
+
frame) resized to 224x224, instead of diffusers' resize-then-centre-crop of the raw photo."""
|
| 184 |
+
mean = torch.tensor(pipe.image_processor.image_mean).view(1, 3, 1, 1)
|
| 185 |
+
std = torch.tensor(pipe.image_processor.image_std).view(1, 3, 1, 1)
|
| 186 |
+
|
| 187 |
+
def encode_image(image: Image.Image, device=None) -> torch.Tensor:
|
| 188 |
+
encoder = pipe.image_encoder
|
| 189 |
+
x = torch.from_numpy(np.array(image.convert("RGB"))).permute(2, 0, 1)[None].float() / 127.5 - 1
|
| 190 |
+
x = x.to(encoder.device)
|
| 191 |
+
x = F.interpolate(x, size=(224, 224), mode="bicubic", align_corners=False)
|
| 192 |
+
x = (x.mul(0.5).add(0.5) - mean.to(x.device)) / std.to(x.device)
|
| 193 |
+
out = encoder(pixel_values=x.to(encoder.dtype), output_hidden_states=True)
|
| 194 |
+
return out.hidden_states[-2].to(device or x.device)
|
| 195 |
+
|
| 196 |
+
return encode_image
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def load_pipeline(lora_strengths: dict[str, float]) -> WanAnimatePipeline:
|
| 200 |
+
transformer = load_transformer(lora_strengths)
|
| 201 |
+
folder = snapshot_download(
|
| 202 |
+
DIFFUSERS_REPO,
|
| 203 |
+
allow_patterns=["model_index.json", "scheduler/*", "tokenizer/*", "text_encoder/*", "image_encoder/*",
|
| 204 |
+
"image_processor/*", "vae/*"],
|
| 205 |
+
)
|
| 206 |
+
vae = AutoencoderKLWan.from_pretrained(folder, subfolder="vae", torch_dtype=torch.float32) # fp32 as in the docs
|
| 207 |
+
pipe = WanAnimatePipeline.from_pretrained(
|
| 208 |
+
folder, transformer=transformer, vae=vae,
|
| 209 |
+
torch_dtype={"image_encoder": torch.float16, "default": torch.bfloat16}, # official: CLIP fp16, UMT5 bf16
|
| 210 |
+
)
|
| 211 |
+
pipe.encode_image = _wan_clip_encoder(pipe)
|
| 212 |
+
pipe.set_progress_bar_config(desc="Denoising")
|
| 213 |
+
pipe.unipc_config = dict(pipe.scheduler.config)
|
| 214 |
+
return pipe
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def set_face_strength(pipe: WanAnimatePipeline, strength: float) -> None:
|
| 218 |
+
"""Scales what each face-adapter block adds to the video tokens (Kijai's `face_strength`): above 1 the driving
|
| 219 |
+
face's expressions (mouth, eyes, brows) come through stronger."""
|
| 220 |
+
for block in pipe.transformer.face_adapter:
|
| 221 |
+
if not getattr(block, "scaled_forward", False):
|
| 222 |
+
def forward(*args, _forward=block.forward, _block=block, **kwargs):
|
| 223 |
+
out = _forward(*args, **kwargs)
|
| 224 |
+
return out if _block.face_strength == 1.0 else out * _block.face_strength
|
| 225 |
+
block.forward = forward
|
| 226 |
+
block.scaled_forward = True
|
| 227 |
+
block.face_strength = float(strength)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def set_sampler(pipe: WanAnimatePipeline, sampler: str, shift: float) -> None:
|
| 231 |
+
algorithm = SAMPLERS[sampler]
|
| 232 |
+
if algorithm is None:
|
| 233 |
+
pipe.scheduler = UniPCMultistepScheduler.from_config(pipe.unipc_config, flow_shift=shift)
|
| 234 |
+
else: # = the official FlowDPMSolverMultistepScheduler (solver order 2, midpoint, final sigma 0)
|
| 235 |
+
pipe.scheduler = DPMSolverMultistepScheduler(
|
| 236 |
+
num_train_timesteps=1000, solver_order=2, prediction_type="flow_prediction", use_flow_sigmas=True,
|
| 237 |
+
flow_shift=shift, algorithm_type=algorithm, solver_type="midpoint", lower_order_final=True,
|
| 238 |
+
final_sigmas_type="zero",
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def segment_count(num_frames: int) -> int:
|
| 243 |
+
"""Inference windows WanAnimatePipeline runs for `num_frames` frames."""
|
| 244 |
+
step = SEGMENT_FRAMES - PREV_SEGMENT_FRAMES
|
| 245 |
+
rest = (num_frames - PREV_SEGMENT_FRAMES) % step
|
| 246 |
+
return (num_frames + (step - rest if rest else 0)) // step
|