frame_analysis.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. #!/usr/bin/env python3
  2. """frame_analysis.py — 抽帧 + 豆包视觉逐帧点评.
  3. 用法:
  4. python3 frame_analysis.py --workdir /tmp/lapian --fps 0.1 --max-frames 12
  5. python3 frame_analysis.py --workdir /tmp/lapian --skip-vision # 只抽帧, 不调视觉
  6. 前置: workdir/video.mp4 (可选 workdir/transcript.json 用于对齐台词)
  7. 产物: workdir/frames/f0001.jpg... + workdir/frames.json
  8. frames.json = [{frame, file, t_ms, t_label, vision, caption}...]
  9. 环境: FEME_NEWAPI_TOKEN — 调 api.fmode.cn 视觉模型(计费)
  10. """
  11. import argparse
  12. import base64
  13. import json
  14. import os
  15. import subprocess
  16. import sys
  17. import urllib.request
  18. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  19. from common import ffprobe_duration, fmt_ts, get_api_token # noqa: E402
  20. VISION_MODEL = os.environ.get("FEME_VISION_MODEL", "doubao-seed-2-0-pro-260215")
  21. VISION_BASE = os.environ.get("FEME_API_BASE", "https://api.fmode.cn/v1")
  22. PROMPT = """你是短视频编导顾问, 对视频抽帧做画面拆解。用中文 JSON 输出(不要markdown代码块):
  23. {"shot":"景别(远景/全景/中景/近景/特写)","scene":"场景与置景一句话",
  24. "subjects":"人物/主体在做什么(表情动作)","text_on_screen":"画面文字, 没有则空串",
  25. "aesthetic":"画面技法一句话(光影/构图/节奏)"},
  26. 只描述这一帧可见的信息。"""
  27. def extract_frames(video: str, outdir: str, fps: float, max_frames: int) -> list:
  28. """均匀抽帧(先抽满 fps, 超出上限再等距丢弃), 返回 [{frame,file,t_ms}]。"""
  29. os.makedirs(outdir, exist_ok=True)
  30. subprocess.run(["ffmpeg", "-y", "-i", video, "-vf", f"fps={fps}",
  31. "-q:v", "3", os.path.join(outdir, "f%04d.jpg")],
  32. capture_output=True, text=True)
  33. files = sorted(f for f in os.listdir(outdir) if f.endswith(".jpg"))
  34. if len(files) > max_frames:
  35. keep = [files[round(i * (len(files) - 1) / (max_frames - 1))]
  36. for i in range(max_frames)]
  37. for f in files:
  38. if f not in keep:
  39. os.remove(os.path.join(outdir, f))
  40. files = keep
  41. dur = ffprobe_duration(video) or 0.0
  42. frames = []
  43. for i, f in enumerate(files):
  44. t_ms = int(dur * 1000 * i / max(len(files) - 1, 1))
  45. frames.append({"frame": i + 1, "file": f, "t_ms": t_ms,
  46. "t_label": fmt_ts(t_ms)})
  47. return frames
  48. def nearest_line(t_ms: int, segments: list) -> str:
  49. for seg in segments:
  50. if seg.get("bg", 0) <= t_ms < seg.get("ed", 0):
  51. return seg.get("text", "")
  52. return ""
  53. def call_vision(image_path: str) -> str:
  54. """单帧 → 豆包视觉, 返回模型文本(JSON字符串或自然语言)。"""
  55. b64 = base64.b64encode(open(image_path, "rb").read()).decode()
  56. payload = {
  57. "model": VISION_MODEL,
  58. "messages": [{"role": "user", "content": [
  59. {"type": "text", "text": PROMPT},
  60. {"type": "image_url",
  61. "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}],
  62. "temperature": 0.12, "max_tokens": 500,
  63. }
  64. req = urllib.request.Request(
  65. f"{VISION_BASE}/chat/completions",
  66. data=json.dumps(payload).encode(),
  67. headers={"Content-Type": "application/json",
  68. "Authorization": f"Bearer {get_api_token()}"})
  69. with urllib.request.urlopen(req, timeout=120) as r:
  70. resp = json.loads(r.read())
  71. return (resp["choices"][0]["message"]["content"] or "").strip()
  72. def parse_vision_json(text: str) -> dict:
  73. """宽容解析模型输出(剥 markdown 围栏, 抠第一个 {...})。"""
  74. text = text.strip()
  75. if text.startswith("```"):
  76. text = text.strip("`").lstrip("json").strip()
  77. try:
  78. return json.loads(text)
  79. except Exception:
  80. pass
  81. if "{" in text and "}" in text:
  82. try:
  83. return json.loads(text[text.index("{"): text.rindex("}") + 1])
  84. except Exception:
  85. pass
  86. return {}
  87. def main():
  88. ap = argparse.ArgumentParser()
  89. ap.add_argument("--workdir", required=True)
  90. ap.add_argument("--fps", type=float, default=0.15, help="抽帧密度(默认0.15≈6.7s一帧)")
  91. ap.add_argument("--max-frames", type=int, default=12, help="最多分析帧数(控制计费)")
  92. ap.add_argument("--skip-vision", action="store_true", help="只抽帧, 不调视觉模型")
  93. args = ap.parse_args()
  94. video = os.path.join(args.workdir, "video.mp4")
  95. if not os.path.exists(video):
  96. raise SystemExit(f"[frames] 缺少 {video}")
  97. outdir = os.path.join(args.workdir, "frames")
  98. frames = extract_frames(video, outdir, args.fps, args.max_frames)
  99. print(f"[frames] 抽帧 {len(frames)} 张 → {outdir}")
  100. segs = []
  101. tp = os.path.join(args.workdir, "transcript.json")
  102. if os.path.exists(tp):
  103. segs = (json.load(open(tp, encoding="utf-8")).get("data") or {}).get("segments") or []
  104. for fr in frames:
  105. fr["line"] = nearest_line(fr["t_ms"], segs)
  106. if args.skip_vision:
  107. fr["vision"] = {}
  108. fr["caption"] = fr["line"][:60] or "(未调视觉)"
  109. continue
  110. img = os.path.join(outdir, fr["file"])
  111. try:
  112. raw = call_vision(img)
  113. v = parse_vision_json(raw)
  114. fr["vision"] = v
  115. parts = [v.get(k, "") for k in ("scene", "subjects", "aesthetic")]
  116. fr["caption"] = ";".join(p for p in parts if p) or raw[:120]
  117. print(f"[frames] f{fr['frame']:04d} {fr['t_label']} ✓ {fr['caption'][:50]}")
  118. except Exception as e:
  119. fr["vision"], fr["caption"] = {}, f"(视觉识别失败: {e})"
  120. print(f"[frames] f{fr['frame']:04d} {fr['t_label']} ✗ {e}")
  121. out = os.path.join(args.workdir, "frames.json")
  122. json.dump(frames, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
  123. ok = sum(1 for f in frames if f.get("vision"))
  124. print(f"[frames] frames.json: {len(frames)} 帧(视觉成功 {ok})")
  125. if __name__ == "__main__":
  126. main()