File size: 10,905 Bytes
49bc52e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
"""
Helpers ported 1:1 from Eyeline-Labs/ID-V2V (github.com/Eyeline-Labs/ID-V2V):

  src/idv2v/inference/pipeline.py   -> center_crop_and_resize, load_frames,
                                        I2V_14B_DIT_CONFIG, VACE_14B_CONFIG,
                                        load_finetuned_dit_vace, DEFAULT_NEGATIVE_PROMPT
  src/idv2v/preprocess/secret_panda.py -> secret_panda (morphology done with OpenCV
                                        instead of scipy.ndimage for speed; identical
                                        Minkowski semantics, border_value=0)
  src/idv2v/preprocess/sam3.py         -> run_sam3_union_masks
  src/idv2v/preprocess/orig_pixel.py   -> foreground_on_gray
"""

import os
import time
from typing import List

import cv2
import imageio.v2 as imageio
import numpy as np
import torch
from PIL import Image
from scipy import ndimage

# Default Wan 2.1 negative prompt (Chinese quality-degradation terms).
DEFAULT_NEGATIVE_PROMPT = (
    "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,"
    "JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,"
    "形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
)

# Source of truth: WanModel / VaceWanModel state_dict converters in diffsynth.
I2V_14B_DIT_CONFIG = {
    "has_image_input": True, "patch_size": [1, 2, 2], "in_dim": 36,
    "dim": 5120, "ffn_dim": 13824, "freq_dim": 256, "text_dim": 4096,
    "out_dim": 16, "num_heads": 40, "num_layers": 40, "eps": 1e-6,
}
VACE_14B_CONFIG = {
    "vace_layers": (0, 5, 10, 15, 20, 25, 30, 35), "vace_in_dim": 96,
    "patch_size": (1, 2, 2), "has_image_input": False, "dim": 5120,
    "num_heads": 40, "ffn_dim": 13824, "eps": 1e-6,
}


# --------------------------------------------------------------------------- #
# Video / image I/O
# --------------------------------------------------------------------------- #
def center_crop_and_resize(img: Image.Image, width: int, height: int) -> Image.Image:
    """Center-crop + resize a PIL image to (width, height) with BICUBIC.
    Resizes to the target aspect ratio first, then center-crops. Verbatim from
    src/idv2v/inference/pipeline.py."""
    w, h = img.size
    target_aspect = height / width
    aspect = h / w

    if (h == height) and (w == width):
        return img
    if abs(aspect - target_aspect) < 1e-6:
        return img.resize((width, height), Image.BICUBIC)

    if aspect > target_aspect:  # too tall -> match width, crop height
        new_w = width
        new_h = int(aspect * new_w)
    else:                        # too wide -> match height, crop width
        new_h = height
        new_w = int(new_h / aspect)
    resized = img.resize((new_w, new_h), Image.BICUBIC)

    rw, rh = resized.size
    left = (rw - width) // 2
    top = (rh - height) // 2
    return resized.crop((left, top, left + width, top + height))


def read_video_rgb(path: str):
    """Return (list[PIL.Image RGB], fps)."""
    reader = imageio.get_reader(path, format="ffmpeg")
    fps = float(reader.get_meta_data().get("fps", 25.0) or 25.0)
    frames = []
    try:
        for f in reader:
            arr = np.asarray(f)
            if arr.ndim == 3 and arr.shape[2] > 3:
                arr = arr[:, :, :3]
            frames.append(Image.fromarray(arr.astype(np.uint8), "RGB"))
    finally:
        reader.close()
    if not frames:
        raise ValueError(f"No frames decoded from {path}")
    return frames, fps


def load_source_frames(path: str, width: int, height: int, num_frames: int, stride: int):
    """Read `num_frames` source frames with temporal `stride`, center-cropped and
    resized to (width, height). Pads by repeating the last frame if the source is
    too short. Also returns the source fps."""
    raw, fps = read_video_rgb(path)
    picked = raw[::max(1, int(stride))][:num_frames]
    if len(picked) < num_frames:
        picked = picked + [picked[-1]] * (num_frames - len(picked))
    return [center_crop_and_resize(f, width, height) for f in picked], fps


def save_video(frames: List[Image.Image], path: str, fps: float):
    writer = imageio.get_writer(
        path, fps=max(1.0, float(fps)), codec="libx264",
        quality=8, macro_block_size=None, pixelformat="yuv420p",
    )
    try:
        for f in frames:
            writer.append_data(np.asarray(f.convert("RGB")))
    finally:
        writer.close()
    return path


# --------------------------------------------------------------------------- #
# Secret Panda mask cleanup
# --------------------------------------------------------------------------- #
def _close(mask_u8: np.ndarray, k: int) -> np.ndarray:
    """Binary morphological close with a k x k square, border value 0
    (matches scipy.ndimage defaults, but OpenCV-fast)."""
    kernel = np.ones((k, k), np.uint8)
    d = cv2.dilate(mask_u8, kernel, borderType=cv2.BORDER_CONSTANT, borderValue=0)
    return cv2.erode(d, kernel, borderType=cv2.BORDER_CONSTANT, borderValue=0)


def secret_panda(mask: np.ndarray, fill_holes_first: bool = True,
                 close_kernel: int = 10, bridge_distance: int = 15) -> np.ndarray:
    """Clean a binary mask: hole-fill -> close -> bridge wider gaps -> hole-fill.
    Port of src/idv2v/preprocess/secret_panda.py."""
    result = (np.asarray(mask) > 0).astype(np.uint8)
    if fill_holes_first:
        result = ndimage.binary_fill_holes(result).astype(np.uint8)
    if close_kernel > 0:
        result = _close(result, close_kernel)
    if bridge_distance > 0:
        result = _close(result, bridge_distance)
    return ndimage.binary_fill_holes(result).astype(bool)


# --------------------------------------------------------------------------- #
# SAM3 person segmentation -> per-frame union masks
# --------------------------------------------------------------------------- #
@torch.inference_mode()
def run_sam3_union_masks(model, processor, frames: List[Image.Image], text_prompt: str,
                        device="cuda", dtype=torch.bfloat16,
                        close_kernel: int = 10, bridge_distance: int = 15,
                        mask_bin_threshold: float = 0.5) -> List[np.ndarray]:
    """Promptable Concept Segmentation over the whole clip, then Secret Panda cleanup
    per object + on the union (== sam3.py with --joint_mask_post_proc).
    Returns one bool HxW union mask per input frame."""
    H, W = frames[0].height, frames[0].width

    session = processor.init_video_session(
        video=frames,
        inference_device=device,
        processing_device=device,
        video_storage_device=device,
        dtype=dtype,
    )
    session = processor.add_text_prompt(inference_session=session, text=text_prompt)

    raw = {}
    for model_outputs in model.propagate_in_video_iterator(
        inference_session=session, max_frame_num_to_track=len(frames) - 1
    ):
        out = processor.postprocess_outputs(session, model_outputs)
        masks = out.get("masks", None)
        if masks is None or len(masks) == 0:
            raw[int(model_outputs.frame_idx)] = None
        else:
            raw[int(model_outputs.frame_idx)] = masks.float().cpu().numpy()

    unions = []
    for i in range(len(frames)):
        m_all = raw.get(i, None)
        if m_all is None:
            unions.append(np.zeros((H, W), dtype=bool))
            continue
        union = np.zeros((H, W), dtype=bool)
        for m in m_all:
            m = np.asarray(m)
            if m.shape != (H, W):
                m = cv2.resize(m.astype(np.float32), (W, H), interpolation=cv2.INTER_NEAREST)
            union |= secret_panda(m > mask_bin_threshold,
                                  close_kernel=close_kernel,
                                  bridge_distance=bridge_distance)
        # joint_mask_post_proc: also clean the union (bridges slivers between people)
        unions.append(secret_panda(union, close_kernel=close_kernel,
                                   bridge_distance=bridge_distance))
    return unions


def foreground_on_gray(frames: List[Image.Image], masks: List[np.ndarray],
                       gray_value: int = 127) -> List[Image.Image]:
    """Keep pixels inside the mask, fill the rest with gray 127. This is the single
    VACE condition ID-V2V consumes. Port of src/idv2v/preprocess/orig_pixel.py."""
    out = []
    for frame, mask in zip(frames, masks):
        rgb = np.asarray(frame.convert("RGB"), dtype=np.uint8)
        comp = np.full_like(rgb, gray_value)
        comp[mask] = rgb[mask]
        out.append(Image.fromarray(comp, "RGB"))
    return out


# --------------------------------------------------------------------------- #
# Finetuned DiT + VACE loading
# --------------------------------------------------------------------------- #
def load_finetuned_dit_vace(pipe, checkpoint_path: str, torch_dtype=torch.bfloat16,
                            delete_checkpoint_after: bool = False):
    """Instantiate empty DiT + VACE on meta, then assign the finetuned fp32 weights and
    cast to bf16. Verbatim logic from _load_finetuned_dit_vace in
    src/idv2v/inference/pipeline.py."""
    from diffsynth.models.utils import init_weights_on_device
    from diffsynth.models.wan_video_dit import WanModel
    from diffsynth.models.wan_video_vace import VaceWanModel

    with init_weights_on_device():
        pipe.dit = WanModel(**I2V_14B_DIT_CONFIG)
        pipe.vace = VaceWanModel(**VACE_14B_CONFIG)

    t0 = time.time()
    state_dict = torch.load(checkpoint_path, map_location="cpu",
                            weights_only=True, mmap=True)
    print(f"[idv2v] mmapped checkpoint in {time.time() - t0:.1f}s "
          f"({len(state_dict)} tensors)", flush=True)

    sd_vace = {k: v for k, v in state_dict.items() if "vace" in k}
    sd_dit = {k: v for k, v in state_dict.items() if "vace" not in k}
    pipe.dit.load_state_dict(sd_dit, assign=True)
    pipe.vace.load_state_dict(sd_vace, assign=True)
    del state_dict, sd_vace, sd_dit

    t0 = time.time()
    pipe.dit = pipe.dit.to(dtype=torch_dtype)
    pipe.vace = pipe.vace.to(dtype=torch_dtype)
    print(f"[idv2v] materialized DiT+VACE in {torch_dtype} in {time.time() - t0:.1f}s",
          flush=True)

    if delete_checkpoint_after:
        # The 78 GB fp32 blob is no longer referenced; free the disk before ZeroGPU
        # packs the bf16 weights back out.
        try:
            real = os.path.realpath(checkpoint_path)
            os.remove(real)
            if os.path.islink(checkpoint_path):
                os.remove(checkpoint_path)
            print(f"[idv2v] removed fp32 checkpoint blob {real}", flush=True)
        except OSError as e:
            print(f"[idv2v] could not remove checkpoint: {e}", flush=True)