news 2026/10/23 14:15:10

video-analyzer 设计深解:关键帧提取、Whisper 转写与视觉 LLM 视频重建的完整技术管线

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
video-analyzer 设计深解:关键帧提取、Whisper 转写与视觉 LLM 视频重建的完整技术管线
  • 人工智能
  • AI 应用
  • 大模型
  • 计算机视觉
  • 语音
  • 本地部署
  • 视频处理

【免费下载链接】video-analyzer

Analyze videos using LLMs, Computer Vision and Automatic Speech Recognition

项目地址:https://gitcode.com/gh_mirrors/vi/video-analyzer
点击查看免费下载

本文基于仓库内的设计文档 docs/DESIGN.md,结合 video_analyzer 源码逐层拆解 video-analyzer 的核心工作流:自适应关键帧选择算法、FFmpeg + Whisper 音频管线、逐帧视觉分析与增量上下文机制、LLM 客户端抽象层、三级配置级联与提示词加载系统。读完后你将理解该工具如何把一段视频转化为结构化 JSON 描述,并能独立扩展新的 LLM Provider 或自定义分析提示词。

总体工作流:提取、转写、分析、重建

video-analyzer 的分析流程在 cli.py 中组织为三个可单独起步的阶段(--start-stage 1~3),对应设计文档中的四个核心环节:

  1. Frame Extraction(帧提取):使用 OpenCV 从视频中提取关键帧,通过计算帧间差异识别"关键时刻",将帧保存为 JPEG 供视觉 LLM 分析;采样量依据视频时长与目标帧率(frames per minute)自适应调整。
  2. Audio Processing(音频处理):使用 FFmpeg 抽取音轨,使用 Whisper 进行转录,通过置信度机制处理低质量音频,并把转写结果分段,供最终分析提供上下文。
  3. Frame Analysis(帧分析):每一帧由视觉 LLM 独立分析,使用 frame_analysis.txt 提示词模板引导分析,捕获时间戳、视觉元素与动作,并保持时间顺序以维持叙事连贯。
  4. Video Reconstruction(视频重建):按时间顺序合并各帧分析结果,若存在音频则整合转写文本,最终由 describe.txt 提示词模板生成技术性的视频描述。

需要说明一点命名差异:设计文档工作流小节提到的video_reconstruction.txt/narrate_storyteller.txt,在当前仓库中对应的是 video_analyzer/prompts/frame_analysis/ 下实际存在的frame_analysis.txt与describe.txt。从 default_config.json 可以看到,"Video Reconstruction" 模板即指向frame_analysis/describe.txt。

运行前提:Python 3.11+,以及音频处理所依赖的 FFmpeg;本地跑视觉模型(Ollama)时建议 16GB 以上内存,若走 OpenAI 兼容 API 则无此要求。

关键帧选择算法:从目标帧数到最终 Top-N

关键帧提取由 frame.py 中的VideoProcessor.extract_keyframes()实现,设计文档将其拆解为四个子步骤,下面结合源码逐一对照。

1. 目标帧数计算

# video_analyzer/frame.py target_frames = max(1, min( int((video_duration / 60) * frames_per_minute), total_frames, max_frames if max_frames is not None else float('inf') ))

目标帧数由视频时长与frames_per_minute相乘得出,并受两个约束夹紧:至少 1 帧、至多不超过视频总帧数;同时尊重可选的max_frames上限(对应 CLI 的--max-frames参数)。duration参数还可以把处理范围截断到前 N 秒,此时video_duration与total_frames会取两者的较小值。

2. 自适应采样

# video_analyzer/frame.py sample_interval = max(1, total_frames // (target_frames * 2))

采样间隔 =total_frames / (target_frames * 2)。也就是说,程序并不逐帧计算差异,而是以"目标帧数的两倍频率"抽取候选帧,既保证候选池足够大,又显著降低了处理负载。遍历循环中,只有frame_count % sample_interval == 0的帧才会参与差异计算。

3. 帧差分析

候选帧的差异分数由_calculate_frame_difference()计算:

# video_analyzer/frame.py def _calculate_frame_difference(self, frame1: np.ndarray, frame2: np.ndarray) -> float: # Convert to grayscale for simpler comparison gray1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY) gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY) diff = cv2.absdiff(gray1, gray2) score = np.mean(diff) return float(score)
  • 先转灰度图以降低比较成本;
  • 用 OpenCV 的cv2.absdiff计算绝对差,再对全图取均值得到分数;
  • 分数与阈值比较:VideoProcessor.FRAME_DIFFERENCE_THRESHOLD类常量默认为10.0(frame.py#L19)。只有score > threshold的帧才会进入候选列表(frame_count, frame, score);
  • 与第一帧相比时prev_frame为None,_is_keyframe()会直接判定为关键帧,保证视频开头一定被捕获。

4. 最终选择过程

# video_analyzer/frame.py # Select the most significant frames by score, then restore chronological order selected_candidates = sorted(frame_candidates, key=lambda x: x[2], reverse=True)[:target_frames] # If max_frames is specified, sample evenly across the candidates if max_frames is not None and max_frames < len(selected_candidates): step = len(selected_candidates) / max_frames selected_frames = [selected_candidates[int(i * step)] for i in range(max_frames)] else: selected_frames = selected_candidates # Re-sort by frame number so frames on disk and in the JSON are chronological selected_frames = sorted(selected_frames, key=lambda x: x[0])

选择逻辑是"先按差异分数排序取 Top-N,再按帧号恢复时间顺序":分数最高的帧意味着画面变化最剧烈(切镜、动作、新元素出现),因此最"值得看";若指定了max_frames且其小于候选数,则在已选帧上均匀间隔抽样。最终每帧被写入output/frames/frame_{idx}.jpg,并封装为Frame(number, path, timestamp, score)数据类,其中timestamp = frame_num / fps。

算法局限性

设计文档明确列出了该算法的已知边界,实际使用时应了解:

  • 采样间隔之间的帧可能被完全错过(sample_interval内只取一个采样点);
  • 快速连续的动作可能只被选中其中一帧代表;
  • 高分帧如果排名低于其他帧,会被 Top-N 截断排除;
  • 使用max_frames均匀抽样时,部分显著变化点可能被跳过。

音频处理:FFmpeg 抽取与 Whisper 转写

音频管线由 audio_processor.py 中的AudioProcessor承担,设计为两级容错:

音频抽取(extract_audio()):优先调用 FFmpeg,把音轨转为 Whisper 友好的 16kHz 单声道 PCM WAV:

ffmpeg -i <video> -vn -acodec pcm_s16le -ar 16000 -ac 1 -y output/audio.wav

若 FFmpeg 报错且提示Output file does not contain any stream(视频无音轨),函数返回None并跳过转写;若 FFmpeg 缺失或失败,则回退到 pydub 完成同样的单声道 16kHz 转换;两者都失败时抛出带各平台安装指引的RuntimeError。

转录(transcribe()):基于faster_whisper的WhisperModel加载(默认medium模型、float32计算、设备默认cpu),转写参数为beam_size=5、word_timestamps=True、vad_filter=True(最小静音 500ms 用于 VAD 过滤)。这对应设计文档所说的"通过置信度检查处理低质量音频"——VAD 过滤与词级概率共同降低了噪声音频产生幻觉文本的概率。若检测到语音,输出封装为AudioTranscript(text, segments, language)数据类,其中segments保留了逐句与逐词的时间戳和概率,供后续分段使用;若未检测到语音则返回None,主流程会以"仅视频分析"继续(cli.py#L138-L139 中会打印相应 warning)。

语言方面,transcribe()内置了 Whisper 支持的语言代码白名单,配置了不支持的language会回退为自动检测并告警。

逐帧视觉分析:增量上下文与 {prompt} 注入

analyzer.py 的VideoAnalyzer.analyze_frame()是第二阶段的主体,它有三个值得注意的设计:

1. 增量上下文(滑动叙事)。每次分析前,历史帧分析结果会被_format_previous_analyses()拼成 "Frame i\n{响应}" 的文本块,替换模板中的{PREVIOUS_FRAMES}占位符:

# video_analyzer/analyzer.py prompt = self.frame_prompt.replace("{PREVIOUS_FRAMES}", self._format_previous_analyses()) prompt = prompt.replace("{prompt}", self._format_user_prompt()) prompt = f"{prompt}\nThis is frame {frame.number} captured at {frame.timestamp:.2f} seconds."

这与 frame_analysis.txt 的结构相呼应:该模板要求模型对照"Previous Notes Section"记录场景变化、动作、新信息与连续性要点,从而让第 N 帧的分析天然携带前 N-1 帧的叙事状态,保证最终描述的时间连贯性。

2. 用户问题注入。--prompt传入的问题会经_format_user_prompt()统一加上"I want to know "前缀,再通过{prompt}token 注入模板,引导帧分析与最终描述都围绕该问题展开。

3. 失败不中断。单帧分析异常不会终止流程,而是把Error analyzing frame {n}: ...作为"分析结果"记入previous_analyses,后续帧与重建阶段照常进行——这也解释了 sample_analysis.json 中个别frame_analyses条目的响应内容可能不遵循模板格式的现象。

调用参数上,analyze_frame()固定num_predict=300,保证单帧笔记短小可控;temperature来自配置(默认 0.2,default_config.json中为 0.0)。

视频重建:合成首帧、全部笔记与转写

reconstruct_video()是第三阶段的落点。它把逐帧分析整理为带时间戳的frame_notes,并替换 describe.txt 模板中的三个占位符:

# video_analyzer/analyzer.py prompt = self.video_prompt.replace("{prompt}", self._format_user_prompt()) prompt = prompt.replace("{FRAME_NOTES}", analysis_text) prompt = prompt.replace("{FIRST_FRAME}", first_frame_text) prompt = prompt.replace("{TRANSCRIPT}", transcript_text)
  • {FRAME_NOTES}:全部帧分析按Frame i (t.ss s)格式拼接的完整笔记;
  • {FIRST_FRAME}:第一帧的分析文本,用于"开场定调";
  • {TRANSCRIPT}:Whisper 转写全文,无音轨或转写失败时为空串。

重建调用使用更大的num_predict=1000,以容纳完整的视频级总结。describe.txt 模板则规定了输出结构:Opening Description(基于首帧直接观察)、Narrative Development(按时间推进)、Technical Elements(运镜、转场、特效、光影),并附带质量自检清单,避免"超出笔记的推测"。

分析完成后,cli.py 把结果写成output/analysis.json,结构为四部分:

{ "metadata": { "client": "ollama", "model": "llama3.2-vision", "whisper_model": "medium", "frames_per_minute": 60, "duration_processed": null, "frames_extracted": 5, "frames_processed": 5, "start_stage": 1, "audio_language": "en", "transcription_successful": true }, "transcript": { "text": "...", "segments": [ ... ] }, "frame_analyses": [ { "model": "...", "response": "...", "done": true, "done_reason": "stop" } ], "video_description": { "response": "..." } }

完整示例见 docs/sample_analysis.json,可以对照验证每个字段。若配置中keep_frames为false(默认),分析结束后cleanup_files()会删除frames/目录与audio.wav;加--keep-frames则可保留中间产物用于人工核对。

LLM 集成:一个抽象基类,两种传输格式

Base Client

所有客户端继承 llm_client.py 中的LLMClient抽象基类,它负责公共的 base64 图像编码,并把生成接口的形态固定下来:

class LLMClient(ABC): def encode_image(self, image_path: str) -> str: with open(image_path, "rb") as image_file: return base64.b64encode(image_file.read()).decode('utf-8') @abstractmethod def generate(self, prompt: str, image_path: Optional[str] = None, stream: bool = False, model: str = "llama3.2-vision", temperature: float = 0.2, num_predict: int = 256) -> Dict[Any, Any]: pass

Ollama 客户端(本地)

ollama.py 的OllamaClient默认指向http://localhost:11434,向/api/generate发 POST 请求。图像以 base64 形式放入请求体的images数组:

data = { "model": model, "prompt": prompt, "stream": stream, "options": {"temperature": temperature, "num_predict": num_predict} } if image_path: data["images"] = [self.encode_image(image_path)]

非流式时直接返回 Ollama 的原始 JSON(这也是 sample 输出里带有total_duration、eval_count等 Ollama 专属字段的原因);流式时逐行解析并累积response片段。

Generic OpenAI API 客户端(云端)

generic_openai_api.py 的GenericOpenAIAPIClient兼容任何 OpenAI 风格 API(OpenAI、OpenRouter 等)。与 Ollama 的关键差异在于图像以content数组 +image_url(data URI)形式传递,并携带 Bearer 鉴权头:

content = [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}} ] data = { "model": model, "messages": [{"role": "user", "content": content}], "stream": stream, "temperature": temperature, "max_tokens": num_predict }

针对云端服务的不稳定性,该客户端内置了重试策略(默认max_retries=3):429 限流时优先读取Retry-After响应头决定等待时间,其他错误固定等待 25 秒后重试;成功响应被标准化为{"response": message['content']},与 Ollama 客户端对齐,使上层VideoAnalyzer对传输细节无感知。

客户端由 cli.py 的create_client()工厂根据配置创建:

client_type = config.get("clients", {}).get("default", "ollama") client_config = get_client(config) if client_type == "ollama": return OllamaClient(client_config["url"]) elif client_type == "openai_api": return GenericOpenAIAPIClient(client_config["api_key"], client_config["api_url"])

配置系统:命令行 > 用户配置 > 默认配置

设计文档声明的级联优先级为:

  1. 命令行参数(Command line args)
  2. 用户配置(config/config.json)
  3. 默认配置(video_analyzer/config/default_config.json)

关键配置分组示例(摘自设计文档):

{ "clients": { "default": "ollama", "ollama": { "url": "http://localhost:11434", "model": "llama3.2-vision" }, "openai_api": { "api_key": "", "api_url": "https://openrouter.ai/api/v1", "model": "meta-llama/llama-3.2-11b-vision-instruct:free" } }, "frames": { "per_minute": 60, "analysis_threshold": 10.0, "min_difference": 5.0, "max_count": 30 } }

对照仓库内完整的 default_config.json,还有几个文档未展开的分组值得了解:

{ "response_length": { "frame": 300, "reconstruction": 1000, "narrative": 500 }, "audio": { "whisper_model": "medium", "sample_rate": 16000, "channels": 1, "language": "en", "device": "cpu" }, "keep_frames": false, "prompt": "" }

从 config.py 的实现看,有两个值得注意的细节:

  • 加载语义是"整份替换"而非逐键合并:Config.load_config()中,若存在用户config.json则只加载它,否则才回退到包内默认配置。因此写用户配置时建议从default_config.json复制整份再改,避免缺失分组(如audio)导致config.get("audio", {})取不到值;
  • CLI 覆盖由update_from_args()逐键完成:--client写入clients.default,--ollama-url、--api-key(提供 key 但未指定 client 时会自动把 default 切到openai_api)、--model、--temperature、--whisper-model、--language、--device、--prompt等均有专门的映射分支,start_stage与max_frames属于纯命令行参数不落入配置。

另外get_client()对openai_api有硬性校验:api_key与api_url任一缺失即抛出ValueError,这是使用云端客户端最常见的启动期报错来源。

提示词系统:两个模板、一套加载器

两个核心提示词

模板文件职责占位符
Frame Analysisframe_analysis/frame_analysis.txt分析单帧:快速扫描变化、记录场景/动作/新信息/连续性要点,支持用户提问{PREVIOUS_FRAMES}、{prompt}
Video Reconstructionframe_analysis/describe.txt综合首帧、全部帧笔记与转写,产出结构化视频总结{FRAME_NOTES}、{TRANSCRIPT}、{prompt}

两个模板都通过{prompt}token 接收--prompt传入的用户问题(自动加上 "I want to know" 前缀),从而让同一次运行中的帧级分析与全局描述都围绕同一问题展开。提示词在VideoAnalyzer._load_prompts()中按配置顺序加载:索引 0 是帧分析,索引 1 是视频重建。

路径解析与自定义目录

PromptLoader 负责定位提示词文件。从 _find_prompt_file() 的源码结构看,实际查找顺序为:

  1. 包内资源(pkg_resources.resource_filename('video_analyzer', 'prompts/<path>'),两种安装模式下均可用);
  2. 包源码目录下的prompts/(面向pip install -e .的开发模式);
  3. 用户prompt_dir(配置项)下的<prompt_dir>/<path>:绝对路径直接拼接,~会经expanduser()展开,相对路径仅相对当前工作目录查找。

三处都找不到时抛出FileNotFoundError。设计文档强调的开发工作流是:pip install -e .之后直接改 video_analyzer/prompts/ 下的模板文件即可即时生效、无需重装。这里需要谨慎地指出:设计文档声称"系统优先使用用户指定的提示词",但当前源码的实际优先级是包内提示词优先、用户目录兜底(见上述 1→3 顺序),如果你依赖自定义目录覆盖,建议以源码行为为准并用 test_prompt_loading.py 这类脚本自行验证。

配置侧的写法(摘自设计文档):

{ "prompt_dir": "/absolute/path/to/prompts", "prompts": [ { "name": "Frame Analysis", "path": "frame_analysis/frame_analysis.txt" }, { "name": "Video Reconstruction", "path": "frame_analysis/describe.txt" } ] }

prompt_dir可写绝对路径、~/prompts或相对路径;留空""则只使用包内提示词。test_prompt_loading.py 中的test_prompt_loading()验证了两条路径:默认包内模板可加载、临时目录中的自定义模板可被按名读取。

常见问题与排查

设计文档列出的三类典型问题,结合源码给出更具体的排查点:

  1. 帧分析失败
    • Ollama 侧:确认ollama serve在运行且模型已拉取(ollama pull llama3.2-vision),OllamaClient的raise_for_status()会把连接失败/5xx 包装成API request failed;
    • OpenRouter / OpenAI 兼容侧:核对api_key与api_url(get_client()会先校验二者存在),注意choices缺失或content为空都会触发内部异常并重试;
    • 两者都要注意图像编码格式:Ollama 用images数组,OpenAI 风格用image_urldata URI,混用格式是接入新客户端时的典型错误。
  2. 内存占用
    • 调低frames.per_minute(默认 60,长视频尤其敏感)或用--max-frames限制帧数;
    • 分析后及时清理帧文件(默认keep_frames=false会自动清理,手动调试时注意cleanup_files()的逻辑);
    • 按机器条件选择--whisper-model(tiny/base/small/medium/large或本地模型路径),medium是默认值。
  3. 分析质量差
    • 检查frames.analysis_threshold(当前阈值由FRAME_DIFFERENCE_THRESHOLD=10.0类常量控制,改阈值需改 frame.py 中的常量或按扩展指南修改);
    • 核对提示词模板是否与你的模型能力匹配;
    • 确认实际生效的model(get_model()按clients.default从对应分组取值),可用--log-level DEBUG观察每次请求参数。

扩展指南:新增 LLM Provider 与自定义分析

设计文档给出的两条扩展路径,对照当前源码的实际落点:

1. 新增 LLM Provider

  • 继承 LLMClient,实现generate(),按目标 API 的要求选择图像传输格式(images数组或image_urlcontent 项),返回统一为{"response": ...};
  • 在default_config.json(或用户config.json)的clients下新增该客户端的配置分组;
  • 在 cli.py 的create_client()中增加对应分支(设计文档写的是video_analyzer.py,当前仓库中该工厂实际位于 cli.py),并在 config.py 的get_client()中补充取值与校验逻辑。

2. 自定义分析

  • 新增提示词模板文件,并在配置的prompts列表中注册name与path;
  • 在VideoAnalyzer中新增/修改分析方法,按analyze_frame()的模式注入占位符与user_prompt;
  • 调整main()中写入analysis.json的results结构以承载新的输出字段。

小结

video-analyzer 的设计可以概括为一条"降维 + 增量"的管线:先用帧差算法把视频降维到几十张高信息量的 JPEG,用 VAD + Whisper 把音轨降维成带时间戳的文本,再让视觉 LLM 在增量上下文({PREVIOUS_FRAMES})中逐帧记录、最后由重建提示词合成全局描述。所有关键参数——采样帧率、差异阈值、Whisper 模型、温度与输出长度——都收敛在三级级联的配置体系里,客户端与提示词两层各自留出了清晰的扩展点。这套结构使得本地(Ollama)与云端(OpenAI 兼容 API)可以无缝互换,也为按内容域定制分析策略提供了稳定的接口。

  • 人工智能
  • AI 应用
  • 大模型
  • 计算机视觉
  • 语音
  • 本地部署
  • 视频处理

【免费下载链接】video-analyzer

Analyze videos using LLMs, Computer Vision and Automatic Speech Recognition

项目地址:https://gitcode.com/gh_mirrors/vi/video-analyzer
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 6:57:22

ROC曲线与AUC:模型排序能力的诊断听诊器

1. 这不是数学考试&#xff0c;是模型诊断的听诊器你刚跑完一个二分类模型&#xff0c;训练集准确率98%&#xff0c;测试集也到了92%——看起来很美。但上线后业务方打来电话&#xff1a;“为什么把30%的高风险客户标成了低风险&#xff1f;漏掉的这批人&#xff0c;坏账率直接…

作者头像 李华
网站建设 2026/10/21 11:14:20

成都参军全飞秒手术医生怎么选?马可、周继容、石玉萍上榜

成都参军做全飞秒&#xff0c;近视手术医生到底怎么选&#xff1f;先把结论放前面&#xff1a;选人别只看名气&#xff0c;按四条线筛——资质能查到、经验能验证、术式对得上征兵标准、复查能跟上节奏。其中两条是硬线&#xff1a;征兵体检只认激光类手术&#xff08;全飞秒、…

作者头像 李华
网站建设 2026/10/22 11:18:51

K210固件烧录指南:v3/v4版本区分与kflash/pyloader实操

简介&#xff1a;这是一套面向K210嵌入式开发板的完整固件与烧录工具合集&#xff0c;适用于基于RISC-V架构的K210芯片开发&#xff0c;支持v3、v4等多个固件版本&#xff0c;并集成了kflash、pyloader烧录器和MaixPy开发环境。资源包共204个文件、261.67MB&#xff0c;涵盖bin…

作者头像 李华