proactvl_stream_infer.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. #!/usr/bin/env python3
  2. """Proact-VL-style streaming inference with off-the-shelf VLMs (Colab friendly).
  3. The official Proact-VL checkpoint is currently restricted, so this module
  4. approximates its proactive streaming behavior (a per-second SPEAK/SILENCE
  5. decision plus a short commentary line, like `MultiAssistantStreamInference.infer_one_chunk`)
  6. using a Qwen2.5-VL / Qwen3-VL backbone.
  7. Two tasks:
  8. --task commentary (default)
  9. Per-second proactive commentary. Silent seconds are omitted from `pred`
  10. (matching the official quickstart), which `f1_only` / `pauc` treat as
  11. silence. Output follows the official evaluation JSONL format so the
  12. results can be scored with `proactvl_eval` / the official scripts.
  13. --task count
  14. Per-second event detection: the VLM decides each second whether a NEW
  15. item (e.g. a piece of clothing) is being dropped into the machine,
  16. with client-side merge dedup. Output record adds `events`, `count`,
  17. and optionally `gt_count` for immediate accuracy check.
  18. Use this for 衣物投放视频 ("how many clothes did the user drop").
  19. Output record (one line per video segment):
  20. {"video_id": "myclip.mp4", "dataset_name": "custom", "begin": 0, "end": 60,
  21. "idx": 0, "tag": "custom", "model_id": "...", "task": "count",
  22. "scene": "衣物投放", "pred": {"5": "DROP: red shirt", "12": "DROP: blue jeans"},
  23. "events": [{"sec": 5, "desc": "red shirt"}, {"sec": 12, "desc": "blue jeans"}],
  24. "count": 2, "gt_count": 3}
  25. Examples:
  26. # single video, count dropped clothing items
  27. python proactvl_stream_infer.py --video clip.mp4 --task count \
  28. --begin 0 --end 60 --gt_count 3 --out count.jsonl
  29. # Proact-VL-style commentary (official eval pipeline input)
  30. python proactvl_stream_infer.py --video clip.mp4 --task commentary \
  31. --begin 0 --end 30 --tag "Solo commentators" --out pred.jsonl
  32. # LiveGamingBenchmark segments
  33. python proactvl_stream_infer.py --dataset oaaoaa/LiveGamingBenchmark \
  34. --test_name all_in_one --video_dir ./data/LiveGamingBenchmark/DATA \
  35. --num_samples 5 --out pred.jsonl
  36. Hardware note: the 3B model fits a 16GB GPU in fp16/bf16; 7B/8B models need
  37. `--load_in_4bit` on Colab T4/P100.
  38. """
  39. import argparse
  40. import json
  41. import os
  42. from dataclasses import dataclass
  43. from pathlib import Path
  44. COMMENTARY_SYSTEM_PROMPT = (
  45. "You are a live commentator for a game video. "
  46. "Your role is to independently analyze and narrate the game, delivering "
  47. "insightful, engaging, and natural commentary just like a human expert. "
  48. "It is not necessary to speak continuously; during uneventful or "
  49. "transitional parts, remain silent."
  50. )
  51. COUNT_SYSTEM_PROMPT = (
  52. "You are a careful video analyst counting physical objects being placed "
  53. "into a machine or container in real time. You watch a live stream "
  54. "second by second and detect the exact moment each NEW object is dropped. "
  55. "An object already in progress from the previous second is not a new one."
  56. )
  57. DEFAULT_COUNT_SCENE = "衣物投放 (clothing items being dropped into a washing machine / drop bin)"
  58. @dataclass
  59. class InferenceConfig:
  60. model: str = "Qwen/Qwen2.5-VL-3B-Instruct"
  61. device: str = "cuda"
  62. dtype: str = "auto" # auto | float16 | bfloat16
  63. load_in_4bit: bool = False
  64. task: str = "commentary" # commentary | count
  65. scene: str = "" # used for count task, e.g. 衣物投放
  66. merge_gap: int = 1 # event seconds within this gap are merged (dedup)
  67. gt_count: int = -1 # -1 = unknown; else used for accuracy check
  68. frames_per_sec: int = 2
  69. max_new_tokens: int = 12 # mirrors the official generate_config
  70. temperature: float = 0.7
  71. top_p: float = 0.9
  72. repetition_penalty: float = 1.15
  73. do_sample: bool = True
  74. history_lines: int = 8 # spoken lines kept in context (commentary)
  75. model_id: str = "stream_infer"
  76. def sample_frames(video_path: str, begin: int, end: int, fps: int,
  77. out_hw=(288, 512)) -> dict:
  78. """Return {second: [PIL.Image, ...]} for t in [begin, end)."""
  79. from PIL import Image
  80. try:
  81. import decord
  82. vr = decord.VideoReader(video_path, num_threads=1)
  83. fps_video = vr.get_avg_fps()
  84. frames = {}
  85. for t in range(begin, end):
  86. i0 = int(t * fps_video)
  87. idxs = [min(i, len(vr) - 1) for i in range(i0, i0 + fps)]
  88. frames[t] = [Image.fromarray(vr[i].asnumpy()).convert("RGB").resize(out_hw)
  89. for i in idxs]
  90. return frames
  91. except ImportError:
  92. import cv2
  93. cap = cv2.VideoCapture(video_path)
  94. fps_video = cap.get(cv2.CAP_PROP_FPS) or 30.0
  95. frames = {}
  96. for t in range(begin, end):
  97. imgs = []
  98. for k in range(fps):
  99. cap.set(cv2.CAP_PROP_POS_FRAMES, int((t + (k + 0.5) / fps) * fps_video))
  100. ok, fr = cap.read()
  101. if ok:
  102. fr = cv2.resize(fr, out_hw)
  103. imgs.append(Image.fromarray(fr[:, :, ::-1]).convert("RGB"))
  104. frames[t] = imgs
  105. cap.release()
  106. return frames
  107. class OpenVLStreamInfer:
  108. """Per-second proactive decisions using an open VLM as the decision head."""
  109. def __init__(self, cfg: InferenceConfig):
  110. self.cfg = cfg
  111. import torch
  112. from transformers import AutoProcessor, AutoModelForVision2Seq
  113. self.torch = torch
  114. kwargs = {}
  115. if not cfg.load_in_4bit:
  116. kwargs["torch_dtype"] = cfg.dtype
  117. kwargs["attn_implementation"] = "sdpa"
  118. else:
  119. from transformers import BitsAndBytesConfig
  120. kwargs["quantization_config"] = BitsAndBytesConfig(
  121. load_in_4bit=True, bnb_4bit_compute_dtype="float16")
  122. self.model = AutoModelForVision2Seq.from_pretrained(cfg.model, **kwargs)
  123. self.model = self.model.to(cfg.device).eval()
  124. self.processor = AutoProcessor.from_pretrained(cfg.model)
  125. # ---------- commentary task ----------
  126. def _history_text(self, spoken: list) -> str:
  127. if not spoken:
  128. return "(no prior commentary yet)"
  129. return "\n".join(f"[t={t}] {txt}" for t, txt in spoken[-self.cfg.history_lines:])
  130. def chunk(self, second: int, imgs, spoken: list) -> str:
  131. """Return commentary text, or '' for silence."""
  132. cfg = self.cfg
  133. prompt = (
  134. f"Video stream up to second {second}:\n"
  135. f"Prior commentary:\n{self._history_text(spoken)}\n"
  136. f"Current frames for second {second} are attached below.\n"
  137. "Decide now: if something notable is happening in these frames "
  138. "and you have not just said it, reply with one short commentary "
  139. "line (max ~12 words). Otherwise reply exactly SILENCE."
  140. )
  141. messages = [
  142. {"role": "system", "content": COMMENTARY_SYSTEM_PROMPT},
  143. {"role": "user", "content": [
  144. *[{"type": "image", "image": im} for im in imgs],
  145. {"type": "text", "text": prompt},
  146. ]},
  147. ]
  148. inputs = self._generate(messages)
  149. text = self._decode(inputs)
  150. if text.upper().startswith("SILENCE") or not text:
  151. return ""
  152. return text
  153. # ---------- count task ----------
  154. def chunk_event(self, second: int, imgs, events: list) -> str:
  155. """Return event description ('DROP: ...' style), or '' for no new event."""
  156. cfg = self.cfg
  157. scene = cfg.scene or DEFAULT_COUNT_SCENE
  158. secs = ", ".join(str(e["sec"]) for e in events) if events else "none"
  159. prompt = (
  160. f"You are watching a live video of {scene}.\n"
  161. f"New items confirmed so far: {len(events)} (at seconds: {secs}).\n"
  162. f"Attached frames show second {second} of the stream.\n"
  163. "If a NEW item is clearly being dropped/placed in during second "
  164. f"{second} (it was not already in progress in the previous second), "
  165. "respond in exactly this format:\n"
  166. "DROP: <short description of the item, max 6 words>\n"
  167. "If no new item is dropped in this second, respond exactly:\n"
  168. "NONE"
  169. )
  170. messages = [
  171. {"role": "system", "content": COUNT_SYSTEM_PROMPT},
  172. {"role": "user", "content": [
  173. *[{"type": "image", "image": im} for im in imgs],
  174. {"type": "text", "text": prompt},
  175. ]},
  176. ]
  177. inputs = self._generate(messages)
  178. text = self._decode(inputs).strip()
  179. if "DROP" in text.upper():
  180. desc = text.split(":", 1)[1].strip() if ":" in text else text
  181. return desc
  182. return ""
  183. # ---------- shared ----------
  184. def _generate(self, messages: list):
  185. cfg = self.cfg
  186. inputs = self.processor.apply_chat_template(
  187. messages, add_generation_prompt=True, tokenize=True,
  188. return_dict=True, return_tensors="pt")
  189. inputs = {k: v.to(cfg.device) for k, v in inputs.items()}
  190. with self.torch.inference_mode():
  191. out = self.model.generate(
  192. **inputs, max_new_tokens=cfg.max_new_tokens,
  193. do_sample=cfg.do_sample, temperature=cfg.temperature,
  194. top_p=cfg.top_p, repetition_penalty=cfg.repetition_penalty)
  195. return inputs, out
  196. def _decode(self, generated: tuple) -> str:
  197. inputs, out = generated
  198. gen = out[0][inputs["input_ids"].shape[1]:]
  199. return self.processor.decode(gen, skip_special_tokens=True).strip()
  200. def run_video(self, video_path: str, begin: int, end: int) -> dict:
  201. """Run the configured task over [begin, end).
  202. commentary -> {"pred": {sec: text}}
  203. count -> {"pred": {sec: "DROP: ..."}, "events": [...], "count": int}
  204. """
  205. frames = sample_frames(video_path, begin, end, self.cfg.frames_per_sec)
  206. out: dict = {}
  207. if self.cfg.task == "count":
  208. events = []
  209. pred = {}
  210. for t in range(begin, end):
  211. desc = self.chunk_event(t, frames[t], events)
  212. if desc.upper().startswith('DROP:'):
  213. desc = desc.split(':', 1)[1].strip()
  214. if desc:
  215. last = events[-1]["sec"] if events else -10**9
  216. if t - last <= self.cfg.merge_gap:
  217. # continuation of the same drop motion: merge
  218. events[-1]["desc"] = desc
  219. pred[str(t)] = f"DROP: {desc}"
  220. print(f"[Sec {t}]: (merged) DROP: {desc}")
  221. continue
  222. events.append({"sec": t, "desc": desc})
  223. pred[str(t)] = f"DROP: {desc}"
  224. print(f"[Sec {t}]: DROP: {desc} (total {len(events)})")
  225. else:
  226. print(f"[Sec {t}]: <none>")
  227. out["pred"] = pred
  228. out["events"] = events
  229. out["count"] = len(events)
  230. if self.cfg.gt_count >= 0:
  231. out["gt_count"] = self.cfg.gt_count
  232. out["count_correct"] = len(events) == self.cfg.gt_count
  233. print(f"Predicted {len(events)}, ground truth {self.cfg.gt_count} -> "
  234. f"{'CORRECT' if out['count_correct'] else 'MISMATCH'}")
  235. else:
  236. pred = {}
  237. spoken = []
  238. for t in range(begin, end):
  239. txt = self.chunk(t, frames[t], spoken)
  240. if txt:
  241. pred[str(t)] = txt
  242. spoken.append((t, txt))
  243. print(f"[Sec {t}]: {txt if txt else '<SILENCE>'}")
  244. out["pred"] = pred
  245. return out
  246. def iter_records(args, infer: OpenVLStreamInfer):
  247. """Yield prediction/analysis records (official eval JSONL compatible)."""
  248. if args.dataset:
  249. from datasets import load_dataset
  250. ds = load_dataset(args.dataset, name=args.test_name, split="test")
  251. n = min(args.num_samples, len(ds)) if args.num_samples > 0 else len(ds)
  252. for idx in range(n):
  253. rec = ds[idx]
  254. video_path = os.path.join(args.video_dir, rec["video_path"])
  255. result = infer.run_video(video_path, rec["video_begin"], rec["video_end"])
  256. yield {
  257. "video_id": os.path.basename(video_path),
  258. "dataset_name": rec["dataset_name"],
  259. "begin": rec["video_begin"],
  260. "end": rec["video_end"],
  261. "idx": rec.get("idx", idx),
  262. "tag": rec.get("tag", ""),
  263. "model_id": args.model_id,
  264. "task": args.task,
  265. **result,
  266. }
  267. else:
  268. result = infer.run_video(args.video, args.begin, args.end)
  269. yield {
  270. "video_id": os.path.basename(args.video),
  271. "dataset_name": args.dataset_name,
  272. "begin": args.begin,
  273. "end": args.end,
  274. "idx": 0,
  275. "tag": args.tag,
  276. "model_id": args.model_id,
  277. "task": args.task,
  278. **result,
  279. }
  280. def _cuda_available() -> bool:
  281. try:
  282. import torch
  283. return torch.cuda.is_available()
  284. except ImportError:
  285. return False
  286. def parse_args():
  287. p = argparse.ArgumentParser(description=__doc__,
  288. formatter_class=argparse.RawDescriptionHelpFormatter)
  289. p.add_argument("--model", default="Qwen/Qwen2.5-VL-3B-Instruct")
  290. p.add_argument("--model_id", default=None, help="Label written into outputs")
  291. p.add_argument("--device", default=None)
  292. p.add_argument("--dtype", default="auto", choices=["auto", "float16", "bfloat16"])
  293. p.add_argument("--load_in_4bit", action="store_true")
  294. p.add_argument("--fps", type=int, default=2, dest="frames_per_sec")
  295. p.add_argument("--history_lines", type=int, default=8)
  296. p.add_argument("--task", default="commentary", choices=["commentary", "count"])
  297. p.add_argument("--scene", default="", help="Scene description for count task, e.g. 衣物投放")
  298. p.add_argument("--merge_gap", type=int, default=1,
  299. help="Drops within this many seconds are merged as one event")
  300. p.add_argument("--gt_count", type=int, default=-1,
  301. help="Ground-truth count; -1 to skip comparison")
  302. p.add_argument("--out", required=True)
  303. g = p.add_argument_group("input: single video")
  304. g.add_argument("--video")
  305. g.add_argument("--begin", type=int, default=0)
  306. g.add_argument("--end", type=int, default=30)
  307. g.add_argument("--tag", default="Solo commentators")
  308. g.add_argument("--dataset_name", default="custom")
  309. g = p.add_argument_group("input: LiveGamingBenchmark segments")
  310. g.add_argument("--dataset", default=None, help="HF dataset, e.g. oaaoaa/LiveGamingBenchmark")
  311. g.add_argument("--test_name", default="all_in_one")
  312. g.add_argument("--video_dir", default=None)
  313. g.add_argument("--num_samples", type=int, default=5)
  314. return p.parse_args()
  315. def main():
  316. args = parse_args()
  317. if not args.video and not args.dataset:
  318. raise SystemExit("provide --video ... or --dataset ...")
  319. if args.model_id is None:
  320. args.model_id = os.path.basename(args.model) + "_stream"
  321. cfg = InferenceConfig(
  322. model=args.model,
  323. device=args.device or ("cuda" if _cuda_available() else "cpu"),
  324. dtype=args.dtype,
  325. load_in_4bit=args.load_in_4bit,
  326. task=args.task,
  327. scene=args.scene,
  328. merge_gap=args.merge_gap,
  329. gt_count=args.gt_count,
  330. frames_per_sec=args.frames_per_sec,
  331. history_lines=args.history_lines,
  332. model_id=args.model_id,
  333. )
  334. infer = OpenVLStreamInfer(cfg)
  335. records = list(iter_records(args, infer))
  336. out = Path(args.out)
  337. out.parent.mkdir(parents=True, exist_ok=True)
  338. with open(out, "w", encoding="utf-8") as f:
  339. for rec in records:
  340. f.write(json.dumps(rec, ensure_ascii=False) + "\n")
  341. print(f"Wrote {len(records)} record(s) to {out}")
  342. for rec in records:
  343. if rec.get("task") == "count":
  344. print(f" {rec['video_id']}: predicted count = {rec['count']}"
  345. + (f" (gt={rec['gt_count']}, correct={rec['count_correct']})"
  346. if "gt_count" in rec else ""))
  347. if __name__ == "__main__":
  348. main()