File size: 18,108 Bytes
91c5e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1eb7df
 
 
 
 
 
91c5e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2451bc2
 
 
 
91c5e5d
 
 
 
2451bc2
 
 
 
 
91c5e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1eb7df
91c5e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1eb7df
 
 
91c5e5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1eb7df
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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
"""
Object-centric procedural video QA with temporal grounding, using
BrightGuo/ProcObject-Qwen3-VL-4B (Qwen3-VL-4B-Instruct fine-tuned on ProcObject-10K,
NeurIPS 2026 Evaluations & Datasets Track).

The inference protocol replicates the authors' released benchmark harness
(https://github.com/WenliangGuo/ProcObject-10K) exactly:
  * 48 frames sampled uniformly over the clip (the fine-tuned model's training
    frame budget), resized to 512x512;
  * each frame is sent as an image preceded by "[Timestamp: <t>s]" (seconds from
    the clip start), then the question;
  * the system prompt from benchmark/prompts/system_prompt.txt;
  * greedy decoding (temperature 0);
  * the model returns {"answer": "...", "evidence": [[start, end], ...]} JSON,
    parsed with the same fallback logic as benchmark/utils.parse_model_response.
"""

import json
import os
import re
import time
from typing import Any, Dict, List, Optional, Tuple

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # noqa: E402  (must come before torch)
import torch  # noqa: E402

import cv2  # noqa: E402
import gradio as gr  # noqa: E402
from PIL import Image, ImageDraw  # noqa: E402
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration  # noqa: E402

MODEL_ID = "BrightGuo/ProcObject-Qwen3-VL-4B"
NUM_FRAMES = 48                    # the fine-tuned model's frame budget
RESIZE = (512, 512)                # frame size used by the benchmark protocol
MAX_NEW_TOKENS = 1024              # same as the harness's --max_tokens

SYSTEM_PROMPT = """You are a video question-answering assistant specializing in procedural, object-centric videos.

You will be given a series of uniformly sampled frames from a video clip. Each frame is labeled with its timestamp in seconds relative to the start of the clip (e.g., [Timestamp: 0.0s] is the first moment, [Timestamp: 45.2s] is 45.2 seconds into the clip). Use these timestamps to localize events temporally.

Your task:
1. Carefully analyze all provided frames and their timestamps.
2. Answer the question in detail, describing what you observe across the video frames.
3. Identify the relevant time intervals as [start_second, end_second] pairs using the frame timestamps. The evidence intervals should correspond to the timestamps shown in the frames. For example, if the relevant action occurs between frames labeled [Timestamp: 10.0s] and [Timestamp: 25.0s], report evidence as [10, 25].

Return your response ONLY as a valid JSON object with exactly these two fields:
- "answer": a detailed string describing what you observed to answer the question
- "evidence": a list of [start_second, end_second] pairs (integers) indicating the relevant temporal segments based on the frame timestamps

Example output: (json format)
{"answer": "The apple is first visible on the cutting board and is then sliced into pieces.", "evidence": [[10, 25], [40, 55]]}

Do not include any text outside the JSON object. Do not wrap the JSON in markdown code fences."""

processor = AutoProcessor.from_pretrained(MODEL_ID, min_pixels=256 * 28 * 28, max_pixels=512 * 512)
model = Qwen3VLForConditionalGeneration.from_pretrained(
    MODEL_ID,
    dtype=torch.bfloat16,
    attn_implementation="sdpa",
).to("cuda").eval()


# ─────────────────────────────────────────────────────────────────────────────
# Frame sampling β€” identical to benchmark/utils.extract_frames (cv2-based,
# uniform over the whole clip, resized to 512x512, timestamps 0-based).
# ─────────────────────────────────────────────────────────────────────────────

def extract_frames(video_path: str, num_frames: int = NUM_FRAMES,
                   resize: Optional[Tuple[int, int]] = RESIZE) -> Tuple[List[Dict[str, Any]], float]:
    """Uniformly sample num_frames frames from the video, with 0-based timestamps.

    Returns (frames, clip_duration); each frame is {"image": PIL, "timestamp": float}.
    """
    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        raise RuntimeError(f"Cannot open video: {video_path}")

    fps = cap.get(cv2.CAP_PROP_FPS)
    n_total = cap.get(cv2.CAP_PROP_FRAME_COUNT)
    clip_duration = (n_total / fps) if (fps and fps > 0 and n_total and n_total > 0) else 0.0
    if clip_duration <= 0:  # some muxers report no frame count β€” seek to the end instead
        cap.set(cv2.CAP_PROP_POS_AVI_RATIO, 1)
        clip_duration = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
        cap.set(cv2.CAP_PROP_POS_MSEC, 0)
    if clip_duration <= 0 or fps <= 0:
        cap.release()
        return [], 0.0

    if num_frames == 1:
        timestamps = [clip_duration / 2]
    else:
        timestamps = [clip_duration * i / (num_frames - 1) for i in range(num_frames)]

    frames: List[Dict[str, Any]] = []
    for ts in timestamps:
        cap.set(cv2.CAP_PROP_POS_MSEC, ts * 1000)
        ret, frame = cap.read()
        back = 1
        while not ret and back <= 5:  # cut clips can end before the last timestamp
            cap.set(cv2.CAP_PROP_POS_MSEC, max(0.0, ts - back / fps) * 1000)
            ret, frame = cap.read()
            back += 1
        if not ret:
            continue
        img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
        if resize:
            img = img.resize(resize, Image.LANCZOS)
        frames.append({"image": img, "timestamp": round(ts, 2)})

    cap.release()
    return frames, clip_duration


# ─────────────────────────────────────────────────────────────────────────────
# Response parsing β€” identical to benchmark/utils.parse_model_response.
# ─────────────────────────────────────────────────────────────────────────────

def parse_model_response(raw_text: str) -> Dict[str, Any]:
    text = raw_text.strip()
    text = re.sub(r"^```(?:json)?\s*", "", text)
    text = re.sub(r"\s*```$", "", text)
    text = text.strip()

    try:
        parsed = json.loads(text)
        return {"answer": str(parsed.get("answer", "")), "evidence": parsed.get("evidence", [])}
    except json.JSONDecodeError:
        pass

    match = re.search(r'\{[^{}]*"answer"[^{}]*\}', text, re.DOTALL)
    if match:
        try:
            parsed = json.loads(match.group())
            return {"answer": str(parsed.get("answer", "")), "evidence": parsed.get("evidence", [])}
        except json.JSONDecodeError:
            pass

    return {"answer": text, "evidence": []}


# ─────────────────────────────────────────────────────────────────────────────
# Evidence timeline visualization
# ─────────────────────────────────────────────────────────────────────────────

def _clean_intervals(evidence: List, duration: float) -> List[Tuple[float, float]]:
    spans = []
    for item in evidence or []:
        try:
            s, e = float(item[0]), float(item[1])
        except (TypeError, ValueError, IndexError):
            continue
        s, e = max(0.0, min(s, duration)), max(0.0, min(e, duration))
        if e > s:
            spans.append((s, e))
    return spans


def render_timeline(duration: float, spans: List[Tuple[float, float]]) -> Image.Image:
    """A thin timeline bar with the grounded evidence spans highlighted."""
    W, H = 780, 92
    pad_l, pad_r, pad_t, bar_h = 14, 14, 38, 28
    img = Image.new("RGB", (W, H), (249, 249, 247))
    d = ImageDraw.Draw(img)

    track = (222, 222, 218)
    d.rectangle([pad_l, pad_t, W - pad_r, pad_t + bar_h], fill=track)
    if duration > 0:
        for (s, e) in spans:
            x0 = pad_l + (s / duration) * (W - pad_l - pad_r)
            x1 = pad_l + (e / duration) * (W - pad_l - pad_r)
            d.rectangle([x0, pad_t, max(x0 + 2.0, x1), pad_t + bar_h], fill=(224, 78, 46))

    # second ticks + labels
    if duration > 0:
        step = 5 if duration <= 60 else (10 if duration <= 180 else 30)
        for t in range(0, int(duration) + 1, step):
            x = pad_l + (t / duration) * (W - pad_l - pad_r)
            d.line([x, pad_t + bar_h + 3, x, pad_t + bar_h + 9], fill=(160, 160, 156))
            label = f"{t}s"
            tw = d.textlength(label)
            d.text((min(max(x - tw / 2, 2), W - tw - 2), pad_t + bar_h + 12), label, fill=(110, 110, 106))

    span_txt = ", ".join(f"[{s:.0f}–{e:.0f}s]" for s, e in spans) if spans else "no evidence returned"
    d.text((pad_l, 12), span_txt, fill=(60, 60, 58))
    return img


def format_result(answer: str, spans: List[Tuple[float, float]]) -> str:
    evidence = ", ".join(f"[{s:.0f}s – {e:.0f}s]" for s, e in spans)
    return f"{answer}\n\n\n⏱ Grounded evidence: {evidence if evidence else 'none returned'}"


# ─────────────────────────────────────────────────────────────────────────────
# Inference
# ─────────────────────────────────────────────────────────────────────────────

def _estimate_duration(video, question, num_frames=NUM_FRAMES, max_new_tokens=MAX_NEW_TOKENS,
                       *args, **kwargs) -> int:
    """Measured ~5s of GPU time for 48 frames + 1024 tokens on ZeroGPU; scale by
    both budgets and add margin for cold weight streaming."""
    try:
        n = int(num_frames)
    except (TypeError, ValueError):
        n = NUM_FRAMES
    try:
        m = int(max_new_tokens)
    except (TypeError, ValueError):
        m = MAX_NEW_TOKENS
    return min(240, 20 + int(n * 0.35) + int(m * 0.004))


@spaces.GPU(duration=_estimate_duration)
def answer_question(video, question, num_frames=NUM_FRAMES, max_new_tokens=MAX_NEW_TOKENS):
    """Answer an object-centric question about a video clip and ground the evidence.

    Args:
        video: an instructional video clip (file path).
        question: a question about an object in the clip.
        num_frames: number of frames sampled uniformly from the clip (48 = the
            model's training budget).
        max_new_tokens: maximum number of tokens to generate.

    Returns:
        (formatted answer + evidence, evidence timeline image, raw model JSON)
    """
    t0 = time.perf_counter()
    if not video:
        raise gr.Error("Please upload a video clip first.")
    question = (question or "").strip()
    if not question:
        raise gr.Error("Please enter a question about the clip.")
    try:
        num_frames = max(4, min(int(num_frames), 64))
    except (TypeError, ValueError):
        num_frames = NUM_FRAMES
    try:
        max_new_tokens = max(128, min(int(max_new_tokens), 2048))
    except (TypeError, ValueError):
        max_new_tokens = MAX_NEW_TOKENS

    frames, clip_duration = extract_frames(video, num_frames=num_frames)
    if not frames:
        raise gr.Error("Could not read any frames from this video. Try an .mp4 file.")

    # Message layout identical to the benchmark's VLLMRunner: one
    # "[Timestamp: <t>s]" text part before every frame image, then the question.
    content: List[Dict[str, Any]] = []
    for f in frames:
        content.append({"type": "text", "text": f"[Timestamp: {f['timestamp']}s]"})
        content.append({"type": "image", "image": f["image"]})
    content.append({"type": "text", "text": f"\nQuestion: {question}"})
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": content},
    ]

    chat_text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    images = [f["image"] for f in frames]
    inputs = processor(
        text=[chat_text], images=images if images else None, padding=True, return_tensors="pt"
    ).to("cuda")

    with torch.inference_mode():
        out_ids = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            do_sample=False,
            temperature=None,
            top_p=None,
            top_k=None,
        )
    gen_ids = out_ids[:, inputs["input_ids"].shape[1]:]
    raw = processor.batch_decode(gen_ids, skip_special_tokens=True)[0].strip()

    parsed = parse_model_response(raw)
    spans = _clean_intervals(parsed["evidence"], clip_duration)
    timeline = render_timeline(clip_duration, spans)
    elapsed = time.perf_counter() - t0

    payload = {"answer": parsed["answer"], "evidence": parsed["evidence"],
               "clip_duration_s": round(clip_duration, 2), "frames": len(frames),
               "inference_s": round(elapsed, 1)}
    return format_result(parsed["answer"], spans), timeline, json.dumps(payload, ensure_ascii=False, indent=2)


# ─────────────────────────────────────────────────────────────────────────────
# UI
# ─────────────────────────────────────────────────────────────────────────────

EXAMPLES = [
    ["examples/3e9350958273.mp4",
     "How does the state of the egg change from the start of the whisking action to the end of this sequence?"],
    ["examples/ba6ce3240abf.mp4",
     "What mistake occurs on the pan with cheese, and why is it a mistake?"],
    ["examples/b039e8c9fafd.mp4",
     "How does the state of the bowl contents change from being stirred initially to the end of this sequence?"],
    ["examples/37aabc657f3b.mp4",
     "How does the state of the coffee grounds change from the start of the blooming phase to the end of this sequence?"],
    ["examples/670e2b9f02bf.mp4",
     "Before cutting the tomato into two pieces begins, is the tomato ready, and what visible cues show this?"],
]

CSS = """
#col-container { max-width: 1150px; 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(
            """
            # 🍳 ProcObject-Qwen3-VL-4B β€” Object-Centric Video QA & Temporal Grounding

            Ask a question about an **object** in an instructional video clip. The model answers and localizes the
            supporting moments as `[start, end]` second intervals β€” the object-centric reasoning of
            [ProcObject-10K](https://huggingface.co/datasets/BrightGuo/ProcObject-10K)
            ([paper](https://arxiv.org/abs/2512.03479), NeurIPS 2026 Datasets & Benchmarks).
            """
        )

        with gr.Row():
            with gr.Column(scale=1):
                video = gr.Video(label="Video clip")
                question = gr.Textbox(
                    label="Question",
                    placeholder="e.g. How does the state of the egg change from the start of the whisking to the end?",
                    lines=3,
                )
                run = gr.Button("Answer", variant="primary")
                with gr.Accordion("Advanced settings", open=False):
                    num_frames = gr.Slider(minimum=8, maximum=64, value=NUM_FRAMES, step=2,
                                           label="Frames sampled", info="48 is the fine-tuned model's training budget")
                    max_new_tokens = gr.Slider(minimum=128, maximum=2048, value=MAX_NEW_TOKENS, step=64,
                                               label="Max new tokens")

            with gr.Column(scale=1):
                answer_out = gr.Textbox(label="Answer + grounded evidence", lines=12, buttons=["copy"])
                timeline_out = gr.Image(label="Evidence timeline", height=140, buttons=[])
                json_out = gr.Textbox(label="Model output (JSON)", lines=6, buttons=["copy"])

        gr.Markdown(
            """
            #### Tips
            - Ask about an **object** (the egg, the pan, the coffee grounds…), not only about the person: the model is
            trained for object-centric reasoning β€” preconditions, state evolution, counterfactuals, mistakes, readiness.
            - Evidence intervals are in seconds from the start of the clip, matching the `[Timestamp: …s]` frame labels.
            - The bundled example clips are from [CaptainCook4D](https://captaincook4d.github.io/captain-cook/), one of
            the four source datasets of ProcObject-10K, cut to the exact benchmark clip windows.
            """
        )

        gr.Examples(
            examples=EXAMPLES,
            inputs=[video, question],
            outputs=[answer_out, timeline_out, json_out],
            fn=answer_question,
            cache_examples=True,
            cache_mode="lazy",
        )

    run.click(
        answer_question,
        inputs=[video, question, num_frames, max_new_tokens],
        outputs=[answer_out, timeline_out, json_out],
        api_name="answer",
    )
    question.submit(
        answer_question,
        inputs=[video, question, num_frames, max_new_tokens],
        outputs=[answer_out, timeline_out, json_out],
        api_name="answer_submit",
    )

demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)