minicpmo_proactive_streaming.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """
  2. MiniCPM-o 4.5 主动式流媒体视频分析 — Colab 可执行脚本
  3. 功能:
  4. - 上传或提供 mp4 URL
  5. - 按 segment_sec 切分视频
  6. - 对每段调用 MiniCPM-o 4.5 流式推理
  7. - 系统提示词驱动模型在检测到关键节点时主动输出推荐/评论
  8. - 可选语音输出(TTS)
  9. 用法:
  10. python minicpmo_proactive_streaming.py \
  11. --video /content/your_video.mp4 \
  12. --prompt "观察视频中投放的衣物,在每次出现新衣物时给出洗涤/护理推荐"
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import os
  17. import sys
  18. import time
  19. from pathlib import Path
  20. import numpy as np
  21. import torch
  22. # ---------------------------------------------------------------------------
  23. # 1. 安装依赖(Colab 环境自动跳过已安装的包)
  24. # ---------------------------------------------------------------------------
  25. print("[1/5] 检查并安装依赖 ...")
  26. subprocess_required = ["ffmpeg"]
  27. import subprocess
  28. for cmd in subprocess_required:
  29. if not shutil.which(cmd):
  30. print(f" 正在安装 {cmd} ...")
  31. subprocess.run(["apt-get", "update", "-qq"], check=False, capture_output=True)
  32. subprocess.run(["apt-get", "install", "-y", "-qq", cmd], check=False, capture_output=True)
  33. import shutil
  34. DEPS = [
  35. "transformers==4.51.0",
  36. "accelerate",
  37. "torch>=2.3.0,<=2.8.0",
  38. "torchaudio<=2.8.0",
  39. "minicpmo-utils[all]>=1.0.5",
  40. "librosa",
  41. "soundfile",
  42. "cv2",
  43. ]
  44. for pkg in DEPS:
  45. try:
  46. __import__(pkg.split("==")[0].split(">=")[0].split("<=")[0].split("[")[0].replace("-", "_"))
  47. except ImportError:
  48. print(f" 安装 {pkg} ...")
  49. subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
  50. print(" ✓ 依赖就绪")
  51. # ---------------------------------------------------------------------------
  52. # 2. 加载模型
  53. # ---------------------------------------------------------------------------
  54. print("[2/5] 加载 MiniCPM-o 4.5 ...")
  55. from transformers import AutoModel
  56. MODEL_ID = "openbmb/MiniCPM-o-4_5"
  57. model = AutoModel.from_pretrained(
  58. MODEL_ID,
  59. trust_remote_code=True,
  60. attn_implementation="sdpa",
  61. torch_dtype=torch.bfloat16,
  62. init_vision=True,
  63. init_audio=True,
  64. init_tts=True,
  65. )
  66. model.eval().cuda()
  67. model.init_tts()
  68. print(f" ✓ 模型已加载 ({sum(p.numel() for p in model.parameters())/1e9:.1f}B 参数)")
  69. # ---------------------------------------------------------------------------
  70. # 3. 下载/准备视频
  71. # ---------------------------------------------------------------------------
  72. print("[3/5] 准备视频 ...")
  73. import cv2
  74. def download_or_use(path_or_url: str, dst_dir: Path) -> str:
  75. """本地路径直接返回;URL 则下载到 dst_dir。"""
  76. p = Path(path_or_url)
  77. if p.exists():
  78. return str(p)
  79. out = dst_dir / p.name
  80. if out.exists():
  81. return str(out)
  82. print(f" 正在下载: {path_or_url}")
  83. subprocess.run(
  84. ["curl", "-L", "-o", str(out), path_or_url],
  85. check=True, capture_output=True,
  86. )
  87. return str(out)
  88. def get_video_duration(video_path: str) -> float:
  89. cap = cv2.VideoCapture(video_path)
  90. fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
  91. frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
  92. cap.release()
  93. return frames / fps
  94. def build_segments(video_path: str, segment_sec: float) -> list[dict]:
  95. """将视频切分为等长片段,返回 [{start, end, path}, ...]"""
  96. duration = get_video_duration(video_path)
  97. cache = Path(video_path).resolve().parent / ".segments"
  98. cache.mkdir(exist_ok=True)
  99. segments = []
  100. t = 0.0
  101. idx = 0
  102. while t < duration - 0.1:
  103. end = min(duration, t + segment_sec)
  104. clip = cache / f"seg_{idx:04d}_{t:.1f}-{end:.1f}.mp4"
  105. if not clip.exists():
  106. subprocess.run(
  107. ["ffmpeg", "-y", "-loglevel", "error",
  108. "-ss", f"{t:.3f}", "-t", f"{end - t:.3f}",
  109. "-i", video_path, "-c", "copy",
  110. "-avoid_negative_ts", "make_zero", str(clip)],
  111. check=True, capture_output=True,
  112. )
  113. segments.append({"path": str(clip), "start": t, "end": end})
  114. t = end
  115. idx += 1
  116. return segments
  117. # ---------------------------------------------------------------------------
  118. # 4. 流式推理主循环
  119. # ---------------------------------------------------------------------------
  120. print("[4/5] 开始流式推理 ...")
  121. from minicpmo.utils import get_video_frame_audio_segments
  122. DEFAULT_PROMPT = (
  123. "你是一位专业的衣物护理顾问。请观看视频,"
  124. "在每次检测到新的衣物类型被投放/展示时,"
  125. "主动给出简短的洗涤/护理推荐(中英文均可)。"
  126. "如果画面中没有新衣物出现,保持沉默不输出。"
  127. "输出格式:[时间戳] 推荐内容"
  128. )
  129. def run_segment(model, session_id: str, seg: dict,
  130. sys_msg: dict, user_prompt: str,
  131. generate_audio: bool = False) -> dict:
  132. """对单个视频片段运行 streaming_prefill + streaming_generate,返回 {text, audio_path, start, end}"""
  133. seg_path = seg["path"]
  134. start = seg["start"]
  135. # 提取帧和音频段
  136. try:
  137. video_frames, audio_segments, stacked_frames = get_video_frame_audio_segments(
  138. seg_path, stack_frames=1, use_ffmpeg=True, adjust_audio_length=True
  139. )
  140. except Exception as e:
  141. print(f" [!] 视频处理失败 {seg_path}: {e}")
  142. return {"start": start, "end": seg["end"], "text": f"[错误] {e}", "audio_path": None}
  143. if not video_frames:
  144. return {"start": start, "end": seg["end"], "text": "[无帧]", "audio_path": None}
  145. # Step 1: Prefill 系统提示
  146. model.streaming_prefill(session_id=session_id, msgs=[sys_msg])
  147. # Step 2: Prefill 视频帧 + 音频
  148. omni_contents = []
  149. for i, frame in enumerate(video_frames):
  150. omni_contents.append(frame)
  151. if i < len(audio_segments):
  152. omni_contents.append(audio_segments[i])
  153. if stacked_frames is not None and i < len(stacked_frames) and stacked_frames[i] is not None:
  154. omni_contents.append(stacked_frames[i])
  155. # 追加用户指令
  156. omni_contents.append(user_prompt)
  157. last_idx = len(omni_contents) - 1
  158. for idx, content in enumerate(omni_contents):
  159. is_last = (idx == last_idx)
  160. model.streaming_prefill(
  161. session_id=session_id,
  162. msgs=[{"role": "user", "content": [content]}],
  163. omni_mode=True,
  164. is_last_chunk=is_last,
  165. )
  166. # Step 3: 生成回复
  167. audio_chunks = []
  168. text_parts = []
  169. try:
  170. gen = model.streaming_generate(
  171. session_id=session_id,
  172. generate_audio=generate_audio,
  173. use_tts_template=True,
  174. enable_thinking=False,
  175. do_sample=True,
  176. temperature=0.7,
  177. max_new_tokens=256,
  178. )
  179. for item in gen:
  180. if generate_audio and isinstance(item, tuple) and len(item) == 2:
  181. wav_chunk, text_chunk = item
  182. audio_chunks.append(wav_chunk)
  183. text_parts.append(text_chunk)
  184. else:
  185. text_chunk, is_finished = item
  186. text_parts.append(text_chunk)
  187. except Exception as e:
  188. return {"start": start, "end": seg["end"], "text": f"[生成错误] {e}", "audio_path": None}
  189. text = "".join(text_parts).strip()
  190. audio_path = None
  191. if generate_audio and audio_chunks:
  192. generated_waveform = torch.cat(audio_chunks, dim=-1)[0]
  193. audio_path = f"/content/proactive_output_{int(start):04d}.wav"
  194. import soundfile as sf
  195. sf.write(audio_path, generated_waveform.cpu().numpy(), samplerate=24000)
  196. return {"start": start, "end": seg["end"], "text": text, "audio_path": audio_path}
  197. # ---------------------------------------------------------------------------
  198. # 5. 主入口
  199. # ---------------------------------------------------------------------------
  200. def main():
  201. ap = argparse.ArgumentParser(description="MiniCPM-o 4.5 主动式流媒体视频分析")
  202. ap.add_argument("--video", required=True, help="mp4 本地路径 或 HTTP URL")
  203. ap.add_argument("--prompt", default=DEFAULT_PROMPT, help="任务指令 prompt")
  204. ap.add_argument("--language", default="zh", choices=["zh", "en"], help="系统提示语言")
  205. ap.add_argument("--segment-sec", type=float, default=10.0, help="每段时长(秒)")
  206. ap.add_argument("--threshold", type=float, default=0.0,
  207. help="仅当检测到新内容时才输出(当前版本始终输出)")
  208. ap.add_argument("--output-audio", action="store_true", help="是否生成语音输出")
  209. ap.add_argument("--ref-audio", default=None, help="参考音频路径(用于声音克隆,可选)")
  210. args = ap.parse_args()
  211. # 下载/定位视频
  212. video_path = download_or_use(args.video, Path("/content"))
  213. print(f" 视频: {video_path} ({get_video_duration(video_path):.1f}s)")
  214. # 构建片段列表
  215. segments = build_segments(video_path, args.segment_sec)
  216. print(f" 共 {len(segments)} 个片段 (每段 {args.segment_sec}s)")
  217. # 系统提示
  218. if args.language == "zh":
  219. sys_content = [
  220. "你是一个智能衣物护理助手,持续观看视频流。",
  221. "当画面中出现新的衣物/织物时,主动给出简短的洗涤或护理推荐。",
  222. "若未检测到新内容则不要输出。",
  223. "每次输出以 [MM:SS] 开头标注时间戳。",
  224. ]
  225. else:
  226. sys_content = [
  227. "You are a smart clothing care assistant watching a video stream.",
  228. "When new clothing/fabric appears, proactively give a brief washing or care recommendation.",
  229. "Stay silent if nothing new is detected.",
  230. "Start each output with a [MM:SS] timestamp.",
  231. ]
  232. sys_msg = {"role": "system", "content": sys_content}
  233. # 可选:声音克隆
  234. if args.ref_audio and Path(args.ref_audio).exists():
  235. import librosa
  236. ref_audio, _ = librosa.load(args.ref_audio, sr=16000, mono=True)
  237. model.init_token2wav_cache(ref_audio)
  238. print(f" 声音克隆参考音频已加载: {args.ref_audio}")
  239. session_id = "proactive_session"
  240. model.reset_session()
  241. # 运行推理
  242. print("[5/5] 推理中 ...")
  243. print("=" * 70)
  244. all_results = []
  245. t_total = time.time()
  246. for seg in segments:
  247. r = run_segment(
  248. model, session_id, seg,
  249. sys_msg=sys_msg,
  250. user_prompt=args.prompt,
  251. generate_audio=args.output_audio,
  252. )
  253. all_results.append(r)
  254. elapsed = time.time() - t_total
  255. status = "🔊" if r["audio_path"] else "📝"
  256. print(f"[{status}] [{r['start']:.1f}-{r['end']:.1f}s] "
  257. f"{r['text'][:120] or '(沉默)'} ({elapsed:.1f}s)")
  258. sys.stdout.flush()
  259. print("=" * 70)
  260. print(f"✓ 完成!总耗时 {time.time()-t_total:.1f}s,共 {len(all_results)} 段")
  261. # 汇总输出
  262. print("\n=== 关键时间节点推荐汇总 ===")
  263. for r in all_results:
  264. if r["text"] and r["text"].strip() and r["text"].strip() not in ("(沉默)", "[无帧]"):
  265. print(f" [{r['start']:.1f}s] {r['text']}")
  266. # 合并音频(如果有)
  267. if args.output_audio:
  268. audio_files = [r["audio_path"] for r in all_results if r["audio_path"]]
  269. if audio_files:
  270. import soundfile as sf
  271. merged = np.concatenate([sf.read(f, dtype="float32")[0] for f in audio_files])
  272. out_audio = "/content/proactive_full_output.wav"
  273. sf.write(out_audio, merged, samplerate=24000)
  274. print(f"\n完整语音输出: {out_audio}")
  275. return all_results
  276. if __name__ == "__main__":
  277. main()