chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
"""Frame interpolation and upscaling support for SGLang diffusion pipelines."""
from sglang.multimodal_gen.runtime.postprocess.realesrgan_upscaler import (
ImageUpscaler,
batch_upscale_frames,
upscale_frames,
)
from sglang.multimodal_gen.runtime.postprocess.rife_interpolator import (
FrameInterpolator,
interpolate_video_frames,
)
__all__ = [
"FrameInterpolator",
"interpolate_video_frames",
"ImageUpscaler",
"batch_upscale_frames",
"upscale_frames",
]
@@ -0,0 +1,822 @@
# SPDX-License-Identifier: Apache-2.0
"""
Real-ESRGAN upscaling for SGLang diffusion pipelines.
Real-ESRGAN model code is vendored and adapted from:
- https://github.com/xinntao/Real-ESRGAN (BSD-3-Clause License)
Copyright (c) 2021 xinntao
The ImageUpscaler wrapper and integration code are original work.
"""
import math
import os
import time
from hashlib import sha256
from typing import Optional
from urllib.parse import unquote, urlparse
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
# Default HuggingFace repo and filename for Real-ESRGAN weights
_DEFAULT_REALESRGAN_HF_REPO = "ai-forever/Real-ESRGAN"
_DEFAULT_REALESRGAN_FILENAME = "RealESRGAN_x4.pth"
_DEFAULT_REALESRGAN_FILENAMES_BY_SCALE = {
2: "RealESRGAN_x2.pth",
4: "RealESRGAN_x4.pth",
8: "RealESRGAN_x8.pth",
}
_LOW_MEMORY_TILED_UPSCALE_FREE_BYTES = 2 * 1024**3
_REALESRGAN_TILE_SIZE = 256
_REALESRGAN_TILE_PAD = 32
# Module-level cache: model_path -> UpscalerModel instance
_MODEL_CACHE: dict[str, "UpscalerModel"] = {}
_RESOLVED_MODEL_PATH_CACHE: dict[str, str] = {}
def _default_model_path_for_scale(scale: int) -> str:
filename = _DEFAULT_REALESRGAN_FILENAMES_BY_SCALE.get(
int(scale),
_DEFAULT_REALESRGAN_FILENAME,
)
return f"{_DEFAULT_REALESRGAN_HF_REPO}:{filename}"
# ---------------------------------------------------------------------------
# Vendored Real-ESRGAN architecture code
# (SRVGGNetCompact, ResidualDenseBlock, RRDB, RRDBNet)
# ---------------------------------------------------------------------------
class SRVGGNetCompact(nn.Module):
"""Compact VGG-style network for super resolution.
Corresponds to ``realesr-animevideov3`` and ``realesr-general-x4v3``.
Reference: xinntao/Real-ESRGAN (BSD-3-Clause).
"""
def __init__(
self,
num_in_ch: int = 3,
num_out_ch: int = 3,
num_feat: int = 64,
num_conv: int = 16,
upscale: int = 4,
act_type: str = "prelu",
):
super().__init__()
self.num_in_ch = num_in_ch
self.num_out_ch = num_out_ch
self.num_feat = num_feat
self.num_conv = num_conv
self.upscale = upscale
self.act_type = act_type
self.body = nn.ModuleList()
# first conv
self.body.append(nn.Conv2d(num_in_ch, num_feat, 3, 1, 1))
# first activation
self.body.append(self._make_act(act_type, num_feat))
# body convs + activations
for _ in range(num_conv):
self.body.append(nn.Conv2d(num_feat, num_feat, 3, 1, 1))
self.body.append(self._make_act(act_type, num_feat))
# last conv: maps to out_ch * upscale^2 for pixel shuffle
self.body.append(nn.Conv2d(num_feat, num_out_ch * upscale * upscale, 3, 1, 1))
self.upsampler = nn.PixelShuffle(upscale)
@staticmethod
def _make_act(act_type: str, num_feat: int) -> nn.Module:
if act_type == "relu":
return nn.ReLU(inplace=True)
elif act_type == "prelu":
return nn.PReLU(num_parameters=num_feat)
elif act_type == "leakyrelu":
return nn.LeakyReLU(negative_slope=0.1, inplace=True)
else:
raise ValueError(f"Unsupported activation type: {act_type}")
def forward(self, x: torch.Tensor) -> torch.Tensor:
out = x
for layer in self.body:
out = layer(out)
out = self.upsampler(out)
# residual addition with nearest upsampled input
base = F.interpolate(x, scale_factor=self.upscale, mode="nearest")
return out + base
class ResidualDenseBlock(nn.Module):
"""Residual Dense Block used in RRDB (RealESRGAN_x4plus)."""
def __init__(self, num_feat: int = 64, num_grow_ch: int = 32):
super().__init__()
self.conv1 = nn.Conv2d(num_feat, num_grow_ch, 3, 1, 1)
self.conv2 = nn.Conv2d(num_feat + num_grow_ch, num_grow_ch, 3, 1, 1)
self.conv3 = nn.Conv2d(num_feat + 2 * num_grow_ch, num_grow_ch, 3, 1, 1)
self.conv4 = nn.Conv2d(num_feat + 3 * num_grow_ch, num_grow_ch, 3, 1, 1)
self.conv5 = nn.Conv2d(num_feat + 4 * num_grow_ch, num_feat, 3, 1, 1)
self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x1 = self.lrelu(self.conv1(x))
x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
return x5 * 0.2 + x
class RRDB(nn.Module):
"""Residual in Residual Dense Block."""
def __init__(self, num_feat: int, num_grow_ch: int = 32):
super().__init__()
self.rdb1 = ResidualDenseBlock(num_feat, num_grow_ch)
self.rdb2 = ResidualDenseBlock(num_feat, num_grow_ch)
self.rdb3 = ResidualDenseBlock(num_feat, num_grow_ch)
def forward(self, x: torch.Tensor) -> torch.Tensor:
out = self.rdb1(x)
out = self.rdb2(out)
out = self.rdb3(out)
return out * 0.2 + x
class RRDBNet(nn.Module):
"""RRDB network for RealESRGAN_x4plus (heavier, higher quality for photos)."""
def __init__(
self,
num_in_ch: int = 3,
num_out_ch: int = 3,
scale: int = 4,
num_feat: int = 64,
num_block: int = 23,
num_grow_ch: int = 32,
):
super().__init__()
self.scale = scale
in_ch = num_in_ch
if scale == 2:
in_ch = num_in_ch * 4
elif scale == 1:
in_ch = num_in_ch * 16
self.conv_first = nn.Conv2d(in_ch, num_feat, 3, 1, 1)
self.body = nn.Sequential(
*[RRDB(num_feat, num_grow_ch) for _ in range(num_block)]
)
self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
# upsample
self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1)
self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self.scale == 2:
feat = F.pixel_unshuffle(x, 2)
elif self.scale == 1:
feat = F.pixel_unshuffle(x, 4)
else:
feat = x
feat = self.conv_first(feat)
body_feat = self.conv_body(self.body(feat))
feat = feat + body_feat
feat = self.lrelu(
self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest"))
)
feat = self.lrelu(
self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest"))
)
return self.conv_last(self.lrelu(self.conv_hr(feat)))
# ---------------------------------------------------------------------------
# Architecture auto-detection
# ---------------------------------------------------------------------------
def _build_net_from_state_dict(state_dict: dict) -> nn.Module:
"""Detect architecture from checkpoint keys and return an unloaded network."""
if "conv_first.weight" in state_dict:
# RRDBNet (e.g., RealESRGAN_x4plus)
num_feat = state_dict["conv_first.weight"].shape[0]
in_channels = state_dict["conv_first.weight"].shape[1]
if in_channels == 3:
scale = 4
elif in_channels == 12:
scale = 2
elif in_channels == 48:
scale = 1
else:
raise ValueError(
f"Unsupported RRDBNet conv_first input channels: {in_channels}"
)
num_block = sum(
1
for k in state_dict
if k.startswith("body.") and k.endswith(".rdb1.conv1.weight")
)
num_grow_ch = state_dict["body.0.rdb1.conv1.weight"].shape[0]
logger.info(
"Detected RRDBNet: num_feat=%d, num_block=%d, num_grow_ch=%d, scale=%d",
num_feat,
num_block,
num_grow_ch,
scale,
)
return RRDBNet(
num_in_ch=3,
num_out_ch=3,
scale=scale,
num_feat=num_feat,
num_block=num_block,
num_grow_ch=num_grow_ch,
)
else:
# SRVGGNetCompact (e.g., realesr-animevideov3)
num_feat = state_dict["body.0.weight"].shape[0]
# body layout: [first_conv, first_act, (conv, act)*num_conv, last_conv]
# count 4-D weight tensors = first_conv + loop_convs + last_conv = num_conv + 2
conv_keys = sorted(
[
k
for k in state_dict
if k.startswith("body.")
and k.endswith(".weight")
and state_dict[k].dim() == 4
],
key=lambda k: int(k.split(".")[1]),
)
num_conv = len(conv_keys) - 2 # subtract first and last
# upscale from last conv output channels: out_ch = num_out_ch * upscale^2
last_out_ch = state_dict[conv_keys[-1]].shape[0]
upscale = int(math.sqrt(last_out_ch / 3))
logger.info(
"Detected SRVGGNetCompact: num_feat=%d, num_conv=%d, upscale=%d",
num_feat,
num_conv,
upscale,
)
return SRVGGNetCompact(
num_in_ch=3,
num_out_ch=3,
num_feat=num_feat,
num_conv=num_conv,
upscale=upscale,
act_type="prelu",
)
# ---------------------------------------------------------------------------
# UpscalerModel
# ---------------------------------------------------------------------------
class UpscalerModel:
"""Wraps a Real-ESRGAN network, provides load() and upscale() API."""
def __init__(self, net: nn.Module, scale: int):
self.net = net
self.scale = scale # the model's native upscaling factor (e.g. 4)
@property
def device(self) -> torch.device:
return next(self.net.parameters()).device
@property
def dtype(self) -> torch.dtype:
return next(self.net.parameters()).dtype
def _copy_input_to_device(self, frames: np.ndarray) -> torch.Tensor:
return torch.from_numpy(frames).to(self.device)
def _preprocess_input_tensor(self, imgs_t: torch.Tensor) -> torch.Tensor:
imgs_t = imgs_t.permute(0, 3, 1, 2).to(dtype=self.dtype).mul_(1.0 / 255.0)
if self.device.type == "cuda":
imgs_t = imgs_t.contiguous(memory_format=torch.channels_last)
return imgs_t
@staticmethod
def _postprocess_output_tensor(out: torch.Tensor) -> torch.Tensor:
out = out.permute(0, 2, 3, 1).clamp(0.0, 1.0).mul_(255.0)
return out.to(torch.uint8).contiguous()
@staticmethod
def _copy_output_to_host(out: torch.Tensor) -> np.ndarray:
return out.cpu().numpy()
def _start_cuda_timer(self):
if self.device.type != "cuda":
return None
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
return start, end
@staticmethod
def _stop_cuda_timer(timer) -> None:
if timer is not None:
timer[1].record()
@staticmethod
def _cuda_elapsed_s(timer, fallback_s: float) -> float:
if timer is None:
return fallback_s
timer[1].synchronize()
return timer[0].elapsed_time(timer[1]) / 1000.0
def _should_use_tiled_upscale(self, h: int, w: int) -> bool:
if self.device.type != "cuda":
return False
free_bytes, _ = torch.cuda.mem_get_info(self.device)
output_bytes = h * w * self.scale * self.scale * 3 * 4
required_free_bytes = max(
_LOW_MEMORY_TILED_UPSCALE_FREE_BYTES,
output_bytes * 4,
)
return free_bytes < required_free_bytes
def _upscale_tiled_to_cpu(
self,
img_t: torch.Tensor,
tile_size: int = _REALESRGAN_TILE_SIZE,
tile_pad: int = _REALESRGAN_TILE_PAD,
) -> torch.Tensor:
_, channels, h, w = img_t.shape
scale = self.scale
output = torch.empty(
(1, channels, h * scale, w * scale),
dtype=torch.float32,
device="cpu",
)
for y in range(0, h, tile_size):
tile_h = min(tile_size, h - y)
in_y0 = max(y - tile_pad, 0)
in_y1 = min(y + tile_h + tile_pad, h)
out_y0 = y * scale
out_y1 = (y + tile_h) * scale
crop_y0 = (y - in_y0) * scale
crop_y1 = crop_y0 + tile_h * scale
for x in range(0, w, tile_size):
tile_w = min(tile_size, w - x)
in_x0 = max(x - tile_pad, 0)
in_x1 = min(x + tile_w + tile_pad, w)
out_x0 = x * scale
out_x1 = (x + tile_w) * scale
crop_x0 = (x - in_x0) * scale
crop_x1 = crop_x0 + tile_w * scale
tile = img_t[..., in_y0:in_y1, in_x0:in_x1]
out_tile = self.net(tile)
out_tile = out_tile[..., crop_y0:crop_y1, crop_x0:crop_x1].float()
output[..., out_y0:out_y1, out_x0:out_x1].copy_(out_tile.cpu())
return output
def upscale(self, frame: np.ndarray, outscale: float | None = None) -> np.ndarray:
"""Upscale a single HWC uint8 frame → HWC uint8 frame.
Args:
frame: Input HWC uint8 numpy array.
outscale: Desired final upscaling factor. If different from the
model's native scale, a cheap resize is applied after
the network output (same approach as the official
Real-ESRGAN ``inference_realesrgan.py --outscale``).
``None`` means use the model's native scale as-is.
"""
h, w = frame.shape[:2]
img = frame.astype(np.float32) / 255.0
img_t = (
torch.from_numpy(img)
.permute(2, 0, 1)
.unsqueeze(0)
.to(device=self.device, dtype=self.dtype)
)
with torch.no_grad():
if self._should_use_tiled_upscale(h, w):
logger.info(
"Using tiled Real-ESRGAN upscale for low GPU memory: "
"frame=%dx%d, tile_size=%d, tile_pad=%d",
w,
h,
_REALESRGAN_TILE_SIZE,
_REALESRGAN_TILE_PAD,
)
out = self._upscale_tiled_to_cpu(img_t)
else:
try:
out = self.net(img_t)
except torch.cuda.OutOfMemoryError:
if self.device.type != "cuda":
raise
torch.cuda.empty_cache()
logger.warning(
"Real-ESRGAN full-frame upscale OOM; retrying with tiled upscale"
)
out = self._upscale_tiled_to_cpu(img_t)
# If the desired outscale differs from the model's native scale,
# resize to (h * outscale, w * outscale).
if outscale is not None and outscale != self.scale:
target_h = int(h * outscale)
target_w = int(w * outscale)
out = F.interpolate(
out, size=(target_h, target_w), mode="bicubic", align_corners=False
)
out_np = out.squeeze(0).permute(1, 2, 0).clamp(0.0, 1.0).cpu().numpy()
return (out_np * 255.0).astype(np.uint8)
def upscale_batch(
self, frames: list[np.ndarray], outscale: float | None = None
) -> list[np.ndarray]:
"""Upscale same-resolution HWC uint8 frames in one batched forward pass."""
if not frames:
return []
h, w = frames[0].shape[:2]
if any(frame.shape[:2] != (h, w) for frame in frames):
raise ValueError("All frames in a batch must have the same resolution")
total_start_time = time.perf_counter()
start_time = time.perf_counter()
imgs = np.stack(frames, axis=0)
stack_duration_s = time.perf_counter() - start_time
start_time = time.perf_counter()
h2d_timer = self._start_cuda_timer()
imgs_t = self._copy_input_to_device(imgs)
self._stop_cuda_timer(h2d_timer)
h2d_wall_duration_s = time.perf_counter() - start_time
start_time = time.perf_counter()
input_preprocess_timer = self._start_cuda_timer()
imgs_t = self._preprocess_input_tensor(imgs_t)
self._stop_cuda_timer(input_preprocess_timer)
input_preprocess_wall_duration_s = time.perf_counter() - start_time
start_time = time.perf_counter()
forward_timer = self._start_cuda_timer()
with torch.inference_mode():
out = self.net(imgs_t)
self._stop_cuda_timer(forward_timer)
forward_wall_duration_s = time.perf_counter() - start_time
resize_timer = None
resize_wall_duration_s = 0.0
if outscale is not None and outscale != self.scale:
start_time = time.perf_counter()
resize_timer = self._start_cuda_timer()
target_h = int(h * outscale)
target_w = int(w * outscale)
out = F.interpolate(
out, size=(target_h, target_w), mode="bicubic", align_corners=False
)
self._stop_cuda_timer(resize_timer)
resize_wall_duration_s = time.perf_counter() - start_time
start_time = time.perf_counter()
output_postprocess_timer = self._start_cuda_timer()
out = self._postprocess_output_tensor(out)
self._stop_cuda_timer(output_postprocess_timer)
output_postprocess_wall_duration_s = time.perf_counter() - start_time
start_time = time.perf_counter()
output_d2h_timer = self._start_cuda_timer()
out_np = self._copy_output_to_host(out)
self._stop_cuda_timer(output_d2h_timer)
output_d2h_wall_duration_s = time.perf_counter() - start_time
start_time = time.perf_counter()
outputs = [frame for frame in out_np]
post_duration_s = time.perf_counter() - start_time
h2d_duration_s = self._cuda_elapsed_s(h2d_timer, h2d_wall_duration_s)
input_preprocess_duration_s = self._cuda_elapsed_s(
input_preprocess_timer, input_preprocess_wall_duration_s
)
forward_duration_s = self._cuda_elapsed_s(
forward_timer, forward_wall_duration_s
)
resize_duration_s = self._cuda_elapsed_s(resize_timer, resize_wall_duration_s)
output_postprocess_duration_s = self._cuda_elapsed_s(
output_postprocess_timer, output_postprocess_wall_duration_s
)
output_d2h_duration_s = self._cuda_elapsed_s(
output_d2h_timer, output_d2h_wall_duration_s
)
total_duration_s = time.perf_counter() - total_start_time
timing_source = "cuda_event" if self.device.type == "cuda" else "wall"
logger.info(
"RealESRGAN batch upscale: batch=%d input=%dx%d native_scale=%dx outscale=%s "
"dtype=%s timing=%s total=%.3fs stack=%.3fs input_h2d=%.3fs "
"input_pre=%.3fs forward=%.3fs resize=%.3fs output_post=%.3fs "
"output_d2h=%.3fs python_post=%.3fs",
len(frames),
w,
h,
self.scale,
outscale if outscale is not None else self.scale,
self.dtype,
timing_source,
total_duration_s,
stack_duration_s,
h2d_duration_s,
input_preprocess_duration_s,
forward_duration_s,
resize_duration_s,
output_postprocess_duration_s,
output_d2h_duration_s,
post_duration_s,
)
return outputs
# ---------------------------------------------------------------------------
# ImageUpscaler public class
# ---------------------------------------------------------------------------
class ImageUpscaler:
"""
Lazy-loaded Real-ESRGAN upscaler.
Weights are downloaded and cached on first call to `.upscale()`.
Supports both SRVGGNetCompact (lightweight, default) and RRDBNet (heavier).
"""
def __init__(
self,
model_path: Optional[str] = None,
scale: int = 4,
half_precision: bool = False,
):
self._model_path = model_path
self._scale = scale
self._half_precision = half_precision
def _ensure_model_loaded(self) -> UpscalerModel:
"""Download/load Real-ESRGAN weights, detect arch, and cache globally."""
model_path = self._model_path or _default_model_path_for_scale(self._scale)
# Resolve: local .pth pass-through, or HF repo → download single file
resolved_path = _resolve_model_path(model_path)
if resolved_path in _MODEL_CACHE:
return _MODEL_CACHE[resolved_path]
logger.info("Loading Real-ESRGAN weights from %s", resolved_path)
try:
state_dict = torch.load(
resolved_path, map_location="cpu", weights_only=True
)
except Exception as e:
raise RuntimeError(
f"Failed to load Real-ESRGAN checkpoint from '{resolved_path}'. "
f"The file may be corrupted or not a valid PyTorch checkpoint. "
f"Original error: {e}"
) from e
# Some checkpoints wrap weights under a 'params' or 'params_ema' key
if "params_ema" in state_dict:
state_dict = state_dict["params_ema"]
elif "params" in state_dict:
state_dict = state_dict["params"]
try:
net = _build_net_from_state_dict(state_dict)
net.load_state_dict(state_dict, strict=True)
except (RuntimeError, KeyError) as e:
raise RuntimeError(
f"Real-ESRGAN weight file '{resolved_path}' is not compatible "
f"with the supported architectures (SRVGGNetCompact / RRDBNet). "
f"Please ensure you are using a valid Real-ESRGAN checkpoint. "
f"Original error: {e}"
) from e
net.eval()
device = current_platform.get_local_torch_device()
if self._half_precision:
net = net.half()
net = net.to(device)
# Detect the model's native scale from network architecture
native_scale = 4 # sensible default
if hasattr(net, "upscale"):
native_scale = net.upscale
elif hasattr(net, "scale"):
native_scale = net.scale
model = UpscalerModel(net=net, scale=native_scale)
_MODEL_CACHE[resolved_path] = model
logger.info(
"Real-ESRGAN model loaded on device: %s (native_scale=%dx, outscale=%s)",
device,
native_scale,
f"{self._scale}x" if self._scale != native_scale else "native",
)
return model
def upscale(self, frames: list[np.ndarray]) -> list[np.ndarray]:
"""Upscale a list of HWC uint8 frames.
Uses the model's native scale for super-resolution, then resizes to
the desired ``outscale`` if it differs (cheap bicubic resize).
"""
if not frames:
return frames
model = self._ensure_model_loaded()
outscale = self._scale if self._scale != model.scale else None
return [model.upscale(frame, outscale=outscale) for frame in frames]
def upscale_batched(self, frames: list[np.ndarray]) -> list[np.ndarray]:
"""Upscale HWC uint8 frames with batched forwards grouped by resolution."""
if not frames:
return frames
total_start_time = time.perf_counter()
model = self._ensure_model_loaded()
outscale = self._scale if self._scale != model.scale else None
output_frames: list[np.ndarray | None] = [None] * len(frames)
groups: dict[tuple[int, ...], list[int]] = {}
for idx, frame in enumerate(frames):
groups.setdefault(tuple(frame.shape), []).append(idx)
for shape, indices in groups.items():
logger.info(
"RealESRGAN upscale group: frames=%d shape=%s indices=%s",
len(indices),
shape,
indices,
)
group_frames = [frames[idx] for idx in indices]
group_outputs = model.upscale_batch(group_frames, outscale=outscale)
for idx, output in zip(indices, group_outputs):
output_frames[idx] = output
if any(frame is None for frame in output_frames):
raise RuntimeError("RealESRGAN batch upscale did not produce all frames")
total_duration_s = time.perf_counter() - total_start_time
logger.info(
"RealESRGAN batch_upscale_frames completed in %.3f seconds for %d frames across %d groups",
total_duration_s,
len(frames),
len(groups),
)
return [frame for frame in output_frames if frame is not None]
# ---------------------------------------------------------------------------
# HF download helper
# ---------------------------------------------------------------------------
def _resolve_model_path(model_path: str) -> str:
"""Return a local .pth file path.
Accepts:
- An existing local file path (pass-through).
- An http(s) URL to a .pth file, downloaded into the local cache.
- A HuggingFace ``repo_id`` → downloads the default weight file
(``RealESRGAN_x4.pth``).
- A HuggingFace ``repo_id:filename`` → downloads *filename* from *repo_id*,
allowing users to specify custom weight files hosted on HF.
"""
cached_path = _RESOLVED_MODEL_PATH_CACHE.get(model_path)
if cached_path is not None:
return cached_path
if os.path.isfile(model_path):
_RESOLVED_MODEL_PATH_CACHE[model_path] = model_path
return model_path
parsed_url = urlparse(model_path)
if parsed_url.scheme in ("http", "https"):
filename = (
os.path.basename(unquote(parsed_url.path)) or _DEFAULT_REALESRGAN_FILENAME
)
cache_dir = os.path.join(
os.path.expanduser("~"), ".cache", "sglang", "realesrgan"
)
os.makedirs(cache_dir, exist_ok=True)
cache_key = sha256(model_path.encode("utf-8")).hexdigest()[:12]
local_path = os.path.join(cache_dir, f"{cache_key}-{filename}")
if not os.path.isfile(local_path):
tmp_path = f"{local_path}.tmp"
logger.info("Downloading Real-ESRGAN weights from URL %s", model_path)
torch.hub.download_url_to_file(model_path, tmp_path, progress=False)
os.replace(tmp_path, local_path)
_RESOLVED_MODEL_PATH_CACHE[model_path] = local_path
return local_path
# Parse optional "repo_id:filename" syntax; fall back to default filename.
if ":" in model_path and not model_path.startswith("/"):
repo_id, filename = model_path.split(":", 1)
else:
repo_id = model_path
filename = _DEFAULT_REALESRGAN_FILENAME
try:
from huggingface_hub import hf_hub_download
except ImportError as e:
raise ImportError(
"huggingface_hub is required to download Real-ESRGAN weights. "
"Install it with: pip install huggingface_hub"
) from e
logger.info(
"Downloading Real-ESRGAN weights from HF repo %s (file: %s)",
repo_id,
filename,
)
try:
local_path = hf_hub_download(
repo_id=repo_id,
filename=filename,
)
except Exception as e:
raise FileNotFoundError(
f"Failed to download Real-ESRGAN weights from HuggingFace repo "
f"'{repo_id}' (file: '{filename}'). If you are using a custom "
f"model, provide either a local .pth file path or use the "
f"'repo_id:filename' format (e.g. 'my-org/my-esrgan:weights.pth'). "
f"Original error: {e}"
) from e
_RESOLVED_MODEL_PATH_CACHE[model_path] = local_path
return local_path
# ---------------------------------------------------------------------------
# Module-level convenience function
# ---------------------------------------------------------------------------
def upscale_frames(
frames: list[np.ndarray],
model_path: Optional[str] = None,
scale: int = 4,
half_precision: bool = False,
) -> list[np.ndarray]:
"""
Convenience wrapper around ImageUpscaler.
The model always runs at its native resolution (e.g. 4× for
``RealESRGAN_x4.pth``). If *scale* differs from the native factor,
a cheap bicubic resize is applied after the network output the same
approach used by the official Real-ESRGAN ``--outscale`` flag.
Args:
frames: List of uint8 HWC numpy frames.
model_path: Local .pth file, HuggingFace repo ID, or
``repo_id:filename`` for a custom weight file.
None → default ``ai-forever/Real-ESRGAN`` with
``RealESRGAN_x4.pth``.
scale: Desired final upscaling factor (e.g. 2, 3, 4).
The 4× model is used internally; the output is
resized to match *scale* when it differs.
half_precision: Use fp16 inference (faster on supported GPUs).
Returns:
List of upscaled uint8 HWC numpy frames.
"""
upscaler = ImageUpscaler(
model_path=model_path,
scale=scale,
half_precision=half_precision,
)
return upscaler.upscale(frames)
def batch_upscale_frames(
frames: list[np.ndarray],
model_path: Optional[str] = None,
scale: int = 4,
) -> list[np.ndarray]:
"""
Batched Real-ESRGAN upscaling for realtime video paths.
The default ``upscale_frames`` API intentionally keeps its original
per-frame behavior. Call this helper only when the caller can tolerate
batched execution and same-shape grouping semantics.
"""
upscaler = ImageUpscaler(
model_path=model_path,
scale=scale,
half_precision=current_platform.is_cuda(),
)
return upscaler.upscale_batched(frames)
@@ -0,0 +1,538 @@
# SPDX-License-Identifier: Apache-2.0
"""
RIFE 4.22.lite frame interpolation for SGLang diffusion pipelines.
RIFE model code is vendored and adapted from:
- https://github.com/hzwer/ECCV2022-RIFE (MIT License)
- https://github.com/hzwer/Practical-RIFE (MIT License)
Copyright (c) 2021 Zhewei Huang
The FrameInterpolator wrapper and integration code are original work.
"""
import os
import time
from typing import Optional
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
# Default HuggingFace repo for RIFE 4.22.lite weights
_DEFAULT_RIFE_HF_REPO = "elfgum/RIFE-4.22.lite"
# Module-level cache: model_path -> Model instance
_MODEL_CACHE: dict[str, "Model"] = {}
_MAX_RIFE_BATCH_PAIRS = 16
# ---------------------------------------------------------------------------
# Vendored RIFE 4.22.lite model code
# (IFBlock, IFNet_HDv3 backbone, Model wrapper)
# ---------------------------------------------------------------------------
def warp(tenInput: torch.Tensor, tenFlow: torch.Tensor) -> torch.Tensor:
"""Warp tenInput by tenFlow using grid_sample."""
# Build base grid for the current size
tenHorizontal = (
torch.linspace(-1.0, 1.0, tenFlow.shape[3], device=tenFlow.device)
.view(1, 1, 1, tenFlow.shape[3])
.expand(tenFlow.shape[0], -1, tenFlow.shape[2], -1)
)
tenVertical = (
torch.linspace(-1.0, 1.0, tenFlow.shape[2], device=tenFlow.device)
.view(1, 1, tenFlow.shape[2], 1)
.expand(tenFlow.shape[0], -1, -1, tenFlow.shape[3])
)
tenGrid = torch.cat([tenHorizontal, tenVertical], dim=1)
tenFlow = torch.cat(
[
tenFlow[:, 0:1, :, :] / ((tenInput.shape[3] - 1.0) / 2.0),
tenFlow[:, 1:2, :, :] / ((tenInput.shape[2] - 1.0) / 2.0),
],
dim=1,
)
grid = (tenGrid + tenFlow).permute(0, 2, 3, 1)
return F.grid_sample(
input=tenInput,
grid=grid,
mode="bilinear",
padding_mode="border",
align_corners=True,
)
def _conv(in_planes, out_planes, kernel_size=3, stride=1, padding=1, dilation=1):
"""Conv2d + LeakyReLU helper (matches RIFE 4.22 conv())."""
return nn.Sequential(
nn.Conv2d(
in_planes,
out_planes,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=True,
),
nn.LeakyReLU(0.2, True),
)
class ResConv(nn.Module):
"""Residual convolution block with learnable beta scaling (RIFE 4.22)."""
def __init__(self, c: int, dilation: int = 1):
super().__init__()
self.conv = nn.Conv2d(c, c, 3, 1, dilation, dilation=dilation, groups=1)
self.beta = nn.Parameter(torch.ones((1, c, 1, 1)), requires_grad=True)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.relu(self.conv(x) * self.beta + x)
class IFBlock(nn.Module):
"""Single-scale optical flow + mask + feature block (RIFE 4.22)."""
def __init__(self, in_planes: int, c: int = 64):
super().__init__()
self.conv0 = nn.Sequential(
_conv(in_planes, c // 2, 3, 2, 1),
_conv(c // 2, c, 3, 2, 1),
)
self.convblock = nn.Sequential(
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
)
self.lastconv = nn.Sequential(
nn.ConvTranspose2d(c, 4 * 13, 4, 2, 1),
nn.PixelShuffle(2),
)
def forward(
self,
x: torch.Tensor,
flow: Optional[torch.Tensor] = None,
scale: float = 1.0,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
x = F.interpolate(
x, scale_factor=1.0 / scale, mode="bilinear", align_corners=False
)
if flow is not None:
flow = (
F.interpolate(
flow,
scale_factor=1.0 / scale,
mode="bilinear",
align_corners=False,
)
* 1.0
/ scale
)
x = torch.cat((x, flow), 1)
feat = self.conv0(x)
feat = self.convblock(feat)
tmp = self.lastconv(feat)
tmp = F.interpolate(
tmp, scale_factor=scale, mode="bilinear", align_corners=False
)
flow = tmp[:, :4] * scale
mask = tmp[:, 4:5]
feat = tmp[:, 5:]
return flow, mask, feat
class Head(nn.Module):
"""Feature encoder producing 4-channel features at full resolution (RIFE 4.22)."""
def __init__(self):
super().__init__()
self.cnn0 = nn.Conv2d(3, 16, 3, 2, 1)
self.cnn1 = nn.Conv2d(16, 16, 3, 1, 1)
self.cnn2 = nn.Conv2d(16, 16, 3, 1, 1)
self.cnn3 = nn.ConvTranspose2d(16, 4, 4, 2, 1)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x0 = self.cnn0(x)
x = self.relu(x0)
x1 = self.cnn1(x)
x = self.relu(x1)
x2 = self.cnn2(x)
x = self.relu(x2)
x3 = self.cnn3(x)
return x3
class IFNet(nn.Module):
"""4-scale IFNet optical flow network (RIFE 4.22 backbone)."""
def __init__(self):
super().__init__()
self.block0 = IFBlock(7 + 8, c=192)
self.block1 = IFBlock(8 + 4 + 8 + 8, c=128)
self.block2 = IFBlock(8 + 4 + 8 + 8, c=64)
self.block3 = IFBlock(8 + 4 + 8 + 8, c=32)
self.encode = Head()
def forward(
self,
x: torch.Tensor,
timestep: float = 0.5,
scale_list: Optional[list] = None,
) -> tuple[list, torch.Tensor, list]:
if scale_list is None:
scale_list = [8, 4, 2, 1]
channel = x.shape[1] // 2
img0 = x[:, :channel]
img1 = x[:, channel:]
if not torch.is_tensor(timestep):
timestep = (x[:, :1].clone() * 0 + 1) * timestep
else:
timestep = timestep.repeat(1, 1, img0.shape[2], img0.shape[3])
f0 = self.encode(img0[:, :3])
f1 = self.encode(img1[:, :3])
flow_list = []
merged = []
mask_list = []
warped_img0 = img0
warped_img1 = img1
flow = None
mask = None
block = [self.block0, self.block1, self.block2, self.block3]
for i in range(4):
if flow is None:
flow, mask, feat = block[i](
torch.cat((img0[:, :3], img1[:, :3], f0, f1, timestep), 1),
None,
scale=scale_list[i],
)
else:
wf0 = warp(f0, flow[:, :2])
wf1 = warp(f1, flow[:, 2:4])
fd, m0, feat = block[i](
torch.cat(
(
warped_img0[:, :3],
warped_img1[:, :3],
wf0,
wf1,
timestep,
mask,
feat,
),
1,
),
flow,
scale=scale_list[i],
)
mask = m0
flow = flow + fd
mask_list.append(mask)
flow_list.append(flow)
warped_img0 = warp(img0, flow[:, :2])
warped_img1 = warp(img1, flow[:, 2:4])
merged.append((warped_img0, warped_img1))
mask = torch.sigmoid(mask)
merged[3] = warped_img0 * mask + warped_img1 * (1 - mask)
return flow_list, mask_list[3], merged
class Model:
"""Wraps IFNet, provides load_model() and inference() API."""
def __init__(self):
self.flownet = IFNet()
self.device_type: str = "cpu"
def eval(self) -> "Model":
self.flownet.eval()
return self
def device(self) -> torch.device:
return next(self.flownet.parameters()).device
def load_model(self, path: str, strip_module_prefix: bool = True) -> None:
"""Load weights from {path}/flownet.pkl.
Args:
path: Directory containing ``flownet.pkl``.
strip_module_prefix: If True, strip the ``module.`` prefix that
``DataParallel`` / ``DistributedDataParallel`` adds to keys.
"""
flownet_path = os.path.join(path, "flownet.pkl")
if not os.path.isfile(flownet_path):
raise FileNotFoundError(
f"RIFE weight file not found: {flownet_path}\n"
"Expected layout: <model_path>/flownet.pkl"
)
def convert(param):
if strip_module_prefix:
return {
k.replace("module.", ""): v
for k, v in param.items()
if "module." in k
}
else:
return {k: v for k, v in param.items() if "module." not in k}
state = torch.load(flownet_path, map_location="cpu", weights_only=False)
self.flownet.load_state_dict(convert(state), strict=False)
logger.info("Loaded RIFE weights from %s", flownet_path)
def inference(
self,
img0: torch.Tensor,
img1: torch.Tensor,
scale: float = 1.0,
timestep: float = 0.5,
) -> torch.Tensor:
"""Interpolate a single intermediate frame between img0 and img1."""
n, c, h, w = img0.shape
# Pad to multiples of 32 so that RIFE's downsample/upsample round-trips
# preserve spatial dimensions exactly.
ph = ((h - 1) // 32 + 1) * 32
pw = ((w - 1) // 32 + 1) * 32
pad = (0, pw - w, 0, ph - h)
img0 = F.pad(img0, pad)
img1 = F.pad(img1, pad)
imgs = torch.cat((img0, img1), 1)
scale_list = [8 / scale, 4 / scale, 2 / scale, 1 / scale]
with torch.no_grad():
flow_list, mask, merged = self.flownet(
imgs,
timestep=timestep,
scale_list=scale_list,
)
# Crop back to original resolution
return merged[3][:, :, :h, :w]
# ---------------------------------------------------------------------------
# FrameInterpolator public class
# ---------------------------------------------------------------------------
class FrameInterpolator:
"""
Lazy-loaded RIFE 4.22.lite frame interpolator.
Weights are loaded on first call to `.interpolate()` and cached globally
per model_path to avoid reloading across requests.
"""
def __init__(self, model_path: Optional[str] = None):
self._model_path = model_path
self._resolved_path: Optional[str] = None
def _ensure_model_loaded(self) -> Model:
"""Load RIFE model weights.
Accepts a local directory **or** a HuggingFace repo ID. When *None*
(the default) the weights are downloaded (and cached) automatically
from ``elfgum/RIFE-4.22.lite`` via ``maybe_download_model()``.
"""
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import (
maybe_download_model,
)
model_path = self._model_path or _DEFAULT_RIFE_HF_REPO
# Resolve: local path pass-through, HF repo ID → download & cache
model_path = maybe_download_model(model_path)
self._resolved_path = model_path
if model_path in _MODEL_CACHE:
return _MODEL_CACHE[model_path]
device = current_platform.get_local_torch_device()
model = Model()
model.load_model(model_path, strip_module_prefix=True)
model.eval()
model.flownet = model.flownet.to(device)
_MODEL_CACHE[model_path] = model
logger.info("RIFE model loaded on device: %s", device)
return model
@staticmethod
def _frame_to_tensor(frame: np.ndarray, device: torch.device) -> torch.Tensor:
"""Convert uint8 HWC numpy frame to float32 CHW tensor on device."""
t = torch.from_numpy(frame).permute(2, 0, 1).unsqueeze(0).float() / 255.0
return t.to(device)
@staticmethod
def _tensor_to_frame(t: torch.Tensor) -> np.ndarray:
"""Convert float32 CHW tensor (batch=1) to uint8 HWC numpy frame."""
arr = t.squeeze(0).permute(1, 2, 0).clamp(0.0, 1.0).cpu().numpy()
return (arr * 255.0).astype(np.uint8)
@staticmethod
def _frames_to_tensor(
frames: list[np.ndarray], device: torch.device
) -> torch.Tensor:
t = torch.from_numpy(np.stack(frames, axis=0))
t = t.permute(0, 3, 1, 2).contiguous().float() / 255.0
return t.to(device, non_blocking=True)
@staticmethod
def _tensor_to_frames(t: torch.Tensor) -> list[np.ndarray]:
arr = t.permute(0, 2, 3, 1).clamp(0.0, 1.0).cpu().numpy()
arr = (arr * 255.0).astype(np.uint8)
return [arr[i] for i in range(arr.shape[0])]
def _make_inference(
self, model: Model, I0: torch.Tensor, I1: torch.Tensor, n: int, scale: float
) -> list[torch.Tensor]:
"""
Recursively generate n-1 intermediate frames between I0 and I1.
Returns a list of intermediate frame tensors (not including I0 or I1).
"""
if n == 1:
return [model.inference(I0, I1, scale=scale)]
mid = model.inference(I0, I1, scale=scale)
return (
self._make_inference(model, I0, mid, n // 2, scale)
+ [mid]
+ self._make_inference(model, mid, I1, n // 2, scale)
)
def _interpolate_2x_batched(
self, model: Model, frames: list[np.ndarray], scale: float
) -> list[np.ndarray]:
device = model.device()
source = self._frames_to_tensor(frames, device)
intermediate_frames: list[np.ndarray] = []
with torch.inference_mode():
for start in range(0, len(frames) - 1, _MAX_RIFE_BATCH_PAIRS):
end = min(start + _MAX_RIFE_BATCH_PAIRS, len(frames) - 1)
mids = model.inference(
source[start:end],
source[start + 1 : end + 1],
scale=scale,
)
intermediate_frames.extend(self._tensor_to_frames(mids))
result: list[np.ndarray] = []
for i, mid in enumerate(intermediate_frames):
result.append(frames[i])
result.append(mid)
result.append(frames[-1])
return result
def interpolate(
self,
frames: list[np.ndarray],
exp: int = 1,
scale: float = 1.0,
) -> tuple[list[np.ndarray], int]:
"""
Interpolate frames using RIFE.
Args:
frames: List of uint8 numpy arrays with shape [H, W, 3].
exp: Exponent for interpolation factor. 1 → 2×, 2 → 4×.
scale: RIFE inference scale. Use 0.5 for high-resolution inputs.
Returns:
(interpolated_frames, multiplier) where multiplier = 2**exp.
"""
if len(frames) < 2:
logger.warning(
"Frame interpolation requires at least 2 frames; returning input unchanged."
)
return frames, 1
model = self._ensure_model_loaded()
device = model.device()
n_intermediate = 2**exp // 2 # intermediates per adjacent pair
start_time = time.perf_counter()
if n_intermediate == 1:
result = self._interpolate_2x_batched(model, frames, scale)
logger.info(
"RIFE batched interpolation completed in %.3f seconds for %d frames",
time.perf_counter() - start_time,
len(frames),
)
return result, 2**exp
result: list[np.ndarray] = []
for i in range(len(frames) - 1):
I0 = self._frame_to_tensor(frames[i], device)
I1 = self._frame_to_tensor(frames[i + 1], device)
intermediate_tensors = self._make_inference(
model, I0, I1, n_intermediate, scale
)
result.append(frames[i])
for t in intermediate_tensors:
result.append(self._tensor_to_frame(t))
result.append(frames[-1])
multiplier = 2**exp
logger.info(
"RIFE interpolation completed in %.3f seconds for %d frames",
time.perf_counter() - start_time,
len(frames),
)
return result, multiplier
# ---------------------------------------------------------------------------
# Module-level convenience function
# ---------------------------------------------------------------------------
def interpolate_video_frames(
frames: list[np.ndarray],
exp: int = 1,
scale: float = 1.0,
model_path: Optional[str] = None,
) -> tuple[list[np.ndarray], int]:
"""
Convenience wrapper around FrameInterpolator.
Args:
frames: List of uint8 HWC numpy frames.
exp: Interpolation exponent (1=2×, 2=4×).
scale: RIFE inference scale (default 1.0; use 0.5 for high-res).
model_path: Local directory or HuggingFace repo ID containing
``flownet.pkl``. *None* → default ``elfgum/RIFE-4.22.lite``.
Returns:
(interpolated_frames, multiplier)
"""
interpolator = FrameInterpolator(model_path=model_path)
return interpolator.interpolate(frames, exp=exp, scale=scale)