streaming_commentary.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """Mage-VL Proactive Streaming Commentary Demo (Colab-compatible).
  2. Plays a video segment-by-segment and uses the built-in StreamMind gate
  3. (System 1, mamba-ssm based) to decide *when* to generate commentary,
  4. then invokes the full Qwen3-4B decoder (System 2) only on triggered segments.
  5. Paper: arXiv:2607.24904
  6. Official reference: mage_vl/inference_streaming.py
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import os
  11. import subprocess
  12. import sys
  13. import time
  14. from pathlib import Path
  15. import numpy as np
  16. import torch
  17. # ---------------------------------------------------------------------------
  18. # mamba-ssm check (required for StreamMind gate)
  19. # ---------------------------------------------------------------------------
  20. try:
  21. import mamba_ssm # noqa: F401
  22. _HAS_MAMBA = True
  23. except ImportError:
  24. _HAS_MAMBA = False
  25. USER_PROMPT = "Please describe the video content in detail based on the provided information."
  26. # ---------------------------------------------------------------------------
  27. # Video segment extraction (ffmpeg subclip)
  28. # ---------------------------------------------------------------------------
  29. def extract_subclip(src: str, start: float, dur: float, out: Path) -> Path:
  30. if out.exists() and out.stat().st_size > 0:
  31. return out
  32. out.parent.mkdir(parents=True, exist_ok=True)
  33. tmp = out.with_name(f".tmp_{out.name}")
  34. subprocess.run(
  35. ["ffmpeg", "-y", "-loglevel", "error",
  36. "-ss", f"{max(0.0, start):.3f}", "-t", f"{dur:.3f}",
  37. "-i", src, "-c", "copy",
  38. "-avoid_negative_ts", "make_zero", str(tmp)],
  39. check=True, timeout=120, capture_output=True,
  40. )
  41. tmp.replace(out)
  42. return out
  43. def build_segments(video_path: str, segment_sec: float) -> list[dict]:
  44. """Split video into non-overlapping segments; returns [{path, start, end}, ...]."""
  45. try:
  46. import cv2
  47. cap = cv2.VideoCapture(video_path)
  48. fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
  49. total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
  50. duration = total_frames / fps
  51. cap.release()
  52. except Exception:
  53. # Fallback: get duration via ffprobe
  54. try:
  55. result = subprocess.run(
  56. ["ffprobe", "-v", "error", "-show_entries",
  57. "format=duration", "-of", "default=noprint_wrappers=1:nokey=1",
  58. video_path],
  59. capture_output=True, text=True, timeout=30,
  60. )
  61. duration = float(result.stdout.strip())
  62. except Exception:
  63. duration = 60.0 # default assumption
  64. cache_dir = Path(video_path).resolve().parent / ".mage_clips"
  65. cache_dir.mkdir(parents=True, exist_ok=True)
  66. segments = []
  67. t = 0.0
  68. idx = 0
  69. while t < duration:
  70. end = min(duration, t + segment_sec)
  71. clip_path = cache_dir / f"seg_{idx:04d}_{t:.1f}-{end:.1f}.mp4"
  72. try:
  73. extract_subclip(video_path, t, end - t, clip_path)
  74. segments.append({"path": str(clip_path), "start": t, "end": end})
  75. except Exception as e:
  76. print(f" [warn] skip {t:.1f}-{end:.1f}s: {e}")
  77. t = end
  78. idx += 1
  79. return segments
  80. # ---------------------------------------------------------------------------
  81. # Gate probabilities (System 1)
  82. # ---------------------------------------------------------------------------
  83. def compute_gate_probs(model, segments: list[dict], processor,
  84. backend: str, num_frames: int, cur_fps: float,
  85. device: str, dtype) -> list[float]:
  86. """Run StreamMind gate over all segments; returns p(speak) per segment."""
  87. gate_inputs = []
  88. for seg in segments:
  89. msgs = [{"role": "user", "content": [
  90. {"type": "video"}, {"type": "text", "text": USER_PROMPT}
  91. ]}]
  92. prompt = processor.apply_chat_template(
  93. msgs, tokenize=False, add_generation_prompt=True,
  94. )
  95. kwargs = {
  96. "text": [prompt],
  97. "videos": [seg["path"]],
  98. "video_backend": backend,
  99. "return_tensors": "pt",
  100. "padding": False,
  101. }
  102. if backend == "codec":
  103. kwargs.update(
  104. codec_config={"patch": 16, "max_pixels": 150000},
  105. max_pixels=150000,
  106. )
  107. else:
  108. kwargs.update(num_frames=num_frames, target_fps=cur_fps)
  109. try:
  110. inp = processor(**kwargs)
  111. gate_inputs.append({
  112. "pixel_values": inp["pixel_values"].to(device=device, dtype=dtype),
  113. "image_grid_thw": inp["image_grid_thw"].to(device),
  114. "patch_positions": inp.get("patch_positions"),
  115. })
  116. except Exception as e:
  117. print(f" [warn] gate input failed: {e}")
  118. gate_inputs.append(None)
  119. valid = [g for g in gate_inputs if g is not None]
  120. probs: list[float] = []
  121. if valid and hasattr(model, "streammind_gate_forward_segments"):
  122. with torch.inference_mode():
  123. logits = model.streammind_gate_forward_segments(valid)[0]
  124. lengths = [int(g["image_grid_thw"][:, 0].sum()) for g in valid]
  125. boundaries = torch.tensor(lengths, device=logits.device).cumsum(0) - 1
  126. probs_full = torch.softmax(logits[boundaries].float(), dim=-1)[:, 1].tolist()
  127. pi = 0
  128. for g in gate_inputs:
  129. if g is not None:
  130. probs.append(probs_full[pi])
  131. pi += 1
  132. else:
  133. probs.append(0.0)
  134. else:
  135. # Fallback: no gate available, all segments get p=1.0
  136. probs = [1.0] * len(gate_inputs)
  137. return probs
  138. # ---------------------------------------------------------------------------
  139. # Generate commentary for a single segment (System 2)
  140. # ---------------------------------------------------------------------------
  141. def generate_segment(model, processor, seg: dict, backend: str,
  142. num_frames: int, cur_fps: float,
  143. max_new_tokens: int, device: str, dtype) -> str:
  144. msgs = [{"role": "user", "content": [
  145. {"type": "video"}, {"type": "text", "text": USER_PROMPT}
  146. ]}]
  147. prompt = processor.apply_chat_template(
  148. msgs, tokenize=False, add_generation_prompt=True,
  149. )
  150. kwargs = {
  151. "text": [prompt],
  152. "videos": [seg["path"]],
  153. "video_backend": backend,
  154. "return_tensors": "pt",
  155. "padding": False,
  156. }
  157. if backend == "codec":
  158. kwargs.update(
  159. codec_config={"patch": 16, "max_pixels": 150000},
  160. max_pixels=150000,
  161. )
  162. else:
  163. kwargs.update(num_frames=num_frames, target_fps=cur_fps)
  164. inputs = processor(**kwargs)
  165. inputs = {
  166. k: (v.to(device=device, dtype=dtype) if k == "pixel_values" else v.to(device))
  167. for k, v in inputs.items()
  168. }
  169. with torch.inference_mode():
  170. out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
  171. new = out[0, inputs["input_ids"].shape[1]:]
  172. return processor.tokenizer.decode(new, skip_special_tokens=True).strip()
  173. # ---------------------------------------------------------------------------
  174. # Main demo
  175. # ---------------------------------------------------------------------------
  176. def run_demo(args):
  177. from transformers import AutoModelForCausalLM, AutoProcessor
  178. print(f"[1/4] Loading model: {args.checkpoint}")
  179. t0 = time.time()
  180. processor = AutoProcessor.from_pretrained(args.checkpoint, trust_remote_code=True)
  181. model = AutoModelForCausalLM.from_pretrained(
  182. args.checkpoint,
  183. trust_remote_code=True,
  184. torch_dtype=torch.bfloat16 if args.gpu else torch.float32,
  185. attn_implementation=None if not args.gpu else "flash_attention_2",
  186. )
  187. device = "cuda" if args.gpu and torch.cuda.is_available() else "cpu"
  188. model = model.to(device).eval()
  189. dtype = model.dtype
  190. print(f" Loaded in {time.time()-t0:.1f}s on {device}")
  191. has_gate = hasattr(model, "streammind_gate_forward_segments")
  192. if not _HAS_MAMBA:
  193. print(" [!] mamba-ssm not installed — StreamMind gate unavailable.")
  194. print(" Install: pip install mamba-ssm (requires CUDA)")
  195. if not has_gate:
  196. print(" [!] Checkpoint missing streammind_gate_forward_segments(). "
  197. "Running un-gated (all segments will generate).")
  198. # ------------------------------------------------------------------
  199. # Build segments
  200. # ------------------------------------------------------------------
  201. print(f"[2/4] Splitting video into {args.segment_sec}s segments ...")
  202. segments = build_segments(args.video, args.segment_sec)
  203. if not segments:
  204. print("No segments extracted."); return
  205. print(f" {len(segments)} segments ({segments[-1]['end']:.1f}s total)")
  206. # ------------------------------------------------------------------
  207. # Gate probabilities (System 1)
  208. # ------------------------------------------------------------------
  209. print("[3/4] Running StreamMind gate (System 1) ...")
  210. gate_probs = compute_gate_probs(
  211. model, segments, processor, args.backend,
  212. args.num_frames, args.cur_fps, device, dtype,
  213. )
  214. for i, (seg, p) in enumerate(zip(segments, gate_probs)):
  215. action = "SPEAK" if p >= args.threshold else "silence"
  216. print(f" [{seg['start']:.1f}-{seg['end']:.1f}s] p={p:.2f} -> {action}")
  217. # ------------------------------------------------------------------
  218. # Generate commentary for triggered segments (System 2)
  219. # ------------------------------------------------------------------
  220. print("[4/4] Generating commentary (System 2) ...")
  221. print("=" * 70)
  222. for i, (seg, p) in enumerate(zip(segments, gate_probs)):
  223. elapsed = time.time()
  224. if p < args.threshold:
  225. print(f"\n[{seg['start']:.1f}-{seg['end']:.1f}s] "
  226. f"gate=SILENCE (p={p:.2f})")
  227. sys.stdout.flush()
  228. continue
  229. print(f"\n[{seg['start']:.1f}-{seg['end']:.1f}s] "
  230. f"gate=RESPONSE (p={p:.2f}) -> generating ...", end="", flush=True)
  231. try:
  232. text = generate_segment(
  233. model, processor, seg, args.backend,
  234. args.num_frames, args.cur_fps,
  235. args.max_tokens, device, dtype,
  236. )
  237. t_gen = time.time() - elapsed
  238. print(f" ({t_gen:.1f}s)")
  239. # Wrap long text for terminal readability
  240. for j in range(0, len(text), 100):
  241. print(f" {text[j:j+100]}")
  242. except Exception as e:
  243. print(f" (FAILED: {e})")
  244. sys.stdout.flush()
  245. print("\n" + "=" * 70)
  246. print("[done]")
  247. # ---------------------------------------------------------------------------
  248. # CLI
  249. # ---------------------------------------------------------------------------
  250. def main():
  251. ap = argparse.ArgumentParser(
  252. description="Mage-VL proactive streaming commentary demo "
  253. "(Colab-compatible, text-only output)",
  254. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  255. )
  256. ap.add_argument("--video", required=True, help="Path to input video (.mp4)")
  257. ap.add_argument("--checkpoint", default="microsoft/Mage-VL",
  258. help="HuggingFace model ID or local path")
  259. ap.add_argument("--backend", choices=["codec", "frames"], default="codec",
  260. help="'codec'=Mage-ViT codec-native; 'frames'=uniform sampling")
  261. ap.add_argument("--segment-sec", type=float, default=8.0,
  262. help="Segment length in seconds")
  263. ap.add_argument("--num-frames", type=int, default=16,
  264. help="Max frames per segment (frames backend)")
  265. ap.add_argument("--cur-fps", type=float, default=2.0,
  266. help="Target FPS for frame sampling")
  267. ap.add_argument("--threshold", type=float, default=0.5,
  268. help="StreamMind gate threshold (p >= threshold => speak)")
  269. ap.add_argument("--max-tokens", type=int, default=120,
  270. help="Max new tokens per commentary generation")
  271. ap.add_argument("--gpu", action="store_true",
  272. help="Use CUDA (recommended; enables bf16 + flash-attn)")
  273. args = ap.parse_args()
  274. if not Path(args.video).is_file():
  275. ap.error(f"Video not found: {args.video}")
  276. if args.gpu and not torch.cuda.is_available():
  277. print("[!] --gpu set but no CUDA. Running on CPU.")
  278. args.gpu = False
  279. run_demo(args)
  280. if __name__ == "__main__":
  281. main()