| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- #!/usr/bin/env python3
- """Proact-VL-style streaming inference with off-the-shelf VLMs (Colab friendly).
- The official Proact-VL checkpoint is currently restricted, so this module
- approximates its proactive streaming behavior (a per-second SPEAK/SILENCE
- decision plus a short commentary line, like `MultiAssistantStreamInference.infer_one_chunk`)
- using a Qwen2.5-VL / Qwen3-VL backbone.
- Two tasks:
- --task commentary (default)
- Per-second proactive commentary. Silent seconds are omitted from `pred`
- (matching the official quickstart), which `f1_only` / `pauc` treat as
- silence. Output follows the official evaluation JSONL format so the
- results can be scored with `proactvl_eval` / the official scripts.
- --task count
- Per-second event detection: the VLM decides each second whether a NEW
- item (e.g. a piece of clothing) is being dropped into the machine,
- with client-side merge dedup. Output record adds `events`, `count`,
- and optionally `gt_count` for immediate accuracy check.
- Use this for 衣物投放视频 ("how many clothes did the user drop").
- Output record (one line per video segment):
- {"video_id": "myclip.mp4", "dataset_name": "custom", "begin": 0, "end": 60,
- "idx": 0, "tag": "custom", "model_id": "...", "task": "count",
- "scene": "衣物投放", "pred": {"5": "DROP: red shirt", "12": "DROP: blue jeans"},
- "events": [{"sec": 5, "desc": "red shirt"}, {"sec": 12, "desc": "blue jeans"}],
- "count": 2, "gt_count": 3}
- Examples:
- # single video, count dropped clothing items
- python proactvl_stream_infer.py --video clip.mp4 --task count \
- --begin 0 --end 60 --gt_count 3 --out count.jsonl
- # Proact-VL-style commentary (official eval pipeline input)
- python proactvl_stream_infer.py --video clip.mp4 --task commentary \
- --begin 0 --end 30 --tag "Solo commentators" --out pred.jsonl
- # LiveGamingBenchmark segments
- python proactvl_stream_infer.py --dataset oaaoaa/LiveGamingBenchmark \
- --test_name all_in_one --video_dir ./data/LiveGamingBenchmark/DATA \
- --num_samples 5 --out pred.jsonl
- Hardware note: the 3B model fits a 16GB GPU in fp16/bf16; 7B/8B models need
- `--load_in_4bit` on Colab T4/P100.
- """
- import argparse
- import json
- import os
- from dataclasses import dataclass
- from pathlib import Path
- COMMENTARY_SYSTEM_PROMPT = (
- "You are a live commentator for a game video. "
- "Your role is to independently analyze and narrate the game, delivering "
- "insightful, engaging, and natural commentary just like a human expert. "
- "It is not necessary to speak continuously; during uneventful or "
- "transitional parts, remain silent."
- )
- COUNT_SYSTEM_PROMPT = (
- "You are a careful video analyst counting physical objects being placed "
- "into a machine or container in real time. You watch a live stream "
- "second by second and detect the exact moment each NEW object is dropped. "
- "An object already in progress from the previous second is not a new one."
- )
- DEFAULT_COUNT_SCENE = "衣物投放 (clothing items being dropped into a washing machine / drop bin)"
- @dataclass
- class InferenceConfig:
- model: str = "Qwen/Qwen2.5-VL-3B-Instruct"
- device: str = "cuda"
- dtype: str = "auto" # auto | float16 | bfloat16
- load_in_4bit: bool = False
- task: str = "commentary" # commentary | count
- scene: str = "" # used for count task, e.g. 衣物投放
- merge_gap: int = 1 # event seconds within this gap are merged (dedup)
- gt_count: int = -1 # -1 = unknown; else used for accuracy check
- frames_per_sec: int = 2
- max_new_tokens: int = 12 # mirrors the official generate_config
- temperature: float = 0.7
- top_p: float = 0.9
- repetition_penalty: float = 1.15
- do_sample: bool = True
- history_lines: int = 8 # spoken lines kept in context (commentary)
- model_id: str = "stream_infer"
- def sample_frames(video_path: str, begin: int, end: int, fps: int,
- out_hw=(288, 512)) -> dict:
- """Return {second: [PIL.Image, ...]} for t in [begin, end)."""
- from PIL import Image
- try:
- import decord
- vr = decord.VideoReader(video_path, num_threads=1)
- fps_video = vr.get_avg_fps()
- frames = {}
- for t in range(begin, end):
- i0 = int(t * fps_video)
- idxs = [min(i, len(vr) - 1) for i in range(i0, i0 + fps)]
- frames[t] = [Image.fromarray(vr[i].asnumpy()).convert("RGB").resize(out_hw)
- for i in idxs]
- return frames
- except ImportError:
- import cv2
- cap = cv2.VideoCapture(video_path)
- fps_video = cap.get(cv2.CAP_PROP_FPS) or 30.0
- frames = {}
- for t in range(begin, end):
- imgs = []
- for k in range(fps):
- cap.set(cv2.CAP_PROP_POS_FRAMES, int((t + (k + 0.5) / fps) * fps_video))
- ok, fr = cap.read()
- if ok:
- fr = cv2.resize(fr, out_hw)
- imgs.append(Image.fromarray(fr[:, :, ::-1]).convert("RGB"))
- frames[t] = imgs
- cap.release()
- return frames
- class OpenVLStreamInfer:
- """Per-second proactive decisions using an open VLM as the decision head."""
- def __init__(self, cfg: InferenceConfig):
- self.cfg = cfg
- import torch
- from transformers import AutoProcessor, AutoModelForVision2Seq
- self.torch = torch
- kwargs = {}
- if not cfg.load_in_4bit:
- kwargs["torch_dtype"] = cfg.dtype
- kwargs["attn_implementation"] = "sdpa"
- else:
- from transformers import BitsAndBytesConfig
- kwargs["quantization_config"] = BitsAndBytesConfig(
- load_in_4bit=True, bnb_4bit_compute_dtype="float16")
- self.model = AutoModelForVision2Seq.from_pretrained(cfg.model, **kwargs)
- self.model = self.model.to(cfg.device).eval()
- self.processor = AutoProcessor.from_pretrained(cfg.model)
- # ---------- commentary task ----------
- def _history_text(self, spoken: list) -> str:
- if not spoken:
- return "(no prior commentary yet)"
- return "\n".join(f"[t={t}] {txt}" for t, txt in spoken[-self.cfg.history_lines:])
- def chunk(self, second: int, imgs, spoken: list) -> str:
- """Return commentary text, or '' for silence."""
- cfg = self.cfg
- prompt = (
- f"Video stream up to second {second}:\n"
- f"Prior commentary:\n{self._history_text(spoken)}\n"
- f"Current frames for second {second} are attached below.\n"
- "Decide now: if something notable is happening in these frames "
- "and you have not just said it, reply with one short commentary "
- "line (max ~12 words). Otherwise reply exactly SILENCE."
- )
- messages = [
- {"role": "system", "content": COMMENTARY_SYSTEM_PROMPT},
- {"role": "user", "content": [
- *[{"type": "image", "image": im} for im in imgs],
- {"type": "text", "text": prompt},
- ]},
- ]
- inputs = self._generate(messages)
- text = self._decode(inputs)
- if text.upper().startswith("SILENCE") or not text:
- return ""
- return text
- # ---------- count task ----------
- def chunk_event(self, second: int, imgs, events: list) -> str:
- """Return event description ('DROP: ...' style), or '' for no new event."""
- cfg = self.cfg
- scene = cfg.scene or DEFAULT_COUNT_SCENE
- secs = ", ".join(str(e["sec"]) for e in events) if events else "none"
- prompt = (
- f"You are watching a live video of {scene}.\n"
- f"New items confirmed so far: {len(events)} (at seconds: {secs}).\n"
- f"Attached frames show second {second} of the stream.\n"
- "If a NEW item is clearly being dropped/placed in during second "
- f"{second} (it was not already in progress in the previous second), "
- "respond in exactly this format:\n"
- "DROP: <short description of the item, max 6 words>\n"
- "If no new item is dropped in this second, respond exactly:\n"
- "NONE"
- )
- messages = [
- {"role": "system", "content": COUNT_SYSTEM_PROMPT},
- {"role": "user", "content": [
- *[{"type": "image", "image": im} for im in imgs],
- {"type": "text", "text": prompt},
- ]},
- ]
- inputs = self._generate(messages)
- text = self._decode(inputs).strip()
- if "DROP" in text.upper():
- desc = text.split(":", 1)[1].strip() if ":" in text else text
- return desc
- return ""
- # ---------- shared ----------
- def _generate(self, messages: list):
- cfg = self.cfg
- inputs = self.processor.apply_chat_template(
- messages, add_generation_prompt=True, tokenize=True,
- return_dict=True, return_tensors="pt")
- inputs = {k: v.to(cfg.device) for k, v in inputs.items()}
- with self.torch.inference_mode():
- out = self.model.generate(
- **inputs, max_new_tokens=cfg.max_new_tokens,
- do_sample=cfg.do_sample, temperature=cfg.temperature,
- top_p=cfg.top_p, repetition_penalty=cfg.repetition_penalty)
- return inputs, out
- def _decode(self, generated: tuple) -> str:
- inputs, out = generated
- gen = out[0][inputs["input_ids"].shape[1]:]
- return self.processor.decode(gen, skip_special_tokens=True).strip()
- def run_video(self, video_path: str, begin: int, end: int) -> dict:
- """Run the configured task over [begin, end).
- commentary -> {"pred": {sec: text}}
- count -> {"pred": {sec: "DROP: ..."}, "events": [...], "count": int}
- """
- frames = sample_frames(video_path, begin, end, self.cfg.frames_per_sec)
- out: dict = {}
- if self.cfg.task == "count":
- events = []
- pred = {}
- for t in range(begin, end):
- desc = self.chunk_event(t, frames[t], events)
- if desc.upper().startswith('DROP:'):
- desc = desc.split(':', 1)[1].strip()
- if desc:
- last = events[-1]["sec"] if events else -10**9
- if t - last <= self.cfg.merge_gap:
- # continuation of the same drop motion: merge
- events[-1]["desc"] = desc
- pred[str(t)] = f"DROP: {desc}"
- print(f"[Sec {t}]: (merged) DROP: {desc}")
- continue
- events.append({"sec": t, "desc": desc})
- pred[str(t)] = f"DROP: {desc}"
- print(f"[Sec {t}]: DROP: {desc} (total {len(events)})")
- else:
- print(f"[Sec {t}]: <none>")
- out["pred"] = pred
- out["events"] = events
- out["count"] = len(events)
- if self.cfg.gt_count >= 0:
- out["gt_count"] = self.cfg.gt_count
- out["count_correct"] = len(events) == self.cfg.gt_count
- print(f"Predicted {len(events)}, ground truth {self.cfg.gt_count} -> "
- f"{'CORRECT' if out['count_correct'] else 'MISMATCH'}")
- else:
- pred = {}
- spoken = []
- for t in range(begin, end):
- txt = self.chunk(t, frames[t], spoken)
- if txt:
- pred[str(t)] = txt
- spoken.append((t, txt))
- print(f"[Sec {t}]: {txt if txt else '<SILENCE>'}")
- out["pred"] = pred
- return out
- def iter_records(args, infer: OpenVLStreamInfer):
- """Yield prediction/analysis records (official eval JSONL compatible)."""
- if args.dataset:
- from datasets import load_dataset
- ds = load_dataset(args.dataset, name=args.test_name, split="test")
- n = min(args.num_samples, len(ds)) if args.num_samples > 0 else len(ds)
- for idx in range(n):
- rec = ds[idx]
- video_path = os.path.join(args.video_dir, rec["video_path"])
- result = infer.run_video(video_path, rec["video_begin"], rec["video_end"])
- yield {
- "video_id": os.path.basename(video_path),
- "dataset_name": rec["dataset_name"],
- "begin": rec["video_begin"],
- "end": rec["video_end"],
- "idx": rec.get("idx", idx),
- "tag": rec.get("tag", ""),
- "model_id": args.model_id,
- "task": args.task,
- **result,
- }
- else:
- result = infer.run_video(args.video, args.begin, args.end)
- yield {
- "video_id": os.path.basename(args.video),
- "dataset_name": args.dataset_name,
- "begin": args.begin,
- "end": args.end,
- "idx": 0,
- "tag": args.tag,
- "model_id": args.model_id,
- "task": args.task,
- **result,
- }
- def _cuda_available() -> bool:
- try:
- import torch
- return torch.cuda.is_available()
- except ImportError:
- return False
- def parse_args():
- p = argparse.ArgumentParser(description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter)
- p.add_argument("--model", default="Qwen/Qwen2.5-VL-3B-Instruct")
- p.add_argument("--model_id", default=None, help="Label written into outputs")
- p.add_argument("--device", default=None)
- p.add_argument("--dtype", default="auto", choices=["auto", "float16", "bfloat16"])
- p.add_argument("--load_in_4bit", action="store_true")
- p.add_argument("--fps", type=int, default=2, dest="frames_per_sec")
- p.add_argument("--history_lines", type=int, default=8)
- p.add_argument("--task", default="commentary", choices=["commentary", "count"])
- p.add_argument("--scene", default="", help="Scene description for count task, e.g. 衣物投放")
- p.add_argument("--merge_gap", type=int, default=1,
- help="Drops within this many seconds are merged as one event")
- p.add_argument("--gt_count", type=int, default=-1,
- help="Ground-truth count; -1 to skip comparison")
- p.add_argument("--out", required=True)
- g = p.add_argument_group("input: single video")
- g.add_argument("--video")
- g.add_argument("--begin", type=int, default=0)
- g.add_argument("--end", type=int, default=30)
- g.add_argument("--tag", default="Solo commentators")
- g.add_argument("--dataset_name", default="custom")
- g = p.add_argument_group("input: LiveGamingBenchmark segments")
- g.add_argument("--dataset", default=None, help="HF dataset, e.g. oaaoaa/LiveGamingBenchmark")
- g.add_argument("--test_name", default="all_in_one")
- g.add_argument("--video_dir", default=None)
- g.add_argument("--num_samples", type=int, default=5)
- return p.parse_args()
- def main():
- args = parse_args()
- if not args.video and not args.dataset:
- raise SystemExit("provide --video ... or --dataset ...")
- if args.model_id is None:
- args.model_id = os.path.basename(args.model) + "_stream"
- cfg = InferenceConfig(
- model=args.model,
- device=args.device or ("cuda" if _cuda_available() else "cpu"),
- dtype=args.dtype,
- load_in_4bit=args.load_in_4bit,
- task=args.task,
- scene=args.scene,
- merge_gap=args.merge_gap,
- gt_count=args.gt_count,
- frames_per_sec=args.frames_per_sec,
- history_lines=args.history_lines,
- model_id=args.model_id,
- )
- infer = OpenVLStreamInfer(cfg)
- records = list(iter_records(args, infer))
- out = Path(args.out)
- out.parent.mkdir(parents=True, exist_ok=True)
- with open(out, "w", encoding="utf-8") as f:
- for rec in records:
- f.write(json.dumps(rec, ensure_ascii=False) + "\n")
- print(f"Wrote {len(records)} record(s) to {out}")
- for rec in records:
- if rec.get("task") == "count":
- print(f" {rec['video_id']}: predicted count = {rec['count']}"
- + (f" (gt={rec['gt_count']}, correct={rec['count_correct']})"
- if "gt_count" in rec else ""))
- if __name__ == "__main__":
- main()
|