Merge remote-tracking branch 'origin/develop' into add-mypy-strict-typecheck
Browse files# Conflicts:
# app.py
# postprocess/interpolation.py
# postprocess/upscale/upscale.py
- app.py +74 -27
- logging_utils.py +22 -11
- model/pipeline.py +52 -10
- postprocess/interpolation.py +30 -8
- postprocess/upscale/upscale.py +129 -37
app.py
CHANGED
|
@@ -5,7 +5,7 @@ import random
|
|
| 5 |
import tempfile
|
| 6 |
import threading
|
| 7 |
import time as _time
|
| 8 |
-
from typing import TYPE_CHECKING
|
| 9 |
|
| 10 |
import gradio as gr
|
| 11 |
import numpy as np
|
|
@@ -42,9 +42,12 @@ from model.pipeline import (
|
|
| 42 |
estimate_generation_seconds,
|
| 43 |
get_num_frames,
|
| 44 |
load_pipeline,
|
|
|
|
| 45 |
resize_image,
|
| 46 |
)
|
|
|
|
| 47 |
from postprocess.interpolation import interpolate_frames
|
|
|
|
| 48 |
|
| 49 |
MAX_SEED = np.iinfo(np.int32).max
|
| 50 |
FRAME_MULTIPLIER_CHOICES = [FIXED_FPS, FIXED_FPS * 2, FIXED_FPS * 4, FIXED_FPS * 8]
|
|
@@ -61,6 +64,12 @@ print_startup_env()
|
|
| 61 |
|
| 62 |
pipe = load_pipeline()
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# Debug logging (LOG-2): gated entirely by LOG_HF_TOKEN/LOG_DATASET_REPO — a silent no-op when
|
| 65 |
# either is unset. Deliberately not the deploy HF_TOKEN: this uses its own write-scoped token so a
|
| 66 |
# leak has a much smaller blast radius (append-only on one dataset repo, not the whole Space repo).
|
|
@@ -68,6 +77,9 @@ _log_uploader = LogUploader(
|
|
| 68 |
token=os.environ.get("LOG_HF_TOKEN"),
|
| 69 |
repo_id=os.environ.get("LOG_DATASET_REPO"),
|
| 70 |
max_bytes=int(float(os.environ.get("LOG_STORAGE_CAP_GB", "10")) * 1024**3),
|
|
|
|
|
|
|
|
|
|
| 71 |
batch_interval=int(os.environ.get("LOG_BATCH_INTERVAL", "60")),
|
| 72 |
)
|
| 73 |
|
|
@@ -85,6 +97,7 @@ _COMMUNITY_MD = (
|
|
| 85 |
|
| 86 |
def _gpu_duration(
|
| 87 |
resized_image: Image.Image,
|
|
|
|
| 88 |
prompt: str,
|
| 89 |
negative_prompt: str,
|
| 90 |
steps: int,
|
|
@@ -114,8 +127,8 @@ def _gpu_duration(
|
|
| 114 |
|
| 115 |
|
| 116 |
def _apply_interpolation(
|
| 117 |
-
raw_frames: FrameArray, frame_factor: int, progress: gr.Progress
|
| 118 |
-
) -> tuple[list[FrameArray], int]:
|
| 119 |
if frame_factor <= 1:
|
| 120 |
return list(raw_frames), FIXED_FPS
|
| 121 |
|
|
@@ -125,8 +138,13 @@ def _apply_interpolation(
|
|
| 125 |
print_stage_start("interpolation")
|
| 126 |
t0 = _time.perf_counter()
|
| 127 |
try:
|
|
|
|
|
|
|
| 128 |
interpolated = interpolate_frames(
|
| 129 |
-
raw_frames,
|
|
|
|
|
|
|
|
|
|
| 130 |
)
|
| 131 |
print_stage_done("interpolation", _time.perf_counter() - t0)
|
| 132 |
return interpolated, FIXED_FPS * frame_factor
|
|
@@ -137,7 +155,19 @@ def _apply_interpolation(
|
|
| 137 |
return list(raw_frames), FIXED_FPS
|
| 138 |
|
| 139 |
|
| 140 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
from postprocess.upscale import upscale_frames
|
| 142 |
|
| 143 |
def _report_upscale_progress(done: int, total: int) -> None:
|
|
@@ -147,10 +177,7 @@ def _apply_upscale(frames: list[FrameArray], progress: gr.Progress) -> list[Imag
|
|
| 147 |
print_stage_start("upscaling")
|
| 148 |
t0 = _time.perf_counter()
|
| 149 |
try:
|
| 150 |
-
|
| 151 |
-
Image.fromarray((np.asarray(frame) * 255.0).round().astype(np.uint8)) for frame in frames
|
| 152 |
-
]
|
| 153 |
-
upscaled = upscale_frames(pil_frames, progress_callback=_report_upscale_progress)
|
| 154 |
print_stage_done("upscaling", _time.perf_counter() - t0)
|
| 155 |
return upscaled
|
| 156 |
except Exception as e:
|
|
@@ -163,6 +190,7 @@ def _apply_upscale(frames: list[FrameArray], progress: gr.Progress) -> list[Imag
|
|
| 163 |
@spaces.GPU(duration=_gpu_duration) # type: ignore[untyped-decorator] # spaces ships no type stubs
|
| 164 |
def run_inference(
|
| 165 |
resized_image: Image.Image,
|
|
|
|
| 166 |
prompt: str,
|
| 167 |
negative_prompt: str,
|
| 168 |
steps: int,
|
|
@@ -172,15 +200,22 @@ def run_inference(
|
|
| 172 |
frame_multiplier: int,
|
| 173 |
upscale_output: bool,
|
| 174 |
progress: gr.Progress = gr.Progress(track_tqdm=True),
|
| 175 |
-
) -> tuple[Frames, int
|
| 176 |
print_infer_start(prompt, negative_prompt, seed, steps, guidance_scale, frame_multiplier, upscale_output)
|
| 177 |
t_start = _time.perf_counter()
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
print_stage_start("generation")
|
| 180 |
t0 = _time.perf_counter()
|
| 181 |
try:
|
| 182 |
result = pipe(
|
| 183 |
image=resized_image,
|
|
|
|
| 184 |
prompt=prompt,
|
| 185 |
negative_prompt=negative_prompt,
|
| 186 |
height=resized_image.height,
|
|
@@ -190,6 +225,7 @@ def run_inference(
|
|
| 190 |
num_inference_steps=int(steps),
|
| 191 |
generator=torch.Generator(device="cuda").manual_seed(seed),
|
| 192 |
output_type="np",
|
|
|
|
| 193 |
)
|
| 194 |
except Exception as e:
|
| 195 |
print_stage_error("generation", e)
|
|
@@ -198,23 +234,27 @@ def run_inference(
|
|
| 198 |
raw_frames = result.frames[0] # (T, H, W, C) float32 in [0, 1]
|
| 199 |
|
| 200 |
frame_factor = frame_multiplier // FIXED_FPS
|
| 201 |
-
final_frames, final_fps = _apply_interpolation(raw_frames, frame_factor, progress)
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
upscaled_frames = _apply_upscale(final_frames, progress) if upscale_output else None
|
| 204 |
|
| 205 |
if upscaled_frames is None:
|
|
|
|
|
|
|
|
|
|
| 206 |
print_infer_done(_time.perf_counter() - t_start, final_fps, len(final_frames))
|
| 207 |
-
return final_frames, final_fps
|
| 208 |
|
| 209 |
-
# LOG-4: the debug log records the pre-upscale (post-interpolation) result, not the final
|
| 210 |
-
# upscaled video and not the raw un-interpolated base output.
|
| 211 |
print_infer_done(_time.perf_counter() - t_start, final_fps, len(upscaled_frames))
|
| 212 |
-
return upscaled_frames, final_fps
|
| 213 |
|
| 214 |
|
| 215 |
def _spawn_log(
|
| 216 |
input_image: Image.Image,
|
| 217 |
-
output_frames:
|
| 218 |
output_fps: int | None,
|
| 219 |
prompt: str,
|
| 220 |
negative_prompt: str,
|
|
@@ -247,6 +287,7 @@ def _spawn_log(
|
|
| 247 |
|
| 248 |
def generate_video(
|
| 249 |
input_image: Image.Image | None,
|
|
|
|
| 250 |
prompt: str,
|
| 251 |
negative_prompt: str,
|
| 252 |
duration_seconds: float,
|
|
@@ -262,6 +303,7 @@ def generate_video(
|
|
| 262 |
raise gr.Error("Please upload an input image.")
|
| 263 |
|
| 264 |
resized_image = resize_image(input_image)
|
|
|
|
| 265 |
num_frames = get_num_frames(duration_seconds)
|
| 266 |
current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
|
| 267 |
|
|
@@ -271,8 +313,9 @@ def generate_video(
|
|
| 271 |
|
| 272 |
t0 = _time.perf_counter()
|
| 273 |
try:
|
| 274 |
-
final_frames, final_fps
|
| 275 |
resized_image,
|
|
|
|
| 276 |
prompt,
|
| 277 |
negative_prompt,
|
| 278 |
steps,
|
|
@@ -299,7 +342,6 @@ def generate_video(
|
|
| 299 |
duration = _time.perf_counter() - t0
|
| 300 |
|
| 301 |
print_frames_info("final_frames", final_frames, final_fps)
|
| 302 |
-
print_frames_info("pre_upscale_frames", pre_upscale_frames, pre_upscale_fps)
|
| 303 |
|
| 304 |
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
|
| 305 |
video_path = tmpfile.name
|
|
@@ -318,9 +360,8 @@ def generate_video(
|
|
| 318 |
# run_inference — a video was still produced, so this still counts as success=True.
|
| 319 |
# interpolation_enabled/upscale_output reflect what the user requested, not whether an
|
| 320 |
# internal fallback fired.
|
| 321 |
-
log_frames = pre_upscale_frames if pre_upscale_frames is not None else final_frames
|
| 322 |
_spawn_log(
|
| 323 |
-
resized_image,
|
| 324 |
steps, guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_output,
|
| 325 |
resized_image.width, resized_image.height, duration, True,
|
| 326 |
)
|
|
@@ -355,6 +396,10 @@ with gr.Blocks() as demo:
|
|
| 355 |
"- Service availability and response times may vary as this Space runs on shared GPU infrastructure.\n"
|
| 356 |
"- When using this Space, please comply with the "
|
| 357 |
"[Hugging Face Content Policy](https://huggingface.co/content-policy).\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
f"- To monitor application performance and improve quality, input data (image/prompt) and generated "
|
| 359 |
f"outputs are logged solely for debugging purposes and retained in a private Hugging Face dataset "
|
| 360 |
f"accessible only to {_OPERATOR_MD}, automatically pruned once the dataset reaches its configured "
|
|
@@ -369,11 +414,11 @@ with gr.Blocks() as demo:
|
|
| 369 |
gr.Markdown(
|
| 370 |
f"**Space:** High Quality Video Generation \n"
|
| 371 |
f"**Operator:** {_OPERATOR_MD} \n"
|
| 372 |
-
f"**Last updated:** 2026-
|
| 373 |
"\n"
|
| 374 |
"#### What data is collected\n"
|
| 375 |
"When you submit a request, the following is logged: your uploaded input image, the generated "
|
| 376 |
-
"output video
|
| 377 |
"steps, guidance scale, frame-interpolation and upscaling settings, output resolution/fps/duration, "
|
| 378 |
"generation duration, and success/error status. No user account, IP address, or session data is "
|
| 379 |
"collected.\n"
|
|
@@ -428,6 +473,11 @@ with gr.Blocks() as demo:
|
|
| 428 |
"interpolation, within the same GPU allocation, and adds to the GPU quota used.",
|
| 429 |
)
|
| 430 |
with gr.Accordion("Advanced Settings", open=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
negative_prompt_input = gr.Textbox(label="Negative Prompt", value=DEFAULT_NEGATIVE_PROMPT, lines=3)
|
| 432 |
steps_slider = gr.Slider(minimum=1, maximum=30, step=1, value=6, label="Inference Steps")
|
| 433 |
guidance_scale_input = gr.Slider(minimum=0.0, maximum=10.0, step=0.5, value=1.0, label="Guidance Scale")
|
|
@@ -443,6 +493,7 @@ with gr.Blocks() as demo:
|
|
| 443 |
fn=generate_video,
|
| 444 |
inputs=[
|
| 445 |
input_image_component,
|
|
|
|
| 446 |
prompt_input,
|
| 447 |
negative_prompt_input,
|
| 448 |
duration_input,
|
|
@@ -458,11 +509,7 @@ with gr.Blocks() as demo:
|
|
| 458 |
|
| 459 |
preview_inputs = [input_image_component, duration_input, frame_multiplier_input, upscale_checkbox]
|
| 460 |
for component in preview_inputs:
|
| 461 |
-
|
| 462 |
-
# stubs only declare .change on the concrete subclasses, not the base.
|
| 463 |
-
component.change( # type: ignore[attr-defined]
|
| 464 |
-
fn=preview_effect, inputs=preview_inputs, outputs=effect_preview
|
| 465 |
-
)
|
| 466 |
demo.load(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
|
| 467 |
|
| 468 |
if __name__ == "__main__":
|
|
|
|
| 5 |
import tempfile
|
| 6 |
import threading
|
| 7 |
import time as _time
|
| 8 |
+
from typing import TYPE_CHECKING, Any
|
| 9 |
|
| 10 |
import gradio as gr
|
| 11 |
import numpy as np
|
|
|
|
| 42 |
estimate_generation_seconds,
|
| 43 |
get_num_frames,
|
| 44 |
load_pipeline,
|
| 45 |
+
resize_and_crop_to_match,
|
| 46 |
resize_image,
|
| 47 |
)
|
| 48 |
+
from postprocess.interpolation import ensure_weights_downloaded as ensure_rife_weights_downloaded
|
| 49 |
from postprocess.interpolation import interpolate_frames
|
| 50 |
+
from postprocess.upscale.upscale import ensure_weights_downloaded as ensure_upscale_weights_downloaded
|
| 51 |
|
| 52 |
MAX_SEED = np.iinfo(np.int32).max
|
| 53 |
FRAME_MULTIPLIER_CHOICES = [FIXED_FPS, FIXED_FPS * 2, FIXED_FPS * 4, FIXED_FPS * 8]
|
|
|
|
| 64 |
|
| 65 |
pipe = load_pipeline()
|
| 66 |
|
| 67 |
+
# RIFE/upscale weight fetches are CPU/network-only (no CUDA needed) — done here, at process
|
| 68 |
+
# startup, so a cold container doesn't pay for them out of the metered @spaces.GPU allocation
|
| 69 |
+
# on its first interpolation/upscale request.
|
| 70 |
+
ensure_rife_weights_downloaded()
|
| 71 |
+
ensure_upscale_weights_downloaded()
|
| 72 |
+
|
| 73 |
# Debug logging (LOG-2): gated entirely by LOG_HF_TOKEN/LOG_DATASET_REPO — a silent no-op when
|
| 74 |
# either is unset. Deliberately not the deploy HF_TOKEN: this uses its own write-scoped token so a
|
| 75 |
# leak has a much smaller blast radius (append-only on one dataset repo, not the whole Space repo).
|
|
|
|
| 77 |
token=os.environ.get("LOG_HF_TOKEN"),
|
| 78 |
repo_id=os.environ.get("LOG_DATASET_REPO"),
|
| 79 |
max_bytes=int(float(os.environ.get("LOG_STORAGE_CAP_GB", "10")) * 1024**3),
|
| 80 |
+
# Hub's commit endpoint rejects pushes once a directory holds >10000 files; stay well under
|
| 81 |
+
# that per-directory cap (each logged stem adds one file to data/, images/, and videos/).
|
| 82 |
+
max_files=int(os.environ.get("LOG_MAX_FILES", "8000")),
|
| 83 |
batch_interval=int(os.environ.get("LOG_BATCH_INTERVAL", "60")),
|
| 84 |
)
|
| 85 |
|
|
|
|
| 97 |
|
| 98 |
def _gpu_duration(
|
| 99 |
resized_image: Image.Image,
|
| 100 |
+
processed_last_image: Image.Image | None,
|
| 101 |
prompt: str,
|
| 102 |
negative_prompt: str,
|
| 103 |
steps: int,
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
def _apply_interpolation(
|
| 130 |
+
raw_frames: FrameArray, frame_factor: int, upscale_output: bool, progress: gr.Progress
|
| 131 |
+
) -> tuple[list[FrameArray] | list[torch.Tensor], int]:
|
| 132 |
if frame_factor <= 1:
|
| 133 |
return list(raw_frames), FIXED_FPS
|
| 134 |
|
|
|
|
| 138 |
print_stage_start("interpolation")
|
| 139 |
t0 = _time.perf_counter()
|
| 140 |
try:
|
| 141 |
+
# When upscale will run right after, hand it GPU tensors directly instead of round-
|
| 142 |
+
# tripping through CPU numpy in between (both stages run in the same @spaces.GPU call).
|
| 143 |
interpolated = interpolate_frames(
|
| 144 |
+
raw_frames,
|
| 145 |
+
multiplier=int(frame_factor),
|
| 146 |
+
progress_callback=_report_interpolation_progress,
|
| 147 |
+
as_tensor=upscale_output,
|
| 148 |
)
|
| 149 |
print_stage_done("interpolation", _time.perf_counter() - t0)
|
| 150 |
return interpolated, FIXED_FPS * frame_factor
|
|
|
|
| 155 |
return list(raw_frames), FIXED_FPS
|
| 156 |
|
| 157 |
|
| 158 |
+
def _frames_to_numpy(frames: list[FrameArray] | list[torch.Tensor]) -> list[FrameArray]:
|
| 159 |
+
# Fallback-path conversion only: interpolation may have handed back GPU tensors (C,H,W)
|
| 160 |
+
# expecting upscale to consume them next (see _apply_interpolation's as_tensor). If upscale
|
| 161 |
+
# then fails or wasn't requested, callers downstream (export_to_video, logging) need plain
|
| 162 |
+
# (H,W,C) numpy — this is a no-op when frames are already numpy.
|
| 163 |
+
if frames and isinstance(frames[0], torch.Tensor):
|
| 164 |
+
return [f.permute(1, 2, 0).float().cpu().numpy() for f in frames]
|
| 165 |
+
return frames # type: ignore[return-value] # narrowed by the isinstance check above
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _apply_upscale(
|
| 169 |
+
frames: list[FrameArray] | list[torch.Tensor], progress: gr.Progress
|
| 170 |
+
) -> list[Image.Image] | None:
|
| 171 |
from postprocess.upscale import upscale_frames
|
| 172 |
|
| 173 |
def _report_upscale_progress(done: int, total: int) -> None:
|
|
|
|
| 177 |
print_stage_start("upscaling")
|
| 178 |
t0 = _time.perf_counter()
|
| 179 |
try:
|
| 180 |
+
upscaled = upscale_frames(frames, progress_callback=_report_upscale_progress)
|
|
|
|
|
|
|
|
|
|
| 181 |
print_stage_done("upscaling", _time.perf_counter() - t0)
|
| 182 |
return upscaled
|
| 183 |
except Exception as e:
|
|
|
|
| 190 |
@spaces.GPU(duration=_gpu_duration) # type: ignore[untyped-decorator] # spaces ships no type stubs
|
| 191 |
def run_inference(
|
| 192 |
resized_image: Image.Image,
|
| 193 |
+
processed_last_image: Image.Image | None,
|
| 194 |
prompt: str,
|
| 195 |
negative_prompt: str,
|
| 196 |
steps: int,
|
|
|
|
| 200 |
frame_multiplier: int,
|
| 201 |
upscale_output: bool,
|
| 202 |
progress: gr.Progress = gr.Progress(track_tqdm=True),
|
| 203 |
+
) -> tuple[Frames, int]:
|
| 204 |
print_infer_start(prompt, negative_prompt, seed, steps, guidance_scale, frame_multiplier, upscale_output)
|
| 205 |
t_start = _time.perf_counter()
|
| 206 |
|
| 207 |
+
def _report_generation_progress(
|
| 208 |
+
pipe_: Any, step_index: int, timestep: int, callback_kwargs: dict[str, Any]
|
| 209 |
+
) -> dict[str, Any]:
|
| 210 |
+
progress(0.7 * (step_index + 1) / int(steps), desc=f"Generating ({step_index + 1}/{int(steps)} steps)...")
|
| 211 |
+
return callback_kwargs
|
| 212 |
+
|
| 213 |
print_stage_start("generation")
|
| 214 |
t0 = _time.perf_counter()
|
| 215 |
try:
|
| 216 |
result = pipe(
|
| 217 |
image=resized_image,
|
| 218 |
+
last_image=processed_last_image,
|
| 219 |
prompt=prompt,
|
| 220 |
negative_prompt=negative_prompt,
|
| 221 |
height=resized_image.height,
|
|
|
|
| 225 |
num_inference_steps=int(steps),
|
| 226 |
generator=torch.Generator(device="cuda").manual_seed(seed),
|
| 227 |
output_type="np",
|
| 228 |
+
callback_on_step_end=_report_generation_progress,
|
| 229 |
)
|
| 230 |
except Exception as e:
|
| 231 |
print_stage_error("generation", e)
|
|
|
|
| 234 |
raw_frames = result.frames[0] # (T, H, W, C) float32 in [0, 1]
|
| 235 |
|
| 236 |
frame_factor = frame_multiplier // FIXED_FPS
|
| 237 |
+
final_frames, final_fps = _apply_interpolation(raw_frames, frame_factor, upscale_output, progress)
|
| 238 |
+
# Confirms which handoff mode actually ran: frame_type=Tensor (GPU, no CPU round trip) when
|
| 239 |
+
# interpolation->upscale both ran, vs. ndarray otherwise. See issue #53.
|
| 240 |
+
print_frames_info("interpolation_output", final_frames, final_fps)
|
| 241 |
|
| 242 |
upscaled_frames = _apply_upscale(final_frames, progress) if upscale_output else None
|
| 243 |
|
| 244 |
if upscaled_frames is None:
|
| 245 |
+
# Covers both upscale_output=False (already numpy, no-op) and upscale_output=True but
|
| 246 |
+
# upscale failing (final_frames may be GPU tensors from the as_tensor interpolation path).
|
| 247 |
+
final_frames = _frames_to_numpy(final_frames)
|
| 248 |
print_infer_done(_time.perf_counter() - t_start, final_fps, len(final_frames))
|
| 249 |
+
return final_frames, final_fps
|
| 250 |
|
|
|
|
|
|
|
| 251 |
print_infer_done(_time.perf_counter() - t_start, final_fps, len(upscaled_frames))
|
| 252 |
+
return upscaled_frames, final_fps
|
| 253 |
|
| 254 |
|
| 255 |
def _spawn_log(
|
| 256 |
input_image: Image.Image,
|
| 257 |
+
output_frames: Frames | None,
|
| 258 |
output_fps: int | None,
|
| 259 |
prompt: str,
|
| 260 |
negative_prompt: str,
|
|
|
|
| 287 |
|
| 288 |
def generate_video(
|
| 289 |
input_image: Image.Image | None,
|
| 290 |
+
last_image: Image.Image | None,
|
| 291 |
prompt: str,
|
| 292 |
negative_prompt: str,
|
| 293 |
duration_seconds: float,
|
|
|
|
| 303 |
raise gr.Error("Please upload an input image.")
|
| 304 |
|
| 305 |
resized_image = resize_image(input_image)
|
| 306 |
+
processed_last_image = resize_and_crop_to_match(last_image, resized_image) if last_image is not None else None
|
| 307 |
num_frames = get_num_frames(duration_seconds)
|
| 308 |
current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
|
| 309 |
|
|
|
|
| 313 |
|
| 314 |
t0 = _time.perf_counter()
|
| 315 |
try:
|
| 316 |
+
final_frames, final_fps = run_inference(
|
| 317 |
resized_image,
|
| 318 |
+
processed_last_image,
|
| 319 |
prompt,
|
| 320 |
negative_prompt,
|
| 321 |
steps,
|
|
|
|
| 342 |
duration = _time.perf_counter() - t0
|
| 343 |
|
| 344 |
print_frames_info("final_frames", final_frames, final_fps)
|
|
|
|
| 345 |
|
| 346 |
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpfile:
|
| 347 |
video_path = tmpfile.name
|
|
|
|
| 360 |
# run_inference — a video was still produced, so this still counts as success=True.
|
| 361 |
# interpolation_enabled/upscale_output reflect what the user requested, not whether an
|
| 362 |
# internal fallback fired.
|
|
|
|
| 363 |
_spawn_log(
|
| 364 |
+
resized_image, final_frames, final_fps, prompt, negative_prompt, current_seed,
|
| 365 |
steps, guidance_scale, interpolation_enabled, interpolation_multiplier, upscale_output,
|
| 366 |
resized_image.width, resized_image.height, duration, True,
|
| 367 |
)
|
|
|
|
| 396 |
"- Service availability and response times may vary as this Space runs on shared GPU infrastructure.\n"
|
| 397 |
"- When using this Space, please comply with the "
|
| 398 |
"[Hugging Face Content Policy](https://huggingface.co/content-policy).\n"
|
| 399 |
+
"- This Space and its outputs are provided **\"as is\"**, without warranties of any kind. You are "
|
| 400 |
+
"solely responsible for the content you generate and for how you use or share it; the operator "
|
| 401 |
+
"accepts no liability for any loss, damage, or claim arising from use of this Space or its "
|
| 402 |
+
"outputs.\n"
|
| 403 |
f"- To monitor application performance and improve quality, input data (image/prompt) and generated "
|
| 404 |
f"outputs are logged solely for debugging purposes and retained in a private Hugging Face dataset "
|
| 405 |
f"accessible only to {_OPERATOR_MD}, automatically pruned once the dataset reaches its configured "
|
|
|
|
| 414 |
gr.Markdown(
|
| 415 |
f"**Space:** High Quality Video Generation \n"
|
| 416 |
f"**Operator:** {_OPERATOR_MD} \n"
|
| 417 |
+
f"**Last updated:** 2026-09-18\n"
|
| 418 |
"\n"
|
| 419 |
"#### What data is collected\n"
|
| 420 |
"When you submit a request, the following is logged: your uploaded input image, the generated "
|
| 421 |
+
"output video, your prompt and negative prompt, seed, inference "
|
| 422 |
"steps, guidance scale, frame-interpolation and upscaling settings, output resolution/fps/duration, "
|
| 423 |
"generation duration, and success/error status. No user account, IP address, or session data is "
|
| 424 |
"collected.\n"
|
|
|
|
| 473 |
"interpolation, within the same GPU allocation, and adds to the GPU quota used.",
|
| 474 |
)
|
| 475 |
with gr.Accordion("Advanced Settings", open=False):
|
| 476 |
+
last_image_component = gr.Image(
|
| 477 |
+
type="pil",
|
| 478 |
+
label="Last Image (Optional) — end frame to animate towards",
|
| 479 |
+
sources=["upload", "clipboard"],
|
| 480 |
+
)
|
| 481 |
negative_prompt_input = gr.Textbox(label="Negative Prompt", value=DEFAULT_NEGATIVE_PROMPT, lines=3)
|
| 482 |
steps_slider = gr.Slider(minimum=1, maximum=30, step=1, value=6, label="Inference Steps")
|
| 483 |
guidance_scale_input = gr.Slider(minimum=0.0, maximum=10.0, step=0.5, value=1.0, label="Guidance Scale")
|
|
|
|
| 493 |
fn=generate_video,
|
| 494 |
inputs=[
|
| 495 |
input_image_component,
|
| 496 |
+
last_image_component,
|
| 497 |
prompt_input,
|
| 498 |
negative_prompt_input,
|
| 499 |
duration_input,
|
|
|
|
| 509 |
|
| 510 |
preview_inputs = [input_image_component, duration_input, frame_multiplier_input, upscale_checkbox]
|
| 511 |
for component in preview_inputs:
|
| 512 |
+
component.change(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
demo.load(fn=preview_effect, inputs=preview_inputs, outputs=effect_preview)
|
| 514 |
|
| 515 |
if __name__ == "__main__":
|
logging_utils.py
CHANGED
|
@@ -3,8 +3,11 @@ Face Hub dataset repo (LOG-2 through LOG-9).
|
|
| 3 |
|
| 4 |
Modeled on the FireRed-Image-Edit-1.0-Fast reference project's logging_utils.py/inference.py
|
| 5 |
pipeline, adapted for video: logged media is referenced by path rather than embedded as bytes
|
| 6 |
-
(LOG-5), the logged output video is the
|
| 7 |
-
total-storage-size cap
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
|
@@ -271,19 +274,26 @@ def _group_by_stem(existing: list[tuple[str, int]]) -> dict[str, dict[str, Any]]
|
|
| 271 |
return groups
|
| 272 |
|
| 273 |
|
| 274 |
-
def
|
| 275 |
-
|
| 276 |
groups = _group_by_stem(existing)
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
return []
|
| 280 |
ops: list[CommitOperationDelete] = []
|
| 281 |
for stem in sorted(groups):
|
| 282 |
-
if
|
| 283 |
break
|
| 284 |
group = groups[stem]
|
| 285 |
ops.extend(CommitOperationDelete(path_in_repo=p) for p in group["paths"])
|
| 286 |
-
|
|
|
|
| 287 |
return ops
|
| 288 |
|
| 289 |
|
|
@@ -313,10 +323,12 @@ def _delete_temp_files(paths: list[str]) -> None:
|
|
| 313 |
|
| 314 |
|
| 315 |
class LogUploader:
|
| 316 |
-
def __init__(self, token: str | None, repo_id: str | None, max_bytes: int,
|
|
|
|
| 317 |
self._token = token
|
| 318 |
self._repo_id = repo_id
|
| 319 |
self._max_bytes = max_bytes
|
|
|
|
| 320 |
self._batch_interval = batch_interval
|
| 321 |
self._pending: list[tuple[str, str]] = []
|
| 322 |
self._lock = threading.Lock()
|
|
@@ -412,9 +424,8 @@ class LogUploader:
|
|
| 412 |
api = HfApi(token=self._token)
|
| 413 |
api.create_repo(repo_id=self._repo_id, repo_type="dataset", private=True, exist_ok=True)
|
| 414 |
existing = _list_existing_files_with_sizes(api, self._repo_id)
|
| 415 |
-
new_batch_size = sum(os.path.getsize(local) for _, local in batch)
|
| 416 |
add_ops = [CommitOperationAdd(path_in_repo=p, path_or_fileobj=local) for p, local in batch]
|
| 417 |
-
del_ops =
|
| 418 |
api.create_commit(
|
| 419 |
repo_id=self._repo_id, repo_type="dataset",
|
| 420 |
operations=[*add_ops, *del_ops],
|
|
|
|
| 3 |
|
| 4 |
Modeled on the FireRed-Image-Edit-1.0-Fast reference project's logging_utils.py/inference.py
|
| 5 |
pipeline, adapted for video: logged media is referenced by path rather than embedded as bytes
|
| 6 |
+
(LOG-5), the logged output video is the same final result served to the user, and retention
|
| 7 |
+
prunes on both a total-storage-size cap and a per-directory file-count cap (LOG-7) — the latter exists
|
| 8 |
+
because the Hub commit endpoint rejects pushes once a directory (data/, images/, videos/)
|
| 9 |
+
holds more than 10000 files, which a size-only cap doesn't bound since data/ and images/
|
| 10 |
+
files are tiny compared to videos/.
|
| 11 |
"""
|
| 12 |
|
| 13 |
import json
|
|
|
|
| 274 |
return groups
|
| 275 |
|
| 276 |
|
| 277 |
+
def _build_delete_ops(existing: list[tuple[str, int]], new_batch: list[tuple[str, str]],
|
| 278 |
+
max_bytes: int, max_files: int) -> list[CommitOperationDelete]:
|
| 279 |
groups = _group_by_stem(existing)
|
| 280 |
+
new_stems = {_stem_of(p) for p, _ in new_batch}
|
| 281 |
+
total_bytes = sum(g["size"] for g in groups.values()) + sum(os.path.getsize(local) for _, local in new_batch)
|
| 282 |
+
total_files = len(groups) + len(new_stems)
|
| 283 |
+
|
| 284 |
+
def _over_cap() -> bool:
|
| 285 |
+
return (max_bytes > 0 and total_bytes > max_bytes) or (max_files > 0 and total_files > max_files)
|
| 286 |
+
|
| 287 |
+
if not _over_cap():
|
| 288 |
return []
|
| 289 |
ops: list[CommitOperationDelete] = []
|
| 290 |
for stem in sorted(groups):
|
| 291 |
+
if not _over_cap():
|
| 292 |
break
|
| 293 |
group = groups[stem]
|
| 294 |
ops.extend(CommitOperationDelete(path_in_repo=p) for p in group["paths"])
|
| 295 |
+
total_bytes -= group["size"]
|
| 296 |
+
total_files -= 1
|
| 297 |
return ops
|
| 298 |
|
| 299 |
|
|
|
|
| 323 |
|
| 324 |
|
| 325 |
class LogUploader:
|
| 326 |
+
def __init__(self, token: str | None, repo_id: str | None, max_bytes: int, max_files: int,
|
| 327 |
+
batch_interval: int = 60) -> None:
|
| 328 |
self._token = token
|
| 329 |
self._repo_id = repo_id
|
| 330 |
self._max_bytes = max_bytes
|
| 331 |
+
self._max_files = max_files
|
| 332 |
self._batch_interval = batch_interval
|
| 333 |
self._pending: list[tuple[str, str]] = []
|
| 334 |
self._lock = threading.Lock()
|
|
|
|
| 424 |
api = HfApi(token=self._token)
|
| 425 |
api.create_repo(repo_id=self._repo_id, repo_type="dataset", private=True, exist_ok=True)
|
| 426 |
existing = _list_existing_files_with_sizes(api, self._repo_id)
|
|
|
|
| 427 |
add_ops = [CommitOperationAdd(path_in_repo=p, path_or_fileobj=local) for p, local in batch]
|
| 428 |
+
del_ops = _build_delete_ops(existing, batch, self._max_bytes, self._max_files)
|
| 429 |
api.create_commit(
|
| 430 |
repo_id=self._repo_id, repo_type="dataset",
|
| 431 |
operations=[*add_ops, *del_ops],
|
model/pipeline.py
CHANGED
|
@@ -1,14 +1,21 @@
|
|
| 1 |
"""WAMU_v3 image-to-video pipeline loading and sizing helpers.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
| 12 |
import torch
|
| 13 |
import torch._dynamo
|
| 14 |
from diffusers.pipelines.wan.pipeline_wan_i2v import WanImageToVideoPipeline
|
|
@@ -17,6 +24,15 @@ from torchao.quantization import Float8DynamicActivationFloat8WeightConfig, Int8
|
|
| 17 |
|
| 18 |
MODEL_ID = "thornmaze/WAMU_v3_WAN2.2_I2V_LIGHTNING"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
MAX_DIM = 832
|
| 21 |
MIN_DIM = 480
|
| 22 |
SQUARE_DIM = 640
|
|
@@ -41,7 +57,6 @@ def load_pipeline() -> WanImageToVideoPipeline:
|
|
| 41 |
pipe = WanImageToVideoPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
| 42 |
|
| 43 |
# Quantize to shrink the ~69GB bf16 pipe (drops per-call ZeroGPU init time and peak memory).
|
| 44 |
-
# Independent of AOT compilation (FR-6/C-2, deferred) — same recipe as the reference project.
|
| 45 |
quantize_(pipe.text_encoder, Int8WeightOnlyConfig())
|
| 46 |
torch._dynamo.reset()
|
| 47 |
quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
|
|
@@ -49,6 +64,18 @@ def load_pipeline() -> WanImageToVideoPipeline:
|
|
| 49 |
quantize_(pipe.transformer_2, Float8DynamicActivationFloat8WeightConfig())
|
| 50 |
torch._dynamo.reset()
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return pipe
|
| 53 |
|
| 54 |
|
|
@@ -86,6 +113,18 @@ def resize_image(image: Image.Image) -> Image.Image:
|
|
| 86 |
return image_to_resize.resize((final_w, final_h), Image.Resampling.LANCZOS)
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def get_num_frames(duration_seconds: float) -> int:
|
| 90 |
raw = int(round(duration_seconds * FIXED_FPS))
|
| 91 |
raw = max(MIN_FRAMES_MODEL, min(MAX_FRAMES_MODEL, raw))
|
|
@@ -99,6 +138,10 @@ def estimate_generation_seconds(
|
|
| 99 |
|
| 100 |
Exact limits are still an open item (SRS Section 4, #3) pending empirical testing on the
|
| 101 |
dev Space; this heuristic is a starting point, not a final calibration.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
"""
|
| 103 |
base_frames_height_width = 81 * 832 * 624
|
| 104 |
base_step_duration = 5.0
|
|
@@ -108,9 +151,8 @@ def estimate_generation_seconds(
|
|
| 108 |
if guidance_scale > 1:
|
| 109 |
gen_time *= 2.4
|
| 110 |
|
| 111 |
-
#
|
| 112 |
-
#
|
| 113 |
-
#
|
| 114 |
-
# until this can be measured empirically on the dev Space.
|
| 115 |
init_overhead = 60
|
| 116 |
return init_overhead + gen_time
|
|
|
|
| 1 |
"""WAMU_v3 image-to-video pipeline loading and sizing helpers.
|
| 2 |
|
| 3 |
+
Weight quantization (below) shrinks the full bf16 pipe from ~69GB, which made ZeroGPU's per-call
|
| 4 |
+
GPU init (loading the packed weights onto the GPU) exceed the requested call duration. The
|
| 5 |
+
transformers additionally get an AOT-compiled forward (FR-6) via `spaces.aoti_load()`, matching
|
| 6 |
+
the reference project: this is what makes `Float8DynamicActivationFloat8WeightConfig` (fp8x8
|
| 7 |
+
matmuls, both weights and activations quantized) safe to run. AOTInductor pre-plans a single
|
| 8 |
+
memory pool for every op in the compiled graph, including that config's per-layer
|
| 9 |
+
activation-quantize casts; run the same config eagerly (no AOT), as an earlier version of this
|
| 10 |
+
file did, and every one of those casts is an unplanned CUDA allocator request instead — that
|
| 11 |
+
churn reliably crashed ZeroGPU with an NVML assert (#35), fixed at the time by downgrading to
|
| 12 |
+
weight-only fp8 (no runtime activation quantization) as a stopgap. See #36 for the AOT
|
| 13 |
+
investigation (package-reuse feasibility) that unblocked switching back.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import spaces
|
| 19 |
import torch
|
| 20 |
import torch._dynamo
|
| 21 |
from diffusers.pipelines.wan.pipeline_wan_i2v import WanImageToVideoPipeline
|
|
|
|
| 24 |
|
| 25 |
MODEL_ID = "thornmaze/WAMU_v3_WAN2.2_I2V_LIGHTNING"
|
| 26 |
|
| 27 |
+
# Precompiled AOT package for the transformer, from the reference project (zerogpu-aoti's
|
| 28 |
+
# wan2-2-fp8da-aoti-faster Space). Confirmed reusable for WAMU_v3 without recompiling (#36):
|
| 29 |
+
# WAMU_v3's transformer/transformer_2 share the exact same config (layer count, head dims, etc.)
|
| 30 |
+
# as the Wan2.2-I2V-A14B base model this package was compiled against — `spaces.aoti_load()`
|
| 31 |
+
# applies compiled kernels to an already-loaded model rather than embedding weights, so
|
| 32 |
+
# compatibility hinges on that shape/architecture match, not on WAMU_v3's fine-tuned weight
|
| 33 |
+
# values. "sm120" in the name matches ZeroGPU's current hardware (RTX Pro 6000 Blackwell).
|
| 34 |
+
AOTI_TRANSFORMER_REPO = "thornmaze/WanTransformer3DModel-sm120-cu130-raa"
|
| 35 |
+
|
| 36 |
MAX_DIM = 832
|
| 37 |
MIN_DIM = 480
|
| 38 |
SQUARE_DIM = 640
|
|
|
|
| 57 |
pipe = WanImageToVideoPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
| 58 |
|
| 59 |
# Quantize to shrink the ~69GB bf16 pipe (drops per-call ZeroGPU init time and peak memory).
|
|
|
|
| 60 |
quantize_(pipe.text_encoder, Int8WeightOnlyConfig())
|
| 61 |
torch._dynamo.reset()
|
| 62 |
quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
|
|
|
|
| 64 |
quantize_(pipe.transformer_2, Float8DynamicActivationFloat8WeightConfig())
|
| 65 |
torch._dynamo.reset()
|
| 66 |
|
| 67 |
+
# AOT-compile the transformers (FR-6) — see module docstring and #36 for why this must come
|
| 68 |
+
# after the dynamic-activation quantize_() calls above, not before: the compiled graph is
|
| 69 |
+
# built from (and expects) that config's op layout, matching how the reference project's own
|
| 70 |
+
# usage example sequences it.
|
| 71 |
+
#
|
| 72 |
+
# Not yet validated against this repo's pinned torch==2.11.0: the package was compiled with
|
| 73 |
+
# torch==2.9.1+cu130, and AOTInductor artifacts can be ABI-sensitive across torch minor
|
| 74 |
+
# versions. Needs real-GPU testing on the dev Space — if loading fails here, the fallback is
|
| 75 |
+
# either pinning torch back to 2.9.1 or recompiling this package against 2.11.0.
|
| 76 |
+
spaces.aoti_load(module=pipe.transformer, repo_id=AOTI_TRANSFORMER_REPO)
|
| 77 |
+
spaces.aoti_load(module=pipe.transformer_2, repo_id=AOTI_TRANSFORMER_REPO)
|
| 78 |
+
|
| 79 |
return pipe
|
| 80 |
|
| 81 |
|
|
|
|
| 113 |
return image_to_resize.resize((final_w, final_h), Image.Resampling.LANCZOS)
|
| 114 |
|
| 115 |
|
| 116 |
+
def resize_and_crop_to_match(target_image: Image.Image, reference_image: Image.Image) -> Image.Image:
|
| 117 |
+
"""Resize/crop `target_image` to exactly match `reference_image`'s size (the pipeline requires
|
| 118 |
+
the first and last frame to share dimensions)."""
|
| 119 |
+
ref_width, ref_height = reference_image.size
|
| 120 |
+
target_width, target_height = target_image.size
|
| 121 |
+
scale = max(ref_width / target_width, ref_height / target_height)
|
| 122 |
+
new_width, new_height = int(target_width * scale), int(target_height * scale)
|
| 123 |
+
resized = target_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
| 124 |
+
left, top = (new_width - ref_width) // 2, (new_height - ref_height) // 2
|
| 125 |
+
return resized.crop((left, top, left + ref_width, top + ref_height))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def get_num_frames(duration_seconds: float) -> int:
|
| 129 |
raw = int(round(duration_seconds * FIXED_FPS))
|
| 130 |
raw = max(MIN_FRAMES_MODEL, min(MAX_FRAMES_MODEL, raw))
|
|
|
|
| 138 |
|
| 139 |
Exact limits are still an open item (SRS Section 4, #3) pending empirical testing on the
|
| 140 |
dev Space; this heuristic is a starting point, not a final calibration.
|
| 141 |
+
|
| 142 |
+
Kept conservative even now that the transformers are AOT-compiled (#36): the reference
|
| 143 |
+
project's numbers below assume a validated AOT setup, and ours hasn't been measured on real
|
| 144 |
+
GPU yet (see load_pipeline's AOT caveat) — recalibrate once that's confirmed working.
|
| 145 |
"""
|
| 146 |
base_frames_height_width = 81 * 832 * 624
|
| 147 |
base_step_duration = 5.0
|
|
|
|
| 151 |
if guidance_scale > 1:
|
| 152 |
gen_time *= 2.4
|
| 153 |
|
| 154 |
+
# Padded well above the reference project's flat overhead (its pipe loads a validated AOT
|
| 155 |
+
# setup already proven on its own GPU quota) until ours is confirmed working and measured
|
| 156 |
+
# empirically on the dev Space (#36).
|
|
|
|
| 157 |
init_overhead = 60
|
| 158 |
return init_overhead + gen_time
|
postprocess/interpolation.py
CHANGED
|
@@ -6,6 +6,10 @@ Model code/weights come from `thornmaze/RIFE`, a packaged build of hzwer/Practic
|
|
| 6 |
`from model.warplayer import warp` (and, at module load time, from `model.loss` and
|
| 7 |
`model.pytorch_msssim`) expecting a sibling `model` package — see the vendored copies in
|
| 8 |
model/warplayer.py, model/loss.py, model/pytorch_msssim/.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -13,7 +17,7 @@ from __future__ import annotations
|
|
| 13 |
import subprocess
|
| 14 |
from functools import lru_cache
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import TYPE_CHECKING, Any, Callable
|
| 17 |
|
| 18 |
import numpy as np
|
| 19 |
import torch
|
|
@@ -37,7 +41,9 @@ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
| 37 |
ProgressCallback = Callable[[int, int], None]
|
| 38 |
|
| 39 |
|
| 40 |
-
def
|
|
|
|
|
|
|
| 41 |
if RIFE_DIR.exists():
|
| 42 |
return
|
| 43 |
subprocess.run(["wget", "-q", RIFE_ZIP_URL, "-O", str(RIFE_ZIP_PATH)], check=True)
|
|
@@ -46,7 +52,7 @@ def _ensure_weights_downloaded() -> None:
|
|
| 46 |
|
| 47 |
@lru_cache(maxsize=1)
|
| 48 |
def _load_model() -> Any:
|
| 49 |
-
|
| 50 |
from train_log.RIFE_HDv3 import Model # only importable after the zip is unpacked
|
| 51 |
|
| 52 |
model = Model()
|
|
@@ -63,15 +69,21 @@ def interpolate_frames(
|
|
| 63 |
multiplier: int,
|
| 64 |
scale: float = 1.0,
|
| 65 |
progress_callback: ProgressCallback | None = None,
|
| 66 |
-
|
|
|
|
| 67 |
"""Interpolate frames with RIFE to `multiplier`x the input frame rate.
|
| 68 |
|
| 69 |
Args:
|
| 70 |
frames_np: (T, H, W, C) array or list of (H, W, C) arrays, float32 in [0, 1].
|
| 71 |
multiplier: 2, 4, or 8. Values < 2 return the frames unchanged (as a list).
|
| 72 |
progress_callback: progress_callback(done, total) fired after each input-frame gap.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
Returns:
|
| 74 |
-
List of (H, W, C) float32 numpy arrays in [0, 1]
|
|
|
|
| 75 |
"""
|
| 76 |
if multiplier < 2:
|
| 77 |
return list(frames_np) if isinstance(frames_np, np.ndarray) else frames_np
|
|
@@ -91,11 +103,19 @@ def interpolate_frames(
|
|
| 91 |
t = t.permute(2, 0, 1).unsqueeze(0)
|
| 92 |
return F.pad(t, padding).half()
|
| 93 |
|
| 94 |
-
def
|
| 95 |
t = tensor[0, :, :height, :width]
|
| 96 |
t = t.permute(1, 2, 0)
|
| 97 |
return t.float().cpu().numpy()
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def make_inference(i0: torch.Tensor, i1: torch.Tensor, n: int) -> list[torch.Tensor]:
|
| 100 |
if model.version >= 3.9:
|
| 101 |
return [model.inference(i0, i1, (i + 1) / (n + 1), scale) for i in range(n)]
|
|
@@ -108,7 +128,7 @@ def interpolate_frames(
|
|
| 108 |
return [*first_half, middle, *second_half]
|
| 109 |
return [*first_half, *second_half]
|
| 110 |
|
| 111 |
-
output_frames: list[FrameArray] = []
|
| 112 |
next_tensor = to_tensor(frames_np[0])
|
| 113 |
total_steps = total_frames - 1
|
| 114 |
|
|
@@ -126,4 +146,6 @@ def interpolate_frames(
|
|
| 126 |
del current_tensor, next_tensor
|
| 127 |
torch.cuda.empty_cache()
|
| 128 |
|
| 129 |
-
|
|
|
|
|
|
|
|
|
| 6 |
`from model.warplayer import warp` (and, at module load time, from `model.loss` and
|
| 7 |
`model.pytorch_msssim`) expecting a sibling `model` package — see the vendored copies in
|
| 8 |
model/warplayer.py, model/loss.py, model/pytorch_msssim/.
|
| 9 |
+
|
| 10 |
+
The zip download/unzip itself needs no GPU, so `ensure_weights_downloaded()` is called from
|
| 11 |
+
app.py at process startup (like `load_pipeline()`), before any `@spaces.GPU` call — otherwise
|
| 12 |
+
a cold container pays for that fetch out of the metered GPU allocation on its first request.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
|
|
| 17 |
import subprocess
|
| 18 |
from functools import lru_cache
|
| 19 |
from pathlib import Path
|
| 20 |
+
from typing import TYPE_CHECKING, Any, Callable, cast
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
import torch
|
|
|
|
| 41 |
ProgressCallback = Callable[[int, int], None]
|
| 42 |
|
| 43 |
|
| 44 |
+
def ensure_weights_downloaded() -> None:
|
| 45 |
+
"""Download and unpack the RIFE weights if not already present. CPU/network only — no CUDA
|
| 46 |
+
needed — so this is safe (and intended) to call outside any @spaces.GPU allocation."""
|
| 47 |
if RIFE_DIR.exists():
|
| 48 |
return
|
| 49 |
subprocess.run(["wget", "-q", RIFE_ZIP_URL, "-O", str(RIFE_ZIP_PATH)], check=True)
|
|
|
|
| 52 |
|
| 53 |
@lru_cache(maxsize=1)
|
| 54 |
def _load_model() -> Any:
|
| 55 |
+
ensure_weights_downloaded()
|
| 56 |
from train_log.RIFE_HDv3 import Model # only importable after the zip is unpacked
|
| 57 |
|
| 58 |
model = Model()
|
|
|
|
| 69 |
multiplier: int,
|
| 70 |
scale: float = 1.0,
|
| 71 |
progress_callback: ProgressCallback | None = None,
|
| 72 |
+
as_tensor: bool = False,
|
| 73 |
+
) -> list[FrameArray] | list[torch.Tensor]:
|
| 74 |
"""Interpolate frames with RIFE to `multiplier`x the input frame rate.
|
| 75 |
|
| 76 |
Args:
|
| 77 |
frames_np: (T, H, W, C) array or list of (H, W, C) arrays, float32 in [0, 1].
|
| 78 |
multiplier: 2, 4, or 8. Values < 2 return the frames unchanged (as a list).
|
| 79 |
progress_callback: progress_callback(done, total) fired after each input-frame gap.
|
| 80 |
+
as_tensor: if True, skip the GPU->CPU conversion and return (C, H, W) fp16 GPU
|
| 81 |
+
tensors instead of (H, W, C) float32 numpy arrays — for callers (e.g. upscale)
|
| 82 |
+
that consume the result on GPU right after, so frames never round-trip to CPU
|
| 83 |
+
in between.
|
| 84 |
Returns:
|
| 85 |
+
List of (H, W, C) float32 numpy arrays in [0, 1], or (if as_tensor) list of
|
| 86 |
+
(C, H, W) fp16 GPU tensors in [0, 1].
|
| 87 |
"""
|
| 88 |
if multiplier < 2:
|
| 89 |
return list(frames_np) if isinstance(frames_np, np.ndarray) else frames_np
|
|
|
|
| 103 |
t = t.permute(2, 0, 1).unsqueeze(0)
|
| 104 |
return F.pad(t, padding).half()
|
| 105 |
|
| 106 |
+
def from_tensor_np(tensor: torch.Tensor) -> FrameArray:
|
| 107 |
t = tensor[0, :, :height, :width]
|
| 108 |
t = t.permute(1, 2, 0)
|
| 109 |
return t.float().cpu().numpy()
|
| 110 |
|
| 111 |
+
def from_tensor_gpu(tensor: torch.Tensor) -> torch.Tensor:
|
| 112 |
+
# .contiguous() detaches from the padded working tensor's storage (a plain crop is
|
| 113 |
+
# just a view into it) so the padded buffer isn't kept alive for the batch's lifetime.
|
| 114 |
+
return tensor[0, :, :height, :width].contiguous()
|
| 115 |
+
|
| 116 |
+
from_tensor: Callable[[torch.Tensor], FrameArray | torch.Tensor]
|
| 117 |
+
from_tensor = from_tensor_gpu if as_tensor else from_tensor_np
|
| 118 |
+
|
| 119 |
def make_inference(i0: torch.Tensor, i1: torch.Tensor, n: int) -> list[torch.Tensor]:
|
| 120 |
if model.version >= 3.9:
|
| 121 |
return [model.inference(i0, i1, (i + 1) / (n + 1), scale) for i in range(n)]
|
|
|
|
| 128 |
return [*first_half, middle, *second_half]
|
| 129 |
return [*first_half, *second_half]
|
| 130 |
|
| 131 |
+
output_frames: list[FrameArray | torch.Tensor] = []
|
| 132 |
next_tensor = to_tensor(frames_np[0])
|
| 133 |
total_steps = total_frames - 1
|
| 134 |
|
|
|
|
| 146 |
del current_tensor, next_tensor
|
| 147 |
torch.cuda.empty_cache()
|
| 148 |
|
| 149 |
+
if as_tensor:
|
| 150 |
+
return cast("list[torch.Tensor]", output_frames)
|
| 151 |
+
return cast("list[FrameArray]", output_frames)
|
postprocess/upscale/upscale.py
CHANGED
|
@@ -2,9 +2,10 @@
|
|
| 2 |
|
| 3 |
Inference structure (pre-pad, tile, merge, crop) is adapted from xinntao/Real-ESRGAN's `RealESRGANer`
|
| 4 |
(BSD-3-Clause License, see `LICENSES/REAL-ESRGAN-LICENSE`, `realesrgan/utils.py`), trimmed to the single code path
|
| 5 |
-
this Space needs: one fixed compact model, RGB
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
The weights are `4xLSDIRCompact` (Philip Hofmann, CC BY 4.0, see `LICENSES/4xLSDIRCompact-LICENSE`) rather than
|
| 10 |
Real-ESRGAN's own `realesr-general-x4v3`: same `SRVGGNetCompact` architecture (just `num_conv=16` instead of 32,
|
|
@@ -16,6 +17,10 @@ flattens real skin/fabric/hair texture into a smooth, CG-like look on frames tha
|
|
| 16 |
Runs inside the caller's `@spaces.GPU` allocation (see app.py's `run_inference`), same as RIFE interpolation
|
| 17 |
(postprocess/interpolation.py) — moved off the Space's shared CPU per issue #11 to avoid CPU contention across
|
| 18 |
concurrent visitors. Falls back to CPU/fp32 automatically when no CUDA device is present (e.g. local runs).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
|
@@ -23,7 +28,7 @@ from __future__ import annotations
|
|
| 23 |
import math
|
| 24 |
from functools import lru_cache
|
| 25 |
from pathlib import Path
|
| 26 |
-
from typing import Callable
|
| 27 |
|
| 28 |
import numpy as np
|
| 29 |
import torch
|
|
@@ -34,34 +39,71 @@ from torch.hub import download_url_to_file, get_dir
|
|
| 34 |
|
| 35 |
from .srvgg_arch import SRVGGNetCompact
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
WEIGHTS_URL = "https://huggingface.co/Phips/4xLSDIRCompact/resolve/main/4xLSDIRCompact.safetensors"
|
| 38 |
SCALE = 4
|
| 39 |
NUM_CONV = 16
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
TILE_PAD = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 43 |
dtype = torch.float16 if device.type == "cuda" else torch.float32
|
| 44 |
|
| 45 |
ProgressCallback = Callable[[int, int], None]
|
| 46 |
|
| 47 |
|
| 48 |
-
def
|
| 49 |
cache_dir = Path(get_dir()) / "checkpoints"
|
| 50 |
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
if not weights_path.exists():
|
| 53 |
download_url_to_file(WEIGHTS_URL, str(weights_path))
|
| 54 |
-
return weights_path
|
| 55 |
|
| 56 |
|
| 57 |
@lru_cache(maxsize=1)
|
| 58 |
-
def _load_model() ->
|
| 59 |
-
model = SRVGGNetCompact( # type: ignore[no-untyped-call] # vendored, untyped (see srvgg_arch.py)
|
| 60 |
num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=NUM_CONV, upscale=SCALE, act_type="prelu"
|
| 61 |
)
|
| 62 |
-
|
|
|
|
| 63 |
model.load_state_dict(state_dict, strict=True)
|
| 64 |
model.to(device=device, dtype=dtype).eval()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return model
|
| 66 |
|
| 67 |
|
|
@@ -70,51 +112,101 @@ def _pre_pad(tensor: torch.Tensor, pad: int) -> torch.Tensor:
|
|
| 70 |
|
| 71 |
|
| 72 |
@torch.no_grad()
|
| 73 |
-
def _tile_process(model:
|
| 74 |
batch, channel, height, width = img.shape
|
| 75 |
output = img.new_zeros((batch, channel, height * SCALE, width * SCALE))
|
| 76 |
tiles_x = math.ceil(width / TILE_SIZE)
|
| 77 |
tiles_y = math.ceil(height / TILE_SIZE)
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
return output
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def upscale_frames(
|
| 98 |
-
frames: list[
|
| 99 |
) -> list[Image.Image]:
|
| 100 |
"""Upscale every frame 4x with `4xLSDIRCompact`, tiled to bound peak memory.
|
| 101 |
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
"""
|
| 104 |
if not frames:
|
| 105 |
return []
|
| 106 |
model = _load_model()
|
| 107 |
-
out_frames = []
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
padded = _pre_pad(tensor, TILE_PAD)
|
| 113 |
upscaled = _tile_process(model, padded)
|
| 114 |
# crop the pre-pad border (scaled) back off
|
| 115 |
upscaled = upscaled[:, :, : h * SCALE, : w * SCALE]
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
progress_callback
|
|
|
|
| 120 |
return out_frames
|
|
|
|
| 2 |
|
| 3 |
Inference structure (pre-pad, tile, merge, crop) is adapted from xinntao/Real-ESRGAN's `RealESRGANer`
|
| 4 |
(BSD-3-Clause License, see `LICENSES/REAL-ESRGAN-LICENSE`, `realesrgan/utils.py`), trimmed to the single code path
|
| 5 |
+
this Space needs: one fixed compact model, RGB frames in (numpy float32 `[0,1]`, `(H,W,C)` — the same
|
| 6 |
+
representation `_apply_interpolation` already produces, so no PIL round trip is needed at the boundary) and
|
| 7 |
+
`PIL.Image` out (no alpha channel, no 16-bit, no cv2/BGR round-trip, no `dni` model blending, no CLI
|
| 8 |
+
prefetch/IO-queue threads — none of those apply to frames coming straight out of the Wan 2.2 decode step).
|
| 9 |
|
| 10 |
The weights are `4xLSDIRCompact` (Philip Hofmann, CC BY 4.0, see `LICENSES/4xLSDIRCompact-LICENSE`) rather than
|
| 11 |
Real-ESRGAN's own `realesr-general-x4v3`: same `SRVGGNetCompact` architecture (just `num_conv=16` instead of 32,
|
|
|
|
| 17 |
Runs inside the caller's `@spaces.GPU` allocation (see app.py's `run_inference`), same as RIFE interpolation
|
| 18 |
(postprocess/interpolation.py) — moved off the Space's shared CPU per issue #11 to avoid CPU contention across
|
| 19 |
concurrent visitors. Falls back to CPU/fp32 automatically when no CUDA device is present (e.g. local runs).
|
| 20 |
+
|
| 21 |
+
The weight download itself needs no GPU, so `ensure_weights_downloaded()` is called from app.py at process
|
| 22 |
+
startup (like `load_pipeline()`), before any `@spaces.GPU` call — otherwise a cold container pays for that
|
| 23 |
+
fetch out of the metered GPU allocation on its first upscale request.
|
| 24 |
"""
|
| 25 |
|
| 26 |
from __future__ import annotations
|
|
|
|
| 28 |
import math
|
| 29 |
from functools import lru_cache
|
| 30 |
from pathlib import Path
|
| 31 |
+
from typing import TYPE_CHECKING, Callable, cast
|
| 32 |
|
| 33 |
import numpy as np
|
| 34 |
import torch
|
|
|
|
| 39 |
|
| 40 |
from .srvgg_arch import SRVGGNetCompact
|
| 41 |
|
| 42 |
+
if TYPE_CHECKING:
|
| 43 |
+
# numpy.typing needs numpy>=1.20; requirements.txt allows down to 1.16, so this must stay out
|
| 44 |
+
# of the runtime import path.
|
| 45 |
+
import numpy.typing as npt
|
| 46 |
+
|
| 47 |
+
FrameArray = npt.NDArray[np.float32]
|
| 48 |
+
else:
|
| 49 |
+
FrameArray = np.ndarray
|
| 50 |
+
|
| 51 |
WEIGHTS_URL = "https://huggingface.co/Phips/4xLSDIRCompact/resolve/main/4xLSDIRCompact.safetensors"
|
| 52 |
SCALE = 4
|
| 53 |
NUM_CONV = 16
|
| 54 |
+
# Larger tiles mean fewer, bigger model() calls (less pad/crop overhead per pixel, better GPU
|
| 55 |
+
# occupancy) at the cost of more peak memory per call. 512 is still conservative for a 16GB+
|
| 56 |
+
# Spaces GPU (T4/L4/A10G) with such a compact network — bump further if profiling on the dev
|
| 57 |
+
# Space shows headroom.
|
| 58 |
+
TILE_SIZE = 512
|
| 59 |
TILE_PAD = 10
|
| 60 |
+
# Frames are grouped into batches (of equal size) before tiling, and same-shaped tile patches
|
| 61 |
+
# — across both the frame batch and tile positions — are run through the model together instead
|
| 62 |
+
# of one at a time, since SRVGGNetCompact is small enough that a single 256x256-ish tile leaves
|
| 63 |
+
# most of the GPU idle. Conservative defaults for a 16GB+ Spaces GPU (T4/L4/A10G); bump if
|
| 64 |
+
# profiling on the dev Space shows headroom.
|
| 65 |
+
FRAME_BATCH_SIZE = 4
|
| 66 |
+
MAX_TILE_BATCH = 16
|
| 67 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 68 |
dtype = torch.float16 if device.type == "cuda" else torch.float32
|
| 69 |
|
| 70 |
ProgressCallback = Callable[[int, int], None]
|
| 71 |
|
| 72 |
|
| 73 |
+
def _weights_path() -> Path:
|
| 74 |
cache_dir = Path(get_dir()) / "checkpoints"
|
| 75 |
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
return cache_dir / Path(WEIGHTS_URL).name
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def ensure_weights_downloaded() -> None:
|
| 80 |
+
"""Download the 4xLSDIRCompact weights if not already cached. CPU/network only — no CUDA
|
| 81 |
+
needed — so this is safe (and intended) to call outside any @spaces.GPU allocation."""
|
| 82 |
+
weights_path = _weights_path()
|
| 83 |
if not weights_path.exists():
|
| 84 |
download_url_to_file(WEIGHTS_URL, str(weights_path))
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
@lru_cache(maxsize=1)
|
| 88 |
+
def _load_model() -> torch.nn.Module:
|
| 89 |
+
model: torch.nn.Module = SRVGGNetCompact( # type: ignore[no-untyped-call] # vendored, untyped (see srvgg_arch.py)
|
| 90 |
num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=NUM_CONV, upscale=SCALE, act_type="prelu"
|
| 91 |
)
|
| 92 |
+
ensure_weights_downloaded()
|
| 93 |
+
state_dict = load_file(_weights_path())
|
| 94 |
model.load_state_dict(state_dict, strict=True)
|
| 95 |
model.to(device=device, dtype=dtype).eval()
|
| 96 |
+
if device.type == "cuda":
|
| 97 |
+
try:
|
| 98 |
+
# dynamic=True avoids a fresh recompile for every distinct tile shape (interior
|
| 99 |
+
# tiles vs. the last row/column, clipped against the image edge) — there are only a
|
| 100 |
+
# handful of those per video, but recompiling per-shape would otherwise eat into the
|
| 101 |
+
# first frame's latency. Guarded: torch.compile can fail to initialize (e.g. missing
|
| 102 |
+
# or incompatible triton on the target GPU image), in which case the eager model is
|
| 103 |
+
# used as-is.
|
| 104 |
+
model = cast(torch.nn.Module, torch.compile(model, dynamic=True))
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print(f"[upscale] torch.compile failed to initialize, using eager model: {e!r}", flush=True)
|
| 107 |
return model
|
| 108 |
|
| 109 |
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
@torch.no_grad()
|
| 115 |
+
def _tile_process(model: torch.nn.Module, img: torch.Tensor) -> torch.Tensor:
|
| 116 |
batch, channel, height, width = img.shape
|
| 117 |
output = img.new_zeros((batch, channel, height * SCALE, width * SCALE))
|
| 118 |
tiles_x = math.ceil(width / TILE_SIZE)
|
| 119 |
tiles_y = math.ceil(height / TILE_SIZE)
|
| 120 |
|
| 121 |
+
# Every tile job needs its own input patch (pad_*) and output placement (in_*/trim_*), but
|
| 122 |
+
# patches share the same H/W across the batch dim and across interior tile positions — only
|
| 123 |
+
# the last row/column of tiles is smaller, clipped against the image edge. Group jobs by
|
| 124 |
+
# patch shape so each model() call is a single batched forward pass over same-shaped patches
|
| 125 |
+
# (chunked to MAX_TILE_BATCH to bound memory) instead of one tile at a time.
|
| 126 |
+
jobs_by_shape: dict[tuple[int, int], list[tuple[int, int, int, int, int, int, int, int, int]]] = {}
|
| 127 |
+
for b in range(batch):
|
| 128 |
+
for y in range(tiles_y):
|
| 129 |
+
for x in range(tiles_x):
|
| 130 |
+
in_x0, in_x1 = x * TILE_SIZE, min((x + 1) * TILE_SIZE, width)
|
| 131 |
+
in_y0, in_y1 = y * TILE_SIZE, min((y + 1) * TILE_SIZE, height)
|
| 132 |
+
pad_x0, pad_x1 = max(in_x0 - TILE_PAD, 0), min(in_x1 + TILE_PAD, width)
|
| 133 |
+
pad_y0, pad_y1 = max(in_y0 - TILE_PAD, 0), min(in_y1 + TILE_PAD, height)
|
| 134 |
+
shape = (pad_y1 - pad_y0, pad_x1 - pad_x0)
|
| 135 |
+
jobs_by_shape.setdefault(shape, []).append(
|
| 136 |
+
(b, in_x0, in_y0, in_x1, in_y1, pad_x0, pad_y0, pad_x1, pad_y1)
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
for jobs in jobs_by_shape.values():
|
| 140 |
+
for chunk_start in range(0, len(jobs), MAX_TILE_BATCH):
|
| 141 |
+
chunk = jobs[chunk_start:chunk_start + MAX_TILE_BATCH]
|
| 142 |
+
patch = torch.cat(
|
| 143 |
+
[img[b:b + 1, :, pad_y0:pad_y1, pad_x0:pad_x1] for b, _, _, _, _, pad_x0, pad_y0, pad_x1, pad_y1 in chunk],
|
| 144 |
+
dim=0,
|
| 145 |
)
|
| 146 |
+
tile_out = model(patch)
|
| 147 |
+
|
| 148 |
+
for i, (b, in_x0, in_y0, in_x1, in_y1, pad_x0, pad_y0, pad_x1, pad_y1) in enumerate(chunk):
|
| 149 |
+
trim_x0, trim_y0 = (in_x0 - pad_x0) * SCALE, (in_y0 - pad_y0) * SCALE
|
| 150 |
+
trim_x1 = trim_x0 + (in_x1 - in_x0) * SCALE
|
| 151 |
+
trim_y1 = trim_y0 + (in_y1 - in_y0) * SCALE
|
| 152 |
+
output[b:b + 1, :, in_y0 * SCALE:in_y1 * SCALE, in_x0 * SCALE:in_x1 * SCALE] = (
|
| 153 |
+
tile_out[i:i + 1, :, trim_y0:trim_y1, trim_x0:trim_x1]
|
| 154 |
+
)
|
| 155 |
return output
|
| 156 |
|
| 157 |
|
| 158 |
+
def _frame_hw(frame: FrameArray | torch.Tensor) -> tuple[int, int]:
|
| 159 |
+
# numpy frames are (H, W, C); GPU tensor frames (from RIFE's as_tensor=True path) are (C, H, W).
|
| 160 |
+
h, w = frame.shape[-2:] if isinstance(frame, torch.Tensor) else frame.shape[:2]
|
| 161 |
+
return int(h), int(w)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _frames_to_tensor(frames: list[FrameArray | torch.Tensor]) -> torch.Tensor:
|
| 165 |
+
if isinstance(frames[0], torch.Tensor):
|
| 166 |
+
# Already (C, H, W) fp16 GPU tensors (RIFE's as_tensor=True output) — just batch them,
|
| 167 |
+
# no host round trip.
|
| 168 |
+
return torch.stack(cast("list[torch.Tensor]", frames), dim=0).to(device=device, dtype=dtype)
|
| 169 |
+
array = np.stack(cast("list[FrameArray]", frames))
|
| 170 |
+
return torch.from_numpy(array).permute(0, 3, 1, 2).to(device=device, dtype=dtype)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
def upscale_frames(
|
| 174 |
+
frames: list[FrameArray] | list[torch.Tensor], progress_callback: ProgressCallback | None = None
|
| 175 |
) -> list[Image.Image]:
|
| 176 |
"""Upscale every frame 4x with `4xLSDIRCompact`, tiled to bound peak memory.
|
| 177 |
|
| 178 |
+
frames are either numpy float32 arrays in `[0,1]`, shape `(H,W,C)`, or (C,H,W) fp16 GPU
|
| 179 |
+
tensors — the same representations `_apply_interpolation` can produce, so callers don't
|
| 180 |
+
need to round-trip through `PIL.Image`, or through the CPU at all, just to cross this
|
| 181 |
+
boundary.
|
| 182 |
+
|
| 183 |
+
Frames are processed in batches (of up to FRAME_BATCH_SIZE, split wherever frame size
|
| 184 |
+
changes) rather than one at a time, so the model sees a real batch dimension instead of
|
| 185 |
+
batch=1 on every call. progress_callback(done, total), if given, fires after each frame
|
| 186 |
+
finishes.
|
| 187 |
"""
|
| 188 |
if not frames:
|
| 189 |
return []
|
| 190 |
model = _load_model()
|
| 191 |
+
out_frames: list[Image.Image] = []
|
| 192 |
+
|
| 193 |
+
i = 0
|
| 194 |
+
while i < len(frames):
|
| 195 |
+
h, w = _frame_hw(frames[i])
|
| 196 |
+
batch: list[FrameArray | torch.Tensor] = [frames[i]]
|
| 197 |
+
i += 1
|
| 198 |
+
while i < len(frames) and len(batch) < FRAME_BATCH_SIZE and _frame_hw(frames[i]) == (h, w):
|
| 199 |
+
batch.append(frames[i])
|
| 200 |
+
i += 1
|
| 201 |
+
|
| 202 |
+
tensor = _frames_to_tensor(batch)
|
| 203 |
padded = _pre_pad(tensor, TILE_PAD)
|
| 204 |
upscaled = _tile_process(model, padded)
|
| 205 |
# crop the pre-pad border (scaled) back off
|
| 206 |
upscaled = upscaled[:, :, : h * SCALE, : w * SCALE]
|
| 207 |
+
results = upscaled.clamp(0, 1).permute(0, 2, 3, 1).float().cpu().numpy()
|
| 208 |
+
for result in results:
|
| 209 |
+
out_frames.append(Image.fromarray((result * 255.0).round().astype(np.uint8)))
|
| 210 |
+
if progress_callback is not None:
|
| 211 |
+
progress_callback(len(out_frames), len(frames))
|
| 212 |
return out_frames
|