Spaces:
Running on Zero
Running on Zero
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)
|