| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- """Mage-VL Proactive Streaming Commentary Demo (Colab-compatible).
- Plays a video segment-by-segment and uses the built-in StreamMind gate
- (System 1, mamba-ssm based) to decide *when* to generate commentary,
- then invokes the full Qwen3-4B decoder (System 2) only on triggered segments.
- Paper: arXiv:2607.24904
- Official reference: mage_vl/inference_streaming.py
- """
- from __future__ import annotations
- import argparse
- import os
- import subprocess
- import sys
- import time
- from pathlib import Path
- import numpy as np
- import torch
- # ---------------------------------------------------------------------------
- # mamba-ssm check (required for StreamMind gate)
- # ---------------------------------------------------------------------------
- try:
- import mamba_ssm # noqa: F401
- _HAS_MAMBA = True
- except ImportError:
- _HAS_MAMBA = False
- USER_PROMPT = "Please describe the video content in detail based on the provided information."
- # ---------------------------------------------------------------------------
- # Video segment extraction (ffmpeg subclip)
- # ---------------------------------------------------------------------------
- def extract_subclip(src: str, start: float, dur: float, out: Path) -> Path:
- if out.exists() and out.stat().st_size > 0:
- return out
- out.parent.mkdir(parents=True, exist_ok=True)
- tmp = out.with_name(f".tmp_{out.name}")
- subprocess.run(
- ["ffmpeg", "-y", "-loglevel", "error",
- "-ss", f"{max(0.0, start):.3f}", "-t", f"{dur:.3f}",
- "-i", src, "-c", "copy",
- "-avoid_negative_ts", "make_zero", str(tmp)],
- check=True, timeout=120, capture_output=True,
- )
- tmp.replace(out)
- return out
- def build_segments(video_path: str, segment_sec: float) -> list[dict]:
- """Split video into non-overlapping segments; returns [{path, start, end}, ...]."""
- try:
- import cv2
- cap = cv2.VideoCapture(video_path)
- fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
- duration = total_frames / fps
- cap.release()
- except Exception:
- # Fallback: get duration via ffprobe
- try:
- result = subprocess.run(
- ["ffprobe", "-v", "error", "-show_entries",
- "format=duration", "-of", "default=noprint_wrappers=1:nokey=1",
- video_path],
- capture_output=True, text=True, timeout=30,
- )
- duration = float(result.stdout.strip())
- except Exception:
- duration = 60.0 # default assumption
- cache_dir = Path(video_path).resolve().parent / ".mage_clips"
- cache_dir.mkdir(parents=True, exist_ok=True)
- segments = []
- t = 0.0
- idx = 0
- while t < duration:
- end = min(duration, t + segment_sec)
- clip_path = cache_dir / f"seg_{idx:04d}_{t:.1f}-{end:.1f}.mp4"
- try:
- extract_subclip(video_path, t, end - t, clip_path)
- segments.append({"path": str(clip_path), "start": t, "end": end})
- except Exception as e:
- print(f" [warn] skip {t:.1f}-{end:.1f}s: {e}")
- t = end
- idx += 1
- return segments
- # ---------------------------------------------------------------------------
- # Gate probabilities (System 1)
- # ---------------------------------------------------------------------------
- def compute_gate_probs(model, segments: list[dict], processor,
- backend: str, num_frames: int, cur_fps: float,
- device: str, dtype) -> list[float]:
- """Run StreamMind gate over all segments; returns p(speak) per segment."""
- gate_inputs = []
- for seg in segments:
- msgs = [{"role": "user", "content": [
- {"type": "video"}, {"type": "text", "text": USER_PROMPT}
- ]}]
- prompt = processor.apply_chat_template(
- msgs, tokenize=False, add_generation_prompt=True,
- )
- kwargs = {
- "text": [prompt],
- "videos": [seg["path"]],
- "video_backend": backend,
- "return_tensors": "pt",
- "padding": False,
- }
- if backend == "codec":
- kwargs.update(
- codec_config={"patch": 16, "max_pixels": 150000},
- max_pixels=150000,
- )
- else:
- kwargs.update(num_frames=num_frames, target_fps=cur_fps)
- try:
- inp = processor(**kwargs)
- gate_inputs.append({
- "pixel_values": inp["pixel_values"].to(device=device, dtype=dtype),
- "image_grid_thw": inp["image_grid_thw"].to(device),
- "patch_positions": inp.get("patch_positions"),
- })
- except Exception as e:
- print(f" [warn] gate input failed: {e}")
- gate_inputs.append(None)
- valid = [g for g in gate_inputs if g is not None]
- probs: list[float] = []
- if valid and hasattr(model, "streammind_gate_forward_segments"):
- with torch.inference_mode():
- logits = model.streammind_gate_forward_segments(valid)[0]
- lengths = [int(g["image_grid_thw"][:, 0].sum()) for g in valid]
- boundaries = torch.tensor(lengths, device=logits.device).cumsum(0) - 1
- probs_full = torch.softmax(logits[boundaries].float(), dim=-1)[:, 1].tolist()
- pi = 0
- for g in gate_inputs:
- if g is not None:
- probs.append(probs_full[pi])
- pi += 1
- else:
- probs.append(0.0)
- else:
- # Fallback: no gate available, all segments get p=1.0
- probs = [1.0] * len(gate_inputs)
- return probs
- # ---------------------------------------------------------------------------
- # Generate commentary for a single segment (System 2)
- # ---------------------------------------------------------------------------
- def generate_segment(model, processor, seg: dict, backend: str,
- num_frames: int, cur_fps: float,
- max_new_tokens: int, device: str, dtype) -> str:
- msgs = [{"role": "user", "content": [
- {"type": "video"}, {"type": "text", "text": USER_PROMPT}
- ]}]
- prompt = processor.apply_chat_template(
- msgs, tokenize=False, add_generation_prompt=True,
- )
- kwargs = {
- "text": [prompt],
- "videos": [seg["path"]],
- "video_backend": backend,
- "return_tensors": "pt",
- "padding": False,
- }
- if backend == "codec":
- kwargs.update(
- codec_config={"patch": 16, "max_pixels": 150000},
- max_pixels=150000,
- )
- else:
- kwargs.update(num_frames=num_frames, target_fps=cur_fps)
- inputs = processor(**kwargs)
- inputs = {
- k: (v.to(device=device, dtype=dtype) if k == "pixel_values" else v.to(device))
- for k, v in inputs.items()
- }
- with torch.inference_mode():
- out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
- new = out[0, inputs["input_ids"].shape[1]:]
- return processor.tokenizer.decode(new, skip_special_tokens=True).strip()
- # ---------------------------------------------------------------------------
- # Main demo
- # ---------------------------------------------------------------------------
- def run_demo(args):
- from transformers import AutoModelForCausalLM, AutoProcessor
- print(f"[1/4] Loading model: {args.checkpoint}")
- t0 = time.time()
- processor = AutoProcessor.from_pretrained(args.checkpoint, trust_remote_code=True)
- model = AutoModelForCausalLM.from_pretrained(
- args.checkpoint,
- trust_remote_code=True,
- torch_dtype=torch.bfloat16 if args.gpu else torch.float32,
- attn_implementation=None if not args.gpu else "flash_attention_2",
- )
- device = "cuda" if args.gpu and torch.cuda.is_available() else "cpu"
- model = model.to(device).eval()
- dtype = model.dtype
- print(f" Loaded in {time.time()-t0:.1f}s on {device}")
- has_gate = hasattr(model, "streammind_gate_forward_segments")
- if not _HAS_MAMBA:
- print(" [!] mamba-ssm not installed — StreamMind gate unavailable.")
- print(" Install: pip install mamba-ssm (requires CUDA)")
- if not has_gate:
- print(" [!] Checkpoint missing streammind_gate_forward_segments(). "
- "Running un-gated (all segments will generate).")
- # ------------------------------------------------------------------
- # Build segments
- # ------------------------------------------------------------------
- print(f"[2/4] Splitting video into {args.segment_sec}s segments ...")
- segments = build_segments(args.video, args.segment_sec)
- if not segments:
- print("No segments extracted."); return
- print(f" {len(segments)} segments ({segments[-1]['end']:.1f}s total)")
- # ------------------------------------------------------------------
- # Gate probabilities (System 1)
- # ------------------------------------------------------------------
- print("[3/4] Running StreamMind gate (System 1) ...")
- gate_probs = compute_gate_probs(
- model, segments, processor, args.backend,
- args.num_frames, args.cur_fps, device, dtype,
- )
- for i, (seg, p) in enumerate(zip(segments, gate_probs)):
- action = "SPEAK" if p >= args.threshold else "silence"
- print(f" [{seg['start']:.1f}-{seg['end']:.1f}s] p={p:.2f} -> {action}")
- # ------------------------------------------------------------------
- # Generate commentary for triggered segments (System 2)
- # ------------------------------------------------------------------
- print("[4/4] Generating commentary (System 2) ...")
- print("=" * 70)
- for i, (seg, p) in enumerate(zip(segments, gate_probs)):
- elapsed = time.time()
- if p < args.threshold:
- print(f"\n[{seg['start']:.1f}-{seg['end']:.1f}s] "
- f"gate=SILENCE (p={p:.2f})")
- sys.stdout.flush()
- continue
- print(f"\n[{seg['start']:.1f}-{seg['end']:.1f}s] "
- f"gate=RESPONSE (p={p:.2f}) -> generating ...", end="", flush=True)
- try:
- text = generate_segment(
- model, processor, seg, args.backend,
- args.num_frames, args.cur_fps,
- args.max_tokens, device, dtype,
- )
- t_gen = time.time() - elapsed
- print(f" ({t_gen:.1f}s)")
- # Wrap long text for terminal readability
- for j in range(0, len(text), 100):
- print(f" {text[j:j+100]}")
- except Exception as e:
- print(f" (FAILED: {e})")
- sys.stdout.flush()
- print("\n" + "=" * 70)
- print("[done]")
- # ---------------------------------------------------------------------------
- # CLI
- # ---------------------------------------------------------------------------
- def main():
- ap = argparse.ArgumentParser(
- description="Mage-VL proactive streaming commentary demo "
- "(Colab-compatible, text-only output)",
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- )
- ap.add_argument("--video", required=True, help="Path to input video (.mp4)")
- ap.add_argument("--checkpoint", default="microsoft/Mage-VL",
- help="HuggingFace model ID or local path")
- ap.add_argument("--backend", choices=["codec", "frames"], default="codec",
- help="'codec'=Mage-ViT codec-native; 'frames'=uniform sampling")
- ap.add_argument("--segment-sec", type=float, default=8.0,
- help="Segment length in seconds")
- ap.add_argument("--num-frames", type=int, default=16,
- help="Max frames per segment (frames backend)")
- ap.add_argument("--cur-fps", type=float, default=2.0,
- help="Target FPS for frame sampling")
- ap.add_argument("--threshold", type=float, default=0.5,
- help="StreamMind gate threshold (p >= threshold => speak)")
- ap.add_argument("--max-tokens", type=int, default=120,
- help="Max new tokens per commentary generation")
- ap.add_argument("--gpu", action="store_true",
- help="Use CUDA (recommended; enables bf16 + flash-attn)")
- args = ap.parse_args()
- if not Path(args.video).is_file():
- ap.error(f"Video not found: {args.video}")
- if args.gpu and not torch.cuda.is_available():
- print("[!] --gpu set but no CUDA. Running on CPU.")
- args.gpu = False
- run_demo(args)
- if __name__ == "__main__":
- main()
|