sdfdsfsf32e3 commited on
Commit
0fc7216
·
verified ·
1 Parent(s): cdb77b8

Upload 7 files

Browse files
Files changed (7) hide show
  1. README.md +62 -5
  2. app.py +673 -0
  3. captioner.py +148 -0
  4. masking.py +339 -0
  5. pose.py +387 -0
  6. requirements.txt +18 -0
  7. wan_animate.py +246 -0
README.md CHANGED
@@ -1,13 +1,70 @@
1
  ---
2
- title: Bbs231
3
- emoji: 😻
4
- colorFrom: gray
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
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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