File size: 16,590 Bytes
415454a
 
 
 
 
 
 
 
 
 
 
 
 
 
31eae47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415454a
 
 
 
 
 
 
 
 
 
31eae47
415454a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d80229c
2ad2f01
 
415454a
 
 
2ad2f01
 
415454a
 
2ad2f01
415454a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31eae47
415454a
 
 
 
 
 
aff32c8
415454a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aff32c8
415454a
 
 
 
 
 
 
 
 
 
 
 
2ad2f01
 
 
 
 
 
 
 
415454a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aff32c8
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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
"""PadCaptioner: omni-modal dense video captioning with parallelized autoregressive decoding.

Paper: "Parallelized Autoregressive Decoding for Omni-Modal Dense Video Captioning" (ECCV 2026).
Code:   https://github.com/showlab/PadCaptioner
Model:  wenzhengzeng/PadCaptioner-3B

The app mirrors the authors' reference test loop (`qwenvl/train/train_qwen_modified.py`,
`scripts_run/test.sh`) exactly: the same preprocessing (LazySupervisedDataset with
base_interval=0.5s, 64..256 frames, per-frame pixel budgets), the same inference options
(grounding threshold 0.7, snap window 1, coverage mask, branch interval attention, dedup
IoU 0.75), and the same post-processing (interval insertion into the <G> placeholders,
NMS over records).
"""

import os
import subprocess
import sys

# Vendored transformers 4.51.3 (the version PadCaptioner was trained/evaluated with).
# PyPI's transformers 4.51.3 metadata pins huggingface-hub<1.0, which conflicts with
# gradio 6.x's huggingface-hub>=1.16 at pip-resolution time, so the vendored wheel's
# metadata relaxes the cap to >=0.30.0 — the code itself is untouched. Installed with
# --no-deps because its real deps are listed in requirements.txt.
_HERE = os.path.dirname(os.path.abspath(__file__))
_VENDORED_WHEEL = os.path.join(_HERE, "vendor", "transformers-4.51.3-py3-none-any.whl")
try:
    import transformers  # noqa: F401
    from transformers import __version__ as _tf_version  # noqa: F401
    if _tf_version != "4.51.3":
        raise ImportError("wrong transformers version")
except Exception:
    subprocess.run(
        [sys.executable, "-m", "pip", "install", "--no-deps", "--quiet", _VENDORED_WHEEL],
        check=True,
    )

import spaces  # MUST come before torch / transformers CUDA init

import json
import re
import tempfile
import time
from types import SimpleNamespace

import gradio as gr

# The bundled qwenvl package (from github.com/showlab/PadCaptioner) sits next to app.py.
sys.path.insert(0, _HERE)

import torch  # noqa: E402  (after spaces)
import transformers  # noqa: E402
from transformers import AutoTokenizer, WhisperFeatureExtractor  # noqa: E402

from qwenvl.data.dataset import LazySupervisedDataset  # noqa: E402
from qwenvl.data.image_processing_qwen2_vl_fast import Qwen2VLImageProcessorFast  # noqa: E402
from qwenvl.model.modeling_qwen2_5_vl import video_SALMONN2_plus  # noqa: E402

MODEL_ID = "wenzhengzeng/PadCaptioner-3B"

# ---- Inference settings: the released-model evaluation settings from scripts_run/test.sh ----
MAX_PIXELS = 61250
MIN_PIXELS = 784
MIN_FRAMES = 64
MAX_FRAMES = 256
BASE_INTERVAL = 0.5
GROUNDING_THRESHOLD = 0.7
GROUNDING_MERGE_GAP = 0
GROUNDING_SNAP_WINDOW = 1
MAX_PLANNING_EVENTS = 24
DVC_DEDUP_IOU = 0.75
MAX_NEW_TOKENS = 1026  # task_body_cap default 1024 + 2 (<G> and EOS)

# The dvc prompt used by the authors' test annotations (DATASET.md / test_dvc2000.json).
DVC_PROMPT = (
    "<video>\nCould you please detail the events that took place during different time "
    "segments in the video? List the events in the format: From second{} to second{}, "
    "event1. \n From second{} to second{}, event2. \n ..."
)

# EVIDENCE_GEN_KEYS: non-tensor grounding inputs the model needs alongside the tensors
# (from the reference test loop).
EVIDENCE_GEN_KEYS = (
    "video_token_indices_list_src",
    "audio_token_indices_list_src",
    "second_per_grid_ts",
    "last_text_token_indices",
    "video_token_indices_per_timestep",
    "audio_token_indices_per_timestep",
)


# ---------- Post-processing helpers (verbatim from qwenvl/train/train_qwen_modified.py) ----------

def format_intervals(intervals):
    return [f"{subinterval[0]} - {subinterval[1]} " for subinterval in intervals]


def timestep_intervals_to_seconds(intervals, timestep_duration, round_digits=2):
    """Convert timestep-index intervals [[a, b], ...] to seconds."""
    if timestep_duration is None or timestep_duration <= 0:
        return intervals
    out = []
    for a, b in intervals:
        start_sec = a * timestep_duration
        end_sec = (b + 1) * timestep_duration  # half-open interval: end time of step b
        out.append([round(start_sec, round_digits), round(end_sec, round_digits)])
    return out


def is_degenerate(body):
    """True if the body is too short, mostly non-ASCII, or leaks the template / modality tags."""
    b = body.strip()
    if len(b.split()) < 6:
        return True
    non_ascii = sum(1 for ch in b if ord(ch) > 127) / max(1, len(b))
    return (non_ascii > 0.3 or "Analyze the" in b or "</audio>" in b or "<audio>" in b
            or "</video>" in b or "<video>" in b)


def trim_to_sentence(body, min_keep=0.5):
    """Drop the fragment after the last sentence end if at least min_keep of the text remains."""
    m = list(re.finditer(r'[.!?]["”]?(?=\s|$)', body))
    if not m:
        return body
    cut = m[-1].end()
    return body[:cut] if cut >= min_keep * len(body) else body


def dedup_dvc_records(text, iou_thresh, drop_degenerate=False):
    """NMS over deinterleaved dvc records ("start - end caption" per line)."""
    head = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*(.*)$")
    records = []  # [start, end, caption, raw_lines]
    for line in text.split("\n"):
        m = head.match(line)
        if m:
            records.append([float(m.group(1)), float(m.group(2)), m.group(3), line])
        elif records:
            records[-1][2] += " " + line.strip()
            records[-1][3] += "\n" + line
        else:
            return text  # leading non-record text: not a deinterleaved dvc output
    if len(records) < 2:
        return text
    kept = []
    for s, e, cap, raw in records:
        if drop_degenerate and is_degenerate(cap):
            continue
        ncap = " ".join(cap.split()).lower()
        redundant = False
        for ks, ke, kcap, _ in kept:
            inter = max(0.0, min(e, ke) - max(s, ks))
            union = max(e, ke) - min(s, ks)
            iou = inter / union if union > 0 else 1.0
            if iou >= iou_thresh or (ncap and ncap == kcap and iou > 0.1):
                redundant = True
                break
        if not redundant:
            kept.append((s, e, ncap, raw))
    return "\n".join(r[3] for r in kept)


# ---------- Model / processor load (module scope, eager .cuda()) ----------

print("[app] loading tokenizer / processors ...", flush=True)
tokenizer = AutoTokenizer.from_pretrained(
    MODEL_ID,
    model_max_length=131072,
    padding_side="right",
    use_fast=False,
)
image_processor = Qwen2VLImageProcessorFast.from_pretrained(MODEL_ID)
audio_processor = WhisperFeatureExtractor(
    feature_size=128,
    sampling_rate=16000,
    hop_length=160,
    chunk_length=30,
)

print("[app] loading model ...", flush=True)
model = video_SALMONN2_plus.from_pretrained(
    MODEL_ID,
    attn_implementation="sdpa",
    torch_dtype=torch.bfloat16,
)
# Inference-time config overrides (the reference test loop's settings for the released model).
model.config.grounding_threshold = GROUNDING_THRESHOLD
model.config.grounding_merge_gap = GROUNDING_MERGE_GAP
model.config.grounding_snap_window = GROUNDING_SNAP_WINDOW
model.config.max_planning_events = MAX_PLANNING_EVENTS
model.config.audio_mean_weight = 1.0  # dvc: audio weighted into the aggregated <G> feature
model.eval()
model.cuda()
print("[app] model ready", flush=True)


# ---------- Preprocessing: one-element annotation -> LazySupervisedDataset item ----------

def build_gen_kwargs(video_path, use_audio=True):
    """Run the authors' preprocessing for a single video and assemble generate_parallel kwargs."""
    annotation = {
        "id": "demo",
        "video": os.path.abspath(video_path),
        "task": "dvc",
        "use_audio": bool(use_audio),
        "video_id": "dvc_demo",
        "conversations": [
            {"from": "human", "value": DVC_PROMPT},
            {"from": "gpt", "value": "placeholder"},  # truncated away in run_test mode
        ],
    }
    with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
        json.dump([annotation], f)
        ann_path = f.name

    data_args = SimpleNamespace(
        dataset_use=ann_path,
        model_type="qwen2.5vl",
        image_processor=image_processor,
        audio_processor=audio_processor,
        max_pixels=MAX_PIXELS,
        min_pixels=MIN_PIXELS,
        video_max_frame_pixels=MAX_PIXELS,
        video_min_frame_pixels=MIN_PIXELS,
        video_min_frames=MIN_FRAMES,
        video_max_frames=MAX_FRAMES,
        base_interval=BASE_INTERVAL,
        run_test=True,
        aggregation_jitter_ratio=0.0,
        do_sample=False,
        num_sample=1,
        deinterleave_parallel_output=True,
        feature_size=128,
        chunk_length=30,
        hop_length=160,
        sampling_rate=16000,
    )
    model_args = SimpleNamespace(use_evidence=True)

    dataset = LazySupervisedDataset(
        tokenizer=tokenizer, data_args=data_args, model_args=model_args
    )
    inputs = dataset[0]  # identity collate: batch_size=1 -> the raw sample dict
    os.unlink(ann_path)

    # Non-tensor keys the reference loop keeps aside (metadata + grounding inputs).
    res = {
        "video": inputs.pop("video", None),
        "use_audio": inputs.pop("use_audio", False),
        "task": inputs.pop("task", None),
    }
    for k in EVIDENCE_GEN_KEYS:
        res[k] = inputs.get(k, None)
    res["timestep_duration"] = inputs.get("timestep_duration", None)

    gen_kwargs = {
        k: v.to("cuda") for k, v in inputs.items() if isinstance(v, torch.Tensor)
    }
    for k in EVIDENCE_GEN_KEYS:
        gen_kwargs[k] = res[k]
    gen_kwargs["timestep_duration"] = res["timestep_duration"]
    return gen_kwargs


# ---------- Inference ----------

@spaces.GPU(duration=30)  # measured: 23.2-23.7s on the bundled examples + margin
def caption_video(video: str, use_audio: bool = True,
                  progress=gr.Progress(track_tqdm=True)) -> str:
    """Produce temporally grounded event captions for a video (with its audio track).

    Args:
        video: uploaded video file. Its audio track is used when present.
        use_audio: feed the audio track to the model along with the video frames.

    Returns:
        One "start - end caption" record per detected event, in temporal order.
    """
    if video is None:
        raise gr.Error("Please upload a video first.")
    t0 = time.perf_counter()

    gen_kwargs = build_gen_kwargs(video, use_audio=use_audio)

    newline_token_id = tokenizer.encode("\n", add_special_tokens=False)[0]
    with torch.no_grad():
        outputs = model.generate_parallel(
            **gen_kwargs,
            max_new_tokens=MAX_NEW_TOKENS,
            do_sample=False,
            top_p=0.9,
            constrain_planning=True,   # dvc planning vocab constraint
            min_body_tokens=0,
            single_event_no_replan=False,
            record_separator_id=newline_token_id,
            deinterleave=True,
        )

    output_trimmed = outputs[0, len(gen_kwargs["input_ids"][0]):]
    output_text = tokenizer.decode(
        output_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
    )

    # Grounding readout: replace each <G> placeholder with its predicted interval.
    tgt = getattr(model, "tgt", None)
    if tgt is not None and len(tgt[0]) == len(output_trimmed):
        evi_token_id = getattr(model.config, "evi_token_id", None)
        match_inds = torch.where(output_trimmed == evi_token_id)[0].tolist()
        if len(match_inds) > 0:
            timestep_duration = gen_kwargs["timestep_duration"]
            evi_strs = []
            for i in match_inds:
                intervals_sec = timestep_intervals_to_seconds(
                    tgt[0][i], timestep_duration)
                evi_strs.append(", ".join(s.strip() for s in format_intervals(intervals_sec)))

            parts = output_text.split("<G>")
            if len(parts) == len(evi_strs) + 1:
                pieces = [parts[0]]
                for i, (iv, rest) in enumerate(zip(evi_strs, parts[1:])):
                    pieces.append(iv)
                    if rest == "":
                        if i < len(evi_strs) - 1:
                            pieces.append("; ")
                    elif rest[0] not in " ;,.<":
                        pieces.append(" ")
                    pieces.append(rest)
                output_text = "".join(pieces)

        # NMS over the deinterleaved records (reference: dvc_dedup_iou=0.75, drop degenerate).
        output_text = dedup_dvc_records(
            output_text, DVC_DEDUP_IOU, drop_degenerate=True)

        # Length cap hit: trim to the last sentence end.
        if len(output_trimmed) >= MAX_NEW_TOKENS - 1:
            m_iv = re.match(r"^[\d\.\s\-,]*", output_text)
            head, body_ = output_text[:m_iv.end()], output_text[m_iv.end():]
            output_text = head + trim_to_sentence(body_)

    if not output_text.strip():
        output_text = "(the model produced no captions for this video)"

    elapsed = time.perf_counter() - t0
    print(f"[app] inference took {elapsed:.1f}s", flush=True)
    return output_text.strip()


# ---------- UI ----------

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

EXAMPLES_DIR = os.path.join(_HERE, "examples")
EXAMPLE_VIDEOS = [
    os.path.join(EXAMPLES_DIR, "example_1.mp4"),
    os.path.join(EXAMPLES_DIR, "example_3.mp4"),
    os.path.join(EXAMPLES_DIR, "example_10.mp4"),
]

with gr.Blocks() as demo:  # theme/css moved to launch() in Gradio 6.0
    gr.Markdown(
        """
        # PadCaptioner: Omni-Modal Dense Video Captioning
        **Parallelized Autoregressive Decoding for Omni-Modal Dense Video Captioning** (ECCV 2026)

        Upload a video (with sound) and the model lists **temporally grounded event captions** —
        one `"start - end caption"` record per event, watched *and* heard. PadCaptioner first
        plans event boundaries with a grounding head (the `<G>` tokens), then decodes all event
        captions **in parallel** in a single pass.

        - Paper: [hf.co/papers/2607.02963](https://huggingface.co/papers/2607.02963)
        - Code: [github.com/showlab/PadCaptioner](https://github.com/showlab/PadCaptioner)
        - Model: [wenzhengzeng/PadCaptioner-3B](https://huggingface.co/wenzhengzeng/PadCaptioner-3B)
        """
    )
    with gr.Column(elem_id="col-container"):
        with gr.Row():
            with gr.Column():
                video_in = gr.Video(label="Video (with audio track)", sources=["upload"])
                use_audio = gr.Checkbox(
                    value=True,
                    label="Use the audio track",
                    info="Off = frames only (video captions without sound context).",
                )
                caption_btn = gr.Button("Caption events", variant="primary")
            with gr.Column():
                caption_out = gr.Textbox(
                    label="Grounded event captions",
                    lines=14,
                    buttons=["copy"],  # `show_copy_button` was removed in Gradio 6.0
                )

        gr.Examples(
            examples=[[v, True] for v in EXAMPLE_VIDEOS],
            inputs=[video_in, use_audio],
            fn=caption_video,
            outputs=caption_out,
            cache_examples=True,
            cache_mode="lazy",
            label="Examples — click one, then run it (first click is cached afterwards)",
        )

        # Primary interaction: the button must be bound or the app exposes no endpoint at all.
        caption_btn.click(
            caption_video,
            inputs=[video_in, use_audio],
            outputs=caption_out,
            api_name="caption_video",
        )

        gr.Markdown(
            """
            <details>
            <summary>About the settings</summary>
            The demo runs the authors' released-model evaluation configuration: 0.5 s event
            grid, 64–256 frames, grounding threshold 0.7 with boundary snapping, coverage
            masking, parallel branch interval attention, and record NMS at IoU 0.75.
            Generation is greedy, exactly as in <code>scripts_run/test.sh</code>.
            </details>

            Example videos are from the
            [video-SALMONN_2 test set](https://huggingface.co/datasets/tsinghua-ee/video-SALMONN_2_testset)
            (Apache-2.0).
            """
        )

if __name__ == "__main__":
    demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)