"""StreamOPD-4B-ST-CueGate — streaming video QA demo. Reproduces the paper's memory-free *recent-window* inference protocol: the model answers a question about a live video stream while seeing only the N most recent frames sampled at 1 fps — no memory bank, no retrieval, no KV-cache compression, no reasoning trace. The prompt construction here mirrors the reference implementation in https://github.com/UniX-AI-Lab/StreamOPD (Apache-2.0), `streamopd/streaming/qwen3_5.py` :: RecentWindowQAModel: * each recent frame is fed as a separate image inside its own <|vision_start|> ... <|vision_end|> block (image tokens, not video tokens), * the assistant turn is opened with thinking already closed ("\\n\\n\\n\\n") so the model answers in instruct mode, * greedy decoding. Frame selection mirrors `decode_video_to_chunks_qwen` with chunk_duration = 1.0 s, fps = 1.0 and `recent_frames_only = N`. """ from __future__ import annotations import spaces # must come before torch / transformers import math import os import re import time import av import gradio as gr import torch from PIL import Image from transformers import AutoModelForImageTextToText, AutoProcessor MODEL_ID = "UniX-Lab/StreamOPD-4B-ST-CueGate" # qwen_vl_utils sampling constants, mirrored from the reference decoder. FRAME_FACTOR = 2 FPS_MIN_FRAMES = 4 FPS_MAX_FRAMES = 768 # Vision budget. The upper bound keeps a 4K upload from exploding the prefill; # the lower bound is the checkpoint's own preprocessor default. MIN_PIXELS = 64 * 32 * 32 # 65_536 MAX_PIXELS = 900 * 32 * 32 # 921_600 (~900 vision tokens per frame) CHUNK_DURATION = 1.0 # seconds per streaming chunk SAMPLE_FPS = 1.0 # frames sampled per second of stream IMAGE_TOKEN = "<|image_pad|>" VISION_START = "<|vision_start|>" VISION_END = "<|vision_end|>" processor = AutoProcessor.from_pretrained( MODEL_ID, min_pixels=MIN_PIXELS, max_pixels=MAX_PIXELS ) model = AutoModelForImageTextToText.from_pretrained( MODEL_ID, dtype=torch.bfloat16, attn_implementation="sdpa" ) model.eval() model.to("cuda") # --------------------------------------------------------------------------- # # Video decoding — recent-window frame selection # --------------------------------------------------------------------------- # def _floor_by(value: float, factor: int) -> int: return int(math.floor(value / factor) * factor) def _ceil_by(value: float, factor: int) -> int: return int(math.ceil(value / factor) * factor) def probe_video(path: str) -> tuple[float, float, int]: """Return (fps, duration_seconds, total_frames) for a video file.""" with av.open(path) as container: stream = container.streams.video[0] fps = float(stream.average_rate) if stream.average_rate else 25.0 duration = 0.0 if stream.duration is not None and stream.time_base: duration = float(stream.duration * stream.time_base) elif container.duration is not None: duration = float(container.duration) / av.time_base total = int(stream.frames or 0) if total <= 0: total = max(1, int(round(duration * fps))) if duration <= 0: duration = total / fps return fps, duration, total def plan_window( path: str, stream_position_pct: float, window_chunks: int, sample_fps: float = SAMPLE_FPS, chunk_duration: float = CHUNK_DURATION, ): """Pick the frame indices of the `window_chunks` most recent 1-second chunks. Mirrors `decode_video_to_chunks_qwen` (uniform 1 fps sampling over the already-streamed prefix, bucketed into 1 s chunks, keep the last N buckets). """ fps, duration, total_frames = probe_video(path) pct = min(max(float(stream_position_pct), 1.0), 100.0) cut_seconds = duration * pct / 100.0 n_prefix = max(1, min(total_frames, int(round(cut_seconds * fps)))) nframes = n_prefix / fps * sample_fps min_frames = _ceil_by(FPS_MIN_FRAMES, FRAME_FACTOR) max_frames = _floor_by(min(FPS_MAX_FRAMES, n_prefix), FRAME_FACTOR) nframes = min(min(max(nframes, min_frames), max_frames), n_prefix) nframes = max(1, _floor_by(nframes, FRAME_FACTOR)) nframes = int(min(nframes, n_prefix)) if nframes <= 1: indices = [n_prefix - 1] else: indices = [ int(round(i * (n_prefix - 1) / (nframes - 1))) for i in range(nframes) ] buckets: dict[int, list[int]] = {} for idx in indices: buckets.setdefault(int((idx / fps) // chunk_duration), []).append(idx) keys = sorted(buckets)[-max(1, int(window_chunks)):] selected = [idx for key in keys for idx in buckets[key]] timestamps = [idx / fps for idx in selected] return selected, timestamps, fps, duration, cut_seconds def grab_frames(path: str, indices: list[int]) -> list[Image.Image]: """Decode the given absolute frame indices as PIL images.""" wanted = sorted(set(indices)) found: dict[int, Image.Image] = {} with av.open(path) as container: stream = container.streams.video[0] stream.thread_type = "AUTO" fps = float(stream.average_rate) if stream.average_rate else 25.0 seek_seconds = max(0.0, (wanted[0] / fps) - 2.0) try: container.seek( int(seek_seconds / float(stream.time_base)), stream=stream, backward=True, ) except Exception: container.seek(0) remaining = list(wanted) last = None for frame in container.decode(stream): if frame.pts is None: continue position = int(round(float(frame.pts * stream.time_base) * fps)) last = frame while remaining and position >= remaining[0]: found[remaining.pop(0)] = frame.to_image() if not remaining: break if remaining and last is not None: image = last.to_image() for idx in remaining: found[idx] = image if not found: raise gr.Error("Could not decode any frame from this video.") fallback = next(iter(found.values())) return [found.get(i, fallback).convert("RGB") for i in indices] # --------------------------------------------------------------------------- # # Prompt construction + generation (mirrors RecentWindowQAModel) # --------------------------------------------------------------------------- # def build_prompt(num_frames: int, question: str, thinking: bool) -> str: blocks = "".join(f"{VISION_START}{IMAGE_TOKEN}{VISION_END}" for _ in range(num_frames)) tail = "\n" if thinking else "\n\n\n\n" return ( "<|im_start|>user\n" + blocks + "\n" + question + "<|im_end|>\n<|im_start|>assistant\n" + tail ) def strip_thinking(text: str) -> str: return re.sub(r".*?\s*", "", text, flags=re.DOTALL).strip() def _gpu_duration( video=None, question="", stream_position=100.0, window_chunks=4, max_new_tokens=192, thinking=False, ) -> int: """Size the ZeroGPU reservation to the requested work (measured ~2-8 s typical).""" return int(min(150, 12 + 1.5 * float(window_chunks) + 0.05 * float(max_new_tokens))) @spaces.GPU(duration=_gpu_duration) def answer_stream( video: str | None, question: str, stream_position: float = 100.0, window_chunks: int = 4, max_new_tokens: int = 192, thinking: bool = False, ) -> tuple[str, list, str]: """Answer a question about a video stream using only its most recent frames. Args: video: Path to the video file that plays the stream. question: Free-form question about what is happening in the stream right now, e.g. "What is the person doing with the object in their hands?". stream_position: Where the stream is "now", as a percentage of the video length (100 = the very end). Everything after this point is unseen. window_chunks: How many of the most recent 1-second chunks the model may see. max_new_tokens: Maximum number of tokens to generate. thinking: Emit a reasoning trace before answering (off in the paper's protocol). Returns: The model's answer, the frames it actually saw, and run details. """ if not video: raise gr.Error("Please upload or pick a video first.") if not question or not question.strip(): raise gr.Error("Please enter a question about the video.") indices, timestamps, fps, duration, cut_seconds = plan_window( video, stream_position, int(window_chunks) ) frames = grab_frames(video, indices) prompt = build_prompt(len(frames), question.strip(), bool(thinking)) inputs = processor(text=[prompt], images=frames, return_tensors="pt") inputs = {k: (v.to("cuda") if hasattr(v, "to") else v) for k, v in inputs.items()} prompt_len = int(inputs["input_ids"].shape[1]) num_vision_tokens = int((inputs["input_ids"] == model.config.image_token_id).sum()) started = time.perf_counter() with torch.inference_mode(): generated = model.generate( **inputs, max_new_tokens=int(max_new_tokens), do_sample=False, ) elapsed = time.perf_counter() - started text = processor.tokenizer.decode( generated[0][prompt_len:], skip_special_tokens=True ).strip() text = strip_thinking(text) or text gallery = [ (frame, f"t = {ts:.2f}s") for frame, ts in zip(frames, timestamps) ] details = ( f"**Recent window:** {len(frames)} frame(s) at " f"{', '.join(f'{t:.2f}s' for t in timestamps)} \n" f"**Stream cut-off:** {cut_seconds:.2f}s of {duration:.2f}s " f"(source {fps:.1f} fps) \n" f"**Prompt:** {prompt_len} tokens ({num_vision_tokens} vision) \n" f"**Generation:** {elapsed:.2f}s" ) return text, gallery, details # --------------------------------------------------------------------------- # # UI # --------------------------------------------------------------------------- # EXAMPLES = [ [ "examples/barista_frothing.mp4", "What is the person doing with the metal pitcher right now?", 100, ], [ "examples/slicing_veggie.mp4", "Describe what has appeared on the cutting board by now.", 100, ], [ "examples/basketball_dribble.mp4", "What is the man on the court doing at this moment?", 60, ], [ "examples/train_arriving.mp4", "Briefly describe what is happening in the scene right now.", 100, ], ] CSS = """ #answer_box textarea { font-size: 1.05rem; } """ with gr.Blocks(title="StreamOPD Streaming Video QA") as demo: gr.Markdown( """ # StreamOPD-4B — Streaming Video QA [**StreamOPD: A Post-Training Recipe with Spatio-Temporal Cue Gating for Streaming Video Understanding**](https://huggingface.co/papers/2608.16320) · [model](https://huggingface.co/UniX-Lab/StreamOPD-4B-ST-CueGate) · [code](https://github.com/UniX-AI-Lab/StreamOPD) Ask a question about a video **as if it were a live stream**. Following the paper's memory-free *recent-window* protocol, the model only ever sees the **4 most recent frames at 1 fps** — no memory bank, no retrieval, no KV-cache compression and no reasoning trace. Move the *stream position* slider to pretend the stream is still running, and everything after that point stays unseen. """ ) with gr.Row(): with gr.Column(scale=1): video = gr.Video(label="Video stream", height=320) stream_position = gr.Slider( 1, 100, value=100, step=1, label="Stream position (% of the video)", info="Where the stream is 'now'. 100% = the very end of the clip.", ) question = gr.Textbox( label="Question", lines=4, placeholder=( "Ask anything about what is happening right now, e.g.\n" "What is the person doing with the object in their hands?" ), ) run = gr.Button("Answer", variant="primary") with gr.Accordion("Advanced", open=False): window_chunks = gr.Slider( 1, 16, value=4, step=1, label="Recent window (1-second chunks)", info="4 is the paper's protocol.", ) max_new_tokens = gr.Slider( 8, 512, value=192, step=8, label="Max new tokens" ) thinking = gr.Checkbox( value=False, label="Enable thinking", info="Off matches the reported instruct-mode results.", ) with gr.Column(scale=1): answer = gr.Textbox(label="Answer", lines=4, elem_id="answer_box") frames_view = gr.Gallery( label="Frames the model actually saw", columns=4, height=200, object_fit="contain", ) details = gr.Markdown() gr.Examples( examples=EXAMPLES, inputs=[video, question, stream_position], outputs=[answer, frames_view, details], fn=answer_stream, cache_examples=True, cache_mode="lazy", label="Examples", ) gr.Markdown( """ Example clips come from [`linoyts/repo-to-space-example-videos`](https://huggingface.co/datasets/linoyts/repo-to-space-example-videos) (CC0-1.0). Prompt construction and frame selection follow [`streamopd/streaming/qwen3_5.py`](https://github.com/UniX-AI-Lab/StreamOPD/blob/main/streamopd/streaming/qwen3_5.py) (Apache-2.0). Answers are limited by construction to evidence inside the recent window. """ ) gr.on( triggers=[run.click, question.submit], fn=answer_stream, inputs=[video, question, stream_position, window_chunks, max_new_tokens, thinking], outputs=[answer, frames_view, details], api_name="answer_stream", ) if __name__ == "__main__": demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)