File size: 11,477 Bytes
8d422ba
 
 
d373f5e
 
 
 
 
 
8d422ba
01fe360
8d422ba
 
 
 
 
 
01fe360
 
8d422ba
 
 
 
 
 
 
 
 
 
 
 
 
 
d373f5e
8d422ba
 
01fe360
 
8d422ba
 
 
 
 
 
 
 
 
 
 
01fe360
8d422ba
d373f5e
 
 
01fe360
8d422ba
d373f5e
8d422ba
 
01fe360
8d422ba
 
01fe360
8d422ba
 
01fe360
 
 
8d422ba
 
01fe360
8d422ba
01fe360
 
 
 
 
 
 
 
 
 
 
 
 
8d422ba
 
 
78f9940
 
 
 
 
 
 
 
 
 
 
01fe360
78f9940
 
01fe360
 
 
 
 
 
 
 
8d422ba
 
01fe360
 
8d422ba
01fe360
 
8d422ba
01fe360
 
 
 
 
8d422ba
 
d373f5e
 
 
78f9940
d373f5e
 
 
 
 
 
 
 
 
 
 
01fe360
d373f5e
 
 
 
 
 
 
78f9940
d373f5e
78f9940
d373f5e
8d422ba
 
01fe360
8d422ba
 
01fe360
d373f5e
01fe360
8d422ba
 
 
 
 
 
 
01fe360
8d422ba
01fe360
 
 
8d422ba
 
 
01fe360
8d422ba
01fe360
 
8d422ba
01fe360
 
 
 
8d422ba
01fe360
 
 
 
8d422ba
01fe360
8d422ba
01fe360
8d422ba
 
 
01fe360
8d422ba
 
 
 
 
01fe360
8d422ba
01fe360
 
8d422ba
01fe360
 
8d422ba
01fe360
 
 
 
 
 
 
 
 
8d422ba
 
 
 
 
78f9940
8d422ba
 
 
01fe360
 
 
 
8d422ba
 
 
 
 
01fe360
8d422ba
 
 
 
01fe360
 
 
 
 
 
 
 
 
 
 
8d422ba
 
 
 
01fe360
 
d373f5e
01fe360
d373f5e
8d422ba
01fe360
 
 
d373f5e
01fe360
 
78f9940
8d422ba
 
01fe360
 
 
 
 
 
 
8d422ba
01fe360
8d422ba
 
 
01fe360
 
 
8d422ba
01fe360
 
8d422ba
 
 
01fe360
8d422ba
 
01fe360
8d422ba
01fe360
 
 
8d422ba
 
 
78f9940
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import os

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
# `~/.cache/torch` is not writable in the Space image and RAFT weights are fetched
# through torch.hub, so point TORCH_HOME (and matplotlib's config dir) somewhere writable.
os.environ.setdefault("TORCH_HOME", "/tmp/torch_home")
os.environ.setdefault("MPLCONFIGDIR", "/tmp/mplconfig")
os.makedirs(os.path.join(os.environ["TORCH_HOME"], "hub", "checkpoints"), exist_ok=True)
os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)

import spaces  # noqa: E402  (must precede any CUDA-touching import)

import tempfile  # noqa: E402
import time  # noqa: E402
from typing import List, Tuple  # noqa: E402

import gradio as gr  # noqa: E402
import imageio.v3 as iio  # noqa: E402
import imageio_ffmpeg  # noqa: E402
import numpy as np  # noqa: E402
import torch  # noqa: E402
from PIL import Image  # noqa: E402
from torchvision.models.optical_flow import Raft_Large_Weights, raft_large  # noqa: E402

from diffusers import DDIMScheduler  # noqa: E402
from pipeline.stream_diffvsr_pipeline import (  # noqa: E402
    ControlNetModel,
    StreamDiffVSRPipeline,
    UNet2DConditionModel,
)
from temporal_autoencoder.autoencoder_tiny import TemporalAutoencoderTiny  # noqa: E402

torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True

MODEL_ID = "Jamichsu/Stream-DiffVSR"
SCALE = 4  # the model is a x4 super-resolver
MULTIPLE_OF = 8  # UNet needs the low-res side divisible by 8

# ---------------------------------------------------------------- model load
controlnet = ControlNetModel.from_pretrained(MODEL_ID, subfolder="controlnet")
unet = UNet2DConditionModel.from_pretrained(MODEL_ID, subfolder="unet")
vae = TemporalAutoencoderTiny.from_pretrained(MODEL_ID, subfolder="vae")
scheduler = DDIMScheduler.from_pretrained(MODEL_ID, subfolder="scheduler")

pipe = StreamDiffVSRPipeline.from_pretrained(
    MODEL_ID,
    controlnet=controlnet,
    unet=unet,
    vae=vae,
    scheduler=scheduler,
    safety_checker=None,
    feature_extractor=None,
    requires_safety_checker=False,
    custom_pipeline=None,
)
pipe.set_progress_bar_config(desc="Stream-DiffVSR")
pipe = pipe.to("cuda")

of_model = raft_large(weights=Raft_Large_Weights.DEFAULT).eval().to("cuda")
of_model.requires_grad_(False)

FFMPEG = imageio_ffmpeg.get_ffmpeg_exe()


# ------------------------------------------------------------------- helpers
def _round_down(value: int, multiple: int = MULTIPLE_OF) -> int:
    return max(multiple, (int(value) // multiple) * multiple)


def _read_frames(path: str, max_frames: int) -> Tuple[List[Image.Image], float]:
    try:
        fps = float(iio.immeta(path, plugin="pyav").get("fps") or 24.0)
    except Exception:
        fps = 24.0
    if not np.isfinite(fps) or fps <= 0:
        fps = 24.0

    frames: List[Image.Image] = []
    for frame in iio.imiter(path, plugin="pyav"):
        frames.append(Image.fromarray(np.asarray(frame)).convert("RGB"))
        if len(frames) >= max_frames:
            break
    if not frames:
        raise gr.Error("Could not read any frame from this video.")
    return frames, fps


def _lowres_size(w: int, h: int, max_input_size: int) -> Tuple[int, int]:
    """Downscale to `max_input_size` on the long side and to a 16:9 pixel budget.

    Bounding the pixel count (not just the long side) keeps the runtime — and hence
    the ZeroGPU reservation — predictable for any aspect ratio.
    """
    budget = max_input_size * max_input_size * 9.0 / 16.0
    scale = min(1.0, max_input_size / float(max(w, h)), (budget / float(w * h)) ** 0.5)
    return _round_down(w * scale), _round_down(h * scale)


def _prepare_lowres(frames: List[Image.Image], max_input_size: int) -> List[Image.Image]:
    w, h = _lowres_size(*frames[0].size, max_input_size=max_input_size)
    return [f if f.size == (w, h) else f.resize((w, h), Image.BICUBIC) for f in frames]


def _write_video(frames: List[Image.Image], fps: float, suffix: str) -> str:
    w, h = frames[0].size
    path = tempfile.NamedTemporaryFile(suffix=f"_{suffix}.mp4", delete=False).name
    writer = imageio_ffmpeg.write_frames(
        path,
        (w, h),
        fps=fps,
        codec="libx264",
        pix_fmt_in="rgb24",
        pix_fmt_out="yuv420p",
        macro_block_size=1,
        quality=None,
        output_params=["-crf", "17", "-preset", "veryfast", "-movflags", "+faststart"],
    )
    writer.send(None)
    for frame in frames:
        writer.send(np.asarray(frame, dtype=np.uint8).tobytes())
    writer.close()
    return path


def _estimate_duration(*args, **kwargs) -> int:
    """ZeroGPU duration estimator, fitted to runtime measured on this Space.

    Measured on ZeroGPU at 320x176 LR: 0.67 s/frame at 4 steps, 1.20 s/frame at 8 steps,
    i.e. ~0.15 s/frame fixed + ~0.125 s/frame/step, scaling with the LR pixel count.
    """
    values = list(args)

    def _pick(name, index, default):
        if name in kwargs and kwargs[name] is not None:
            return kwargs[name]
        if len(values) > index and values[index] is not None:
            return values[index]
        return default

    try:
        num_frames = int(_pick("num_frames", 1, 30))
        max_input_size = int(_pick("max_input_size", 2, 320))
        steps = int(_pick("num_inference_steps", 3, 4))
    except (TypeError, ValueError):
        num_frames, max_input_size, steps = 30, 320, 4

    # assume a 16:9 clip for the pixel-count scaling against the 312x176 reference
    # `_lowres_size` bounds the LR frame to a 16:9 pixel budget, so this is an upper bound
    px = (max_input_size * max_input_size * 9.0 / 16.0) / (312.0 * 176.0)
    seconds = 5.0 + num_frames * (0.15 + 0.125 * steps) * px * 1.25
    return int(min(115, max(20, round(seconds))))


# ----------------------------------------------------------------- inference
@spaces.GPU(duration=_estimate_duration)
def upscale_video(
    video: str,
    num_frames: int = 30,
    max_input_size: int = 320,
    num_inference_steps: int = 4,
    seed: int = 42,
    progress=gr.Progress(track_tqdm=True),
) -> Tuple[str, str, str]:
    """Upscale a low-resolution video 4x with Stream-DiffVSR.

    Args:
        video: path to the low-resolution input video.
        num_frames: how many frames from the start of the clip to process.
        max_input_size: the input is downscaled so its longest side is at most
            this many pixels before the 4x upscale.
        num_inference_steps: denoising steps per frame (the model is distilled for 4).
        seed: RNG seed for the initial latents.

    Returns:
        The Stream-DiffVSR 4x video, a bicubic 4x video for reference, and a status line.
    """
    if not video:
        raise gr.Error("Please upload a low-resolution video first.")

    num_frames = int(num_frames)
    num_inference_steps = int(num_inference_steps)
    max_input_size = int(max_input_size)
    seed = int(seed)

    frames, fps = _read_frames(video, num_frames)
    lowres = _prepare_lowres(frames, max_input_size)
    in_w, in_h = lowres[0].size
    out_w, out_h = in_w * SCALE, in_h * SCALE

    generator = torch.Generator(device="cuda").manual_seed(seed)

    start = time.perf_counter()
    with torch.inference_mode():
        result = pipe(
            "",
            lowres,
            num_inference_steps=num_inference_steps,
            guidance_scale=0,
            of_model=of_model,
            generator=generator,
        )
    elapsed = time.perf_counter() - start

    sr_frames = [f[0] if isinstance(f, (list, tuple)) else f for f in result.images]
    bicubic_frames = [f.resize((out_w, out_h), Image.BICUBIC) for f in lowres]

    sr_path = _write_video(sr_frames, fps, "streamdiffvsr")
    bicubic_path = _write_video(bicubic_frames, fps, "bicubic")

    status = (
        f"{len(sr_frames)} frames · {in_w}x{in_h} → {out_w}x{out_h} · "
        f"{num_inference_steps} steps · {elapsed:.1f}s "
        f"({elapsed / max(len(sr_frames), 1):.2f}s/frame)"
    )
    return sr_path, bicubic_path, status


# ------------------------------------------------------------------------ UI
CSS = """
#col-container { max-width: 1200px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

with gr.Blocks() as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            """
# Stream-DiffVSR · 4× Video Super-Resolution
Low-latency streamable video super-resolution via auto-regressive diffusion — a
causal, 4-step distilled denoiser with Auto-regressive Temporal Guidance (ARTG)
and a temporal-aware decoder, so every frame is restored from **past frames only**.

[Paper](https://huggingface.co/papers/2512.23709) ·
[Model](https://huggingface.co/Jamichsu/Stream-DiffVSR) ·
[Code](https://github.com/jamichss/Stream-DiffVSR) ·
[Project page](https://jamichss.github.io/stream-diffvsr-project-page/)
            """
        )

        with gr.Row():
            with gr.Column(scale=1):
                video_in = gr.Video(
                    label="Low-resolution video",
                    sources=["upload"],
                    height=300,
                )
                run_btn = gr.Button("Upscale 4×", variant="primary")
                status_out = gr.Markdown()
            with gr.Column(scale=2):
                with gr.Row():
                    sr_out = gr.Video(label="Stream-DiffVSR 4×", autoplay=True, loop=True)
                    bicubic_out = gr.Video(label="Bicubic 4× (reference)", autoplay=True, loop=True)

        with gr.Accordion("Advanced settings", open=False):
            with gr.Row():
                num_frames = gr.Slider(
                    label="Frames to process",
                    minimum=4,
                    maximum=40,
                    step=1,
                    value=30,
                )
                max_input_size = gr.Slider(
                    label="Max input size (long side, px)",
                    minimum=128,
                    maximum=384,
                    step=32,
                    value=320,
                    info="The clip is downscaled to this long side (and a matching 16:9 pixel budget) before the 4× upscale.",
                )
            with gr.Row():
                num_inference_steps = gr.Slider(
                    label="Denoising steps",
                    minimum=1,
                    maximum=8,
                    step=1,
                    value=4,
                    info="The model is distilled for 4 steps.",
                )
                seed = gr.Number(label="Seed", value=42, precision=0)

        gr.Examples(
            examples=[
                ["examples/portrait_lr.mp4"],
                ["examples/city_night_lr.mp4"],
                ["examples/waterfall_lr.mp4"],
            ],
            inputs=[video_in],
            outputs=[sr_out, bicubic_out, status_out],
            fn=upscale_video,
            cache_examples=True,
            cache_mode="lazy",
            label="Examples (low-resolution 320×176 clips)",
        )

    run_btn.click(
        fn=upscale_video,
        inputs=[video_in, num_frames, max_input_size, num_inference_steps, seed],
        outputs=[sr_out, bicubic_out, status_out],
        api_name="upscale_video",
    )

if __name__ == "__main__":
    # Gradio 6 moved `theme` / `css` from the Blocks constructor to launch()
    demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)