Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- .gitattributes +2 -0
- README.md +82 -7
- app.py +377 -0
- case-01.mp4 +3 -0
- eating_spaghetti.mp4 +3 -0
- requirements.txt +7 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
case-01.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
eating_spaghetti.mp4 filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,13 +1,88 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: VideoJev-4B
|
| 3 |
+
emoji: 🎬
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Temporal grounding + video QA in one forward pass
|
| 10 |
+
python_version: "3.12"
|
| 11 |
+
startup_duration_timeout: 1h
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# 🎬 VideoJev-4B — video decisions without generation
|
| 15 |
+
|
| 16 |
+
Interactive demo for **VideoJev-4B** ([ZhuYuhan/VideoJev-4B](https://huggingface.co/ZhuYuhan/VideoJev-4B), Apache-2.0),
|
| 17 |
+
the video *System One* model from
|
| 18 |
+
[**"VideoJev: System One Model for Video Understanding and Interaction"**](https://arxiv.org/abs/2610.18615).
|
| 19 |
+
|
| 20 |
+
Instead of decoding text token by token, VideoJev **scores candidate time intervals and answer options
|
| 21 |
+
directly against the video**: temporal grounding and multiple-choice video QA come out of a single forward
|
| 22 |
+
pass, orders of magnitude faster than generative answering on the same tasks. Ask several questions about
|
| 23 |
+
the same video and they are answered together, sharing one video encoding.
|
| 24 |
+
|
| 25 |
+
## What you can do here
|
| 26 |
+
|
| 27 |
+
- **Temporal grounding** — "when does *X* happen?" → an event span `[start, end]` in seconds, with the
|
| 28 |
+
per-timestamp sigmoid probabilities it was cut from.
|
| 29 |
+
- **Multiple-choice video QA** — a question + options → the selected answer with softmax probabilities.
|
| 30 |
+
|
| 31 |
+
Both tasks run in one shared forward pass over your video; mix them freely in the question table.
|
| 32 |
+
|
| 33 |
+
## Example videos
|
| 34 |
+
|
| 35 |
+
The two bundled example videos are the authors' own demo inputs:
|
| 36 |
+
|
| 37 |
+
- `eating_spaghetti.mp4` — the checkpoint's quick-start example
|
| 38 |
+
([ZhuYuhan/VideoJev-Demo](https://huggingface.co/datasets/ZhuYuhan/VideoJev-Demo), itself mirrored from
|
| 39 |
+
[nielsr/video-demo](https://huggingface.co/datasets/nielsr/video-demo)).
|
| 40 |
+
- `case-01.mp4` — a Charades clip featured on the authors'
|
| 41 |
+
[project page](https://mcg-nju.github.io/VideoJev).
|
| 42 |
+
|
| 43 |
+
## Running locally
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
from transformers import AutoModel, AutoProcessor
|
| 47 |
+
import torch, json
|
| 48 |
+
|
| 49 |
+
repo = "ZhuYuhan/VideoJev-4B"
|
| 50 |
+
processor = AutoProcessor.from_pretrained(repo, trust_remote_code=True)
|
| 51 |
+
model = AutoModel.from_pretrained(repo, trust_remote_code=True, dtype=torch.bfloat16,
|
| 52 |
+
attn_implementation="sdpa").to("cuda")
|
| 53 |
+
model.prepare_for_inference()
|
| 54 |
+
|
| 55 |
+
questions = [
|
| 56 |
+
{"id": "g1", "task": "grounding", "query": "The woman in green eats pasta."},
|
| 57 |
+
{"id": "q1", "task": "choice", "query": "What is the woman in green eating?",
|
| 58 |
+
"options": ["A. Pasta.", "B. Rice.", "C. Soup."]},
|
| 59 |
+
]
|
| 60 |
+
print(json.dumps(model.predict_video(processor, "video.mp4", questions,
|
| 61 |
+
fps=2.0, max_frames=2048, context=8192), indent=2))
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
This Space runs the checkpoint's own `trust_remote_code` modules (`modeling_videojev.py`,
|
| 65 |
+
`processing_videojev.py`) unchanged, with `attn_implementation="sdpa"` — on GPUs without
|
| 66 |
+
flash-linear-attention kernels, transformers' pure-torch chunked `gated_delta_rule` fallback
|
| 67 |
+
executes the hybrid linear-attention layers.
|
| 68 |
+
|
| 69 |
+
## Links
|
| 70 |
+
|
| 71 |
+
- Paper: <https://arxiv.org/abs/2610.18615>
|
| 72 |
+
- Code (Apache-2.0): <https://github.com/MCG-NJU/VideoJev>
|
| 73 |
+
- Model: <https://huggingface.co/ZhuYuhan/VideoJev-4B> · larger MoE: <https://huggingface.co/ZhuYuhan/VideoJev-35B-A3B>
|
| 74 |
+
- Homepage: <https://mcg-nju.github.io/VideoJev>
|
| 75 |
+
|
| 76 |
+
## Citation
|
| 77 |
+
|
| 78 |
+
```bibtex
|
| 79 |
+
@misc{zhu2026videojevmodelvideounderstanding,
|
| 80 |
+
title={VideoJev: System One Model for Video Understanding and Interaction},
|
| 81 |
+
author={Yuhan Zhu and Xiangyu Zeng and Qingyi Si and Chenxu Yang and Yifei Li and Luozheng Qin and Junhao Zhou and Dingyu Yao and Jun Zhang and Chuanyu Qin and Zhiqiu Zhang and Yuandong Yang and Yuqi Xu and Tianyu Zhang and Hongcheng Guo and Jiaqi Wang and Nan Duan and Limin Wang},
|
| 82 |
+
year={2026},
|
| 83 |
+
eprint={2610.18615},
|
| 84 |
+
archivePrefix={arXiv},
|
| 85 |
+
primaryClass={cs.CV},
|
| 86 |
+
url={https://arxiv.org/abs/2610.18615},
|
| 87 |
+
}
|
| 88 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VideoJev-4B — temporal grounding + multiple-choice video QA in one forward pass.
|
| 2 |
+
|
| 3 |
+
Demo for https://huggingface.co/ZhuYuhan/VideoJev-4B (Apache-2.0), the video System One
|
| 4 |
+
model from "VideoJev: System One Model for Video Understanding and Interaction"
|
| 5 |
+
(arXiv:2610.18615). VideoJev scores time intervals and answer options directly against
|
| 6 |
+
the video — no autoregressive decoding — and answers a whole group of questions in a
|
| 7 |
+
single forward pass.
|
| 8 |
+
|
| 9 |
+
Inference follows the authors' reference path exactly (the `modeling_videojev.py` /
|
| 10 |
+
`processing_videojev.py` shipped inside the checkpoint, byte-identical to
|
| 11 |
+
MCG-NJU/VideoJev `videojev_hf/`): `prepare_video` → `encode_video` → `predict_features`,
|
| 12 |
+
which is precisely what `model.predict_video` does internally — split here only so the
|
| 13 |
+
demo can also show the per-timestamp probability timeline. The model runs with
|
| 14 |
+
`attn_implementation="sdpa"`: transformers 5.x falls back to a pure-torch chunked
|
| 15 |
+
`gated_delta_rule` for the hybrid linear-attention layers when flash-linear-attention
|
| 16 |
+
kernels are absent, which keeps the Space dependency-light.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 22 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 23 |
+
os.environ.setdefault("GRADIO_SSR_MODE", "false")
|
| 24 |
+
# Match the authors' env (scripts/env.sh) so native recurrence numerics stay put.
|
| 25 |
+
os.environ.setdefault("FLA_INTRACARD_CP", "0")
|
| 26 |
+
|
| 27 |
+
import spaces # noqa: E402 -- MUST come before torch / transformers / any CUDA-touching import
|
| 28 |
+
|
| 29 |
+
import html # noqa: E402
|
| 30 |
+
import string # noqa: E402
|
| 31 |
+
import sys # noqa: E402
|
| 32 |
+
import time # noqa: E402
|
| 33 |
+
|
| 34 |
+
import gradio as gr # noqa: E402
|
| 35 |
+
import torch # noqa: E402
|
| 36 |
+
from transformers import AutoModel, AutoProcessor # noqa: E402
|
| 37 |
+
|
| 38 |
+
MODEL_ID = "ZhuYuhan/VideoJev-4B"
|
| 39 |
+
LETTERS = string.ascii_uppercase
|
| 40 |
+
|
| 41 |
+
print("[videojev] loading processor + model (bf16, sdpa)…", flush=True)
|
| 42 |
+
processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
|
| 43 |
+
model = AutoModel.from_pretrained(
|
| 44 |
+
MODEL_ID,
|
| 45 |
+
trust_remote_code=True,
|
| 46 |
+
dtype=torch.bfloat16,
|
| 47 |
+
attn_implementation="sdpa",
|
| 48 |
+
).to("cuda")
|
| 49 |
+
model.prepare_for_inference()
|
| 50 |
+
|
| 51 |
+
# Pull the checkpoint's own preprocessing module — the exact code revision the weights
|
| 52 |
+
# ship with — from the sys.modules entry trust_remote_code already created for it.
|
| 53 |
+
_processing = sys.modules[model.__class__.__module__]
|
| 54 |
+
prepare_video = _processing.prepare_video
|
| 55 |
+
print(f"[videojev] {MODEL_ID} ready — grounding threshold {model.config.decoder['threshold']}", flush=True)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Input helpers
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def _normalize_rows(rows) -> list:
|
| 63 |
+
"""Accept whatever gr.Dataframe hands over (pandas / numpy / nested list)."""
|
| 64 |
+
if rows is None:
|
| 65 |
+
return []
|
| 66 |
+
if hasattr(rows, "values"): # pandas DataFrame
|
| 67 |
+
rows = rows.astype(object).where(rows.notna(), "").values.tolist()
|
| 68 |
+
elif hasattr(rows, "tolist"): # numpy array
|
| 69 |
+
rows = rows.tolist()
|
| 70 |
+
return [[("" if c is None else c) for c in list(r) + [""] * (3 - len(list(r)))] for r in rows]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _parse_options(text) -> list:
|
| 74 |
+
"""Split the options cell on ';' or newlines (commas are kept inside options)."""
|
| 75 |
+
if text is None or not str(text).strip():
|
| 76 |
+
return []
|
| 77 |
+
return [o.strip() for o in str(text).replace(";", "\n").split("\n") if o.strip()]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _label_options(options: list) -> list:
|
| 81 |
+
"""Keep author-format options ('A. …') untouched; add letter labels otherwise."""
|
| 82 |
+
out = []
|
| 83 |
+
for i, option in enumerate(options):
|
| 84 |
+
if i >= len(LETTERS):
|
| 85 |
+
raise gr.Error(f"At most {len(LETTERS)} options are supported.")
|
| 86 |
+
letter = LETTERS[i]
|
| 87 |
+
out.append(option if option.startswith(f"{letter}.") else f"{letter}. {option}")
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _build_questions(rows) -> list:
|
| 92 |
+
"""Turn the questions table into the authors' question-dict format."""
|
| 93 |
+
questions = []
|
| 94 |
+
for i, row in enumerate(_normalize_rows(rows)):
|
| 95 |
+
task, query, options_cell = row[0], row[1], row[2]
|
| 96 |
+
task = str(task).strip().lower()
|
| 97 |
+
query = str(query or "").strip()
|
| 98 |
+
if not query and not task:
|
| 99 |
+
continue
|
| 100 |
+
if task not in ("grounding", "choice"):
|
| 101 |
+
raise gr.Error(
|
| 102 |
+
f"Row {i + 1}: task must be 'grounding' or 'choice' (got '{row[0]}')."
|
| 103 |
+
)
|
| 104 |
+
if not query:
|
| 105 |
+
raise gr.Error(f"Row {i + 1}: the query text is required.")
|
| 106 |
+
if "<|" in query or "<|" in str(options_cell or ""):
|
| 107 |
+
raise gr.Error("Special control tokens (<|…|>) are not allowed in questions or options.")
|
| 108 |
+
options = _label_options(_parse_options(options_cell))
|
| 109 |
+
if task == "choice" and len(options) < 2:
|
| 110 |
+
raise gr.Error(f"Row {i + 1}: choice questions need at least two options, separated by ';' or new lines.")
|
| 111 |
+
question = {"id": f"q{i + 1}", "task": task, "query": query}
|
| 112 |
+
if task == "choice":
|
| 113 |
+
question["options"] = options
|
| 114 |
+
questions.append(question)
|
| 115 |
+
if not questions:
|
| 116 |
+
raise gr.Error("Add at least one question (a query text is required).")
|
| 117 |
+
return questions
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Rendering
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
_BAR = (
|
| 125 |
+
'<div style="display:flex;align-items:center;gap:8px;margin:2px 0">'
|
| 126 |
+
'<span style="flex:0 0 46px;text-align:right;font-size:12px;opacity:.65">{t:.1f}s</span>'
|
| 127 |
+
'<div style="flex:1;height:22px;background:var(--border-color-primary);border-radius:3px;position:relative;overflow:hidden">'
|
| 128 |
+
'<div style="width:{fill:.1f}%;position:absolute;inset:0;'
|
| 129 |
+
'background:var(--color-accent);opacity:.25"></div>'
|
| 130 |
+
'<div style="position:absolute;left:{ls:.2f}%;width:{ws:.2f}%;top:3px;bottom:3px;'
|
| 131 |
+
'background:var(--color-accent);border-radius:2px"></div>'
|
| 132 |
+
"</div>"
|
| 133 |
+
'<span style="flex:0 0 44px;font-size:12px;opacity:.75">{p:.2f}</span></div>'
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _grounding_html(item: dict, record: dict) -> str:
|
| 138 |
+
start, end = item["interval"]
|
| 139 |
+
duration = max(record["sampled_duration"], 1e-6)
|
| 140 |
+
left = 100.0 * start / duration
|
| 141 |
+
width = 100.0 * max(end - start, 0.0) / duration
|
| 142 |
+
bars = "".join(
|
| 143 |
+
_BAR.format(t=t, fill=100.0 * p, ls=left, ws=width, p=p)
|
| 144 |
+
for t, p in zip(record["score_times"], item["probabilities"])
|
| 145 |
+
)
|
| 146 |
+
return (
|
| 147 |
+
'<div style="padding:10px 0;border-bottom:1px solid var(--border-color-primary)">'
|
| 148 |
+
'<div style="font-size:12.5px;opacity:.7;margin-bottom:4px"><code>grounding</code> · '
|
| 149 |
+
f"{html.escape(item['_query'])}</div>"
|
| 150 |
+
'<div style="font-size:20px;font-weight:600;margin:2px 0">'
|
| 151 |
+
f"⏱ {start:.1f}s – {end:.1f}s"
|
| 152 |
+
'<span style="font-weight:400;font-size:12.5px;opacity:.65;margin-left:8px">'
|
| 153 |
+
f"highlighted over the {duration:.1f}s video</span></div>"
|
| 154 |
+
f'<div style="margin-top:6px">{bars}</div></div>'
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _choice_html(item: dict) -> str:
|
| 159 |
+
winner = item["answer_index"]
|
| 160 |
+
rows = "".join(
|
| 161 |
+
'<div style="display:flex;align-items:center;gap:8px;margin:2px 0">'
|
| 162 |
+
f'<span style="flex:1;font-size:12.5px;opacity:{1.0 if i == winner else 0.5};'
|
| 163 |
+
f'font-weight:{600 if i == winner else 400}">{html.escape(option)}</span>'
|
| 164 |
+
'<div style="flex:0 0 170px;background:var(--border-color-primary);height:12px;border-radius:3px">'
|
| 165 |
+
f'<div style="width:{100.0 * p:.1f}%;height:12px;border-radius:3px;background:var(--color-accent);'
|
| 166 |
+
f'opacity:{1.0 if i == winner else 0.4}"></div></div>'
|
| 167 |
+
f'<span style="flex:0 0 50px;font-size:12px;text-align:right;'
|
| 168 |
+
f'opacity:{1.0 if i == winner else 0.5}">{p * 100:.1f}%</span></div>'
|
| 169 |
+
for i, (option, p) in enumerate(zip(item["_options"], item["probabilities"]))
|
| 170 |
+
)
|
| 171 |
+
return (
|
| 172 |
+
'<div style="padding:10px 0;border-bottom:1px solid var(--border-color-primary)">'
|
| 173 |
+
'<div style="font-size:12.5px;opacity:.7;margin-bottom:4px"><code>choice</code> · '
|
| 174 |
+
f"{html.escape(item['_query'])}</div>"
|
| 175 |
+
'<div style="font-size:20px;font-weight:600;margin:2px 0">'
|
| 176 |
+
f"✔ {html.escape(item['answer'])}"
|
| 177 |
+
'<span style="font-weight:400;font-size:12.5px;opacity:.65;margin-left:8px">'
|
| 178 |
+
f"softmax P = {item['probabilities'][winner]:.3f}</span></div>"
|
| 179 |
+
f'<div style="margin-top:6px">{rows}</div></div>'
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# GPU handler
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
def _estimate_duration(video, rows, *args, **kwargs):
|
| 188 |
+
"""Duration sizing: flat allowance (weight streaming + decode) + per-second cost."""
|
| 189 |
+
try:
|
| 190 |
+
import av
|
| 191 |
+
|
| 192 |
+
with av.open(str(video)) as container:
|
| 193 |
+
stream = container.streams.video[0]
|
| 194 |
+
if stream.duration is not None:
|
| 195 |
+
seconds = float(stream.duration * stream.time_base)
|
| 196 |
+
else:
|
| 197 |
+
seconds = container.duration / 1e6
|
| 198 |
+
except Exception:
|
| 199 |
+
seconds = 30.0
|
| 200 |
+
return min(240, 60 + int(seconds * 1.5))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@spaces.GPU(duration=_estimate_duration)
|
| 204 |
+
def analyze(
|
| 205 |
+
video,
|
| 206 |
+
rows,
|
| 207 |
+
fps=2.0,
|
| 208 |
+
max_frames=512,
|
| 209 |
+
context=32768,
|
| 210 |
+
threshold=0.25,
|
| 211 |
+
progress=gr.Progress(track_tqdm=True),
|
| 212 |
+
):
|
| 213 |
+
"""Answer a group of temporal-grounding and multiple-choice questions about one video in a single forward pass.
|
| 214 |
+
|
| 215 |
+
Args:
|
| 216 |
+
video: input video file (any container PyAV can decode).
|
| 217 |
+
rows: question table — one row per question with columns
|
| 218 |
+
[task ('grounding' | 'choice'), query, options]. Options apply to 'choice'
|
| 219 |
+
questions only and are separated by ';' or new lines; letters are added
|
| 220 |
+
automatically unless already present ("A. …").
|
| 221 |
+
fps: frame sampling rate (authors use 2 for QA, 4 for grounding).
|
| 222 |
+
max_frames: cap on sampled frames across the whole video.
|
| 223 |
+
context: token budget for video + queries; video resolution adapts to fit.
|
| 224 |
+
threshold: sigmoid threshold used to cut the grounding interval.
|
| 225 |
+
progress: Gradio progress bar.
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
(results_html, stats_markdown, raw_json): per-question rendering, run stats,
|
| 229 |
+
and the raw model output (interval / answer / probabilities per question).
|
| 230 |
+
"""
|
| 231 |
+
if not video:
|
| 232 |
+
raise gr.Error("Upload a video or pick one of the examples first.")
|
| 233 |
+
video = str(video)
|
| 234 |
+
questions = _build_questions(rows)
|
| 235 |
+
fps, max_frames, context, threshold = float(fps), int(max_frames), int(context), float(threshold)
|
| 236 |
+
|
| 237 |
+
t0 = time.perf_counter()
|
| 238 |
+
try:
|
| 239 |
+
with torch.inference_mode():
|
| 240 |
+
# The authors' exact predict_video pipeline, split open so the per-timestamp
|
| 241 |
+
# probability timeline can be shown in the UI.
|
| 242 |
+
pixels, record = prepare_video(processor, video, fps=fps, max_frames=max_frames, context=context)
|
| 243 |
+
features = model.encode_video(pixels, torch.tensor([record["grid_thw"]]))
|
| 244 |
+
results = model.predict_features(
|
| 245 |
+
processor, features, record, questions, context=context, threshold=threshold
|
| 246 |
+
)
|
| 247 |
+
except gr.Error:
|
| 248 |
+
raise
|
| 249 |
+
except Exception as e: # surface a readable message instead of a stack trace
|
| 250 |
+
raise gr.Error(f"VideoJev inference failed: {type(e).__name__}: {e}")
|
| 251 |
+
elapsed = time.perf_counter() - t0
|
| 252 |
+
|
| 253 |
+
parts = []
|
| 254 |
+
for item, question in zip(results, questions):
|
| 255 |
+
item = dict(item)
|
| 256 |
+
item["_query"] = question["query"]
|
| 257 |
+
if question["task"] == "choice":
|
| 258 |
+
item["_options"] = question["options"]
|
| 259 |
+
parts.append(_choice_html(item))
|
| 260 |
+
else:
|
| 261 |
+
parts.append(_grounding_html(item, record))
|
| 262 |
+
|
| 263 |
+
n_ground = sum(1 for q in questions if q["task"] == "grounding")
|
| 264 |
+
n_choice = len(questions) - n_ground
|
| 265 |
+
stats = (
|
| 266 |
+
f"**{len(questions)} question{'s' if len(questions) > 1 else ''}** "
|
| 267 |
+
f"({n_ground} grounding · {n_choice} choice) answered in "
|
| 268 |
+
f"**{elapsed:.1f}s** on GPU · {len(record['score_times'])} video timestamps · "
|
| 269 |
+
f"{record['tokens']:,} video tokens"
|
| 270 |
+
)
|
| 271 |
+
payload = [
|
| 272 |
+
{k: v for k, v in dict(item, query=q["query"]).items()}
|
| 273 |
+
for item, q in zip(results, questions)
|
| 274 |
+
]
|
| 275 |
+
return "".join(parts), stats, payload
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
# UI
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
|
| 282 |
+
CSS = """
|
| 283 |
+
#col-container { max-width: 1200px; margin: 0 auto; }
|
| 284 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
| 285 |
+
"""
|
| 286 |
+
|
| 287 |
+
DEFAULT_ROWS = [
|
| 288 |
+
["grounding", "The woman in green eats pasta.", ""],
|
| 289 |
+
["choice", "What is the woman in green eating?", "A. Pasta.; B. Rice.; C. Soup."],
|
| 290 |
+
]
|
| 291 |
+
|
| 292 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
|
| 293 |
+
gr.Markdown(
|
| 294 |
+
"""
|
| 295 |
+
# 🎬 VideoJev-4B — video decisions without generation
|
| 296 |
+
|
| 297 |
+
**Temporal grounding + multiple-choice video QA in one forward pass.** VideoJev is a video *System One*
|
| 298 |
+
model: instead of decoding text, it scores candidate time intervals and answer options directly against
|
| 299 |
+
the video — orders of magnitude faster than generative answering on the same tasks. All questions below
|
| 300 |
+
are answered together, sharing one video encoding.
|
| 301 |
+
|
| 302 |
+
[Paper](https://arxiv.org/abs/2610.18615) · [Code](https://github.com/MCG-NJU/VideoJev) ·
|
| 303 |
+
[Model](https://huggingface.co/ZhuYuhan/VideoJev-4B) · [Homepage](https://mcg-nju.github.io/VideoJev)
|
| 304 |
+
"""
|
| 305 |
+
)
|
| 306 |
+
with gr.Column(elem_id="col-container"):
|
| 307 |
+
with gr.Row():
|
| 308 |
+
video_in = gr.Video(label="Video", sources=["upload"])
|
| 309 |
+
with gr.Column(scale=7):
|
| 310 |
+
questions = gr.Dataframe(
|
| 311 |
+
headers=["task", "query", "options (choice only: ';' or newline separated)"],
|
| 312 |
+
datatype=["str", "str", "str"],
|
| 313 |
+
col_count=(3, "fixed"),
|
| 314 |
+
row_count=(1, "dynamic"),
|
| 315 |
+
value=DEFAULT_ROWS,
|
| 316 |
+
wrap=True,
|
| 317 |
+
label="Questions — all answered in ONE shared forward pass",
|
| 318 |
+
interactive=True,
|
| 319 |
+
)
|
| 320 |
+
run = gr.Button("Answer questions", variant="primary")
|
| 321 |
+
|
| 322 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 323 |
+
with gr.Row():
|
| 324 |
+
fps = gr.Radio([2.0, 4.0], value=2.0, label="Sampling fps (2 = QA, 4 = grounding per authors)")
|
| 325 |
+
max_frames = gr.Slider(16, 2048, value=512, step=16, info="Caps sampling; covers 4+ min at 2 fps",
|
| 326 |
+
label="Max frames over the whole video")
|
| 327 |
+
context = gr.Slider(8192, 262144, value=32768, step=2048,
|
| 328 |
+
info="Video resolution adapts to fit this budget",
|
| 329 |
+
label="Context tokens")
|
| 330 |
+
threshold = gr.Slider(0.05, 0.95, value=0.25, step=0.05,
|
| 331 |
+
info="Sigmoid cut for grounding intervals",
|
| 332 |
+
label="Grounding threshold")
|
| 333 |
+
|
| 334 |
+
stats = gr.Markdown("")
|
| 335 |
+
results = gr.HTML(label="Results")
|
| 336 |
+
raw_json = gr.JSON(label="Raw model output")
|
| 337 |
+
|
| 338 |
+
with gr.Accordion("How to read the output", open=False):
|
| 339 |
+
gr.Markdown(
|
| 340 |
+
"""
|
| 341 |
+
- **grounding** → an event span `[start, end]` in seconds. The bar chart shows the model's sigmoid
|
| 342 |
+
probability at each video timestamp (a translucent bar = below threshold, solid = inside the
|
| 343 |
+
returned span).
|
| 344 |
+
- **choice** → the selected option, with softmax probabilities over all options.
|
| 345 |
+
- The raw JSON is exactly what the checkpoint returns from `predict_features`: `interval`,
|
| 346 |
+
`answer`, `answer_index` and `probabilities`.
|
| 347 |
+
"""
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
gr.Examples(
|
| 351 |
+
examples=[
|
| 352 |
+
["eating_spaghetti.mp4", DEFAULT_ROWS],
|
| 353 |
+
["case-01.mp4", [
|
| 354 |
+
["grounding", "A man is sitting on a chair.", ""],
|
| 355 |
+
["grounding", "The man gets up from the chair.", ""],
|
| 356 |
+
["grounding", "The man runs toward the door.", ""],
|
| 357 |
+
]],
|
| 358 |
+
["case-01.mp4", [
|
| 359 |
+
["choice", "What does the man do first?",
|
| 360 |
+
"A. He sits on a chair; B. He runs to the door; C. He eats pasta"],
|
| 361 |
+
]],
|
| 362 |
+
],
|
| 363 |
+
inputs=[video_in, questions],
|
| 364 |
+
outputs=[results, stats, raw_json],
|
| 365 |
+
fn=analyze,
|
| 366 |
+
cache_examples=True,
|
| 367 |
+
cache_mode="lazy",
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
run.click(
|
| 371 |
+
analyze,
|
| 372 |
+
inputs=[video_in, questions, fps, max_frames, context, threshold],
|
| 373 |
+
outputs=[results, stats, raw_json],
|
| 374 |
+
api_name="analyze",
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
demo.launch(mcp_server=True)
|
case-01.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f72a9fba1d3acdc1ef76f65b85ec00793628cf0fa9bb2ff25d6509000f9dfb5b
|
| 3 |
+
size 2020390
|
eating_spaghetti.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:252f63d13748f08acf56765c295506bfdb8bb73b822e93a33a57d73988814a71
|
| 3 |
+
size 1013655
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers==5.14.1
|
| 2 |
+
torchvision
|
| 3 |
+
accelerate>=1.10
|
| 4 |
+
av==18.1.0
|
| 5 |
+
numpy>=1.26,<3
|
| 6 |
+
Pillow>=10
|
| 7 |
+
safetensors>=0.4
|