- 人工智能
- AI 应用
- 大模型
- 计算机视觉
- 语音
- 本地部署
- 视频处理
【免费下载链接】video-analyzer
Analyze videos using LLMs, Computer Vision and Automatic Speech Recognition
本文基于仓库内的设计文档 docs/DESIGN.md,结合 video_analyzer 源码逐层拆解 video-analyzer 的核心工作流:自适应关键帧选择算法、FFmpeg + Whisper 音频管线、逐帧视觉分析与增量上下文机制、LLM 客户端抽象层、三级配置级联与提示词加载系统。读完后你将理解该工具如何把一段视频转化为结构化 JSON 描述,并能独立扩展新的 LLM Provider 或自定义分析提示词。
总体工作流:提取、转写、分析、重建
video-analyzer 的分析流程在 cli.py 中组织为三个可单独起步的阶段(--start-stage 1~3),对应设计文档中的四个核心环节:
- Frame Extraction(帧提取):使用 OpenCV 从视频中提取关键帧,通过计算帧间差异识别"关键时刻",将帧保存为 JPEG 供视觉 LLM 分析;采样量依据视频时长与目标帧率(frames per minute)自适应调整。
- Audio Processing(音频处理):使用 FFmpeg 抽取音轨,使用 Whisper 进行转录,通过置信度机制处理低质量音频,并把转写结果分段,供最终分析提供上下文。
- Frame Analysis(帧分析):每一帧由视觉 LLM 独立分析,使用 frame_analysis.txt 提示词模板引导分析,捕获时间戳、视觉元素与动作,并保持时间顺序以维持叙事连贯。
- 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]: passOllama 客户端(本地)
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"])配置系统:命令行 > 用户配置 > 默认配置
设计文档声明的级联优先级为:
- 命令行参数(Command line args)
- 用户配置(
config/config.json) - 默认配置(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 Analysis | frame_analysis/frame_analysis.txt | 分析单帧:快速扫描变化、记录场景/动作/新信息/连续性要点,支持用户提问 | {PREVIOUS_FRAMES}、{prompt} |
| Video Reconstruction | frame_analysis/describe.txt | 综合首帧、全部帧笔记与转写,产出结构化视频总结 | {FRAME_NOTES}、{TRANSCRIPT}、{prompt} |
两个模板都通过{prompt}token 接收--prompt传入的用户问题(自动加上 "I want to know" 前缀),从而让同一次运行中的帧级分析与全局描述都围绕同一问题展开。提示词在VideoAnalyzer._load_prompts()中按配置顺序加载:索引 0 是帧分析,索引 1 是视频重建。
路径解析与自定义目录
PromptLoader 负责定位提示词文件。从 _find_prompt_file() 的源码结构看,实际查找顺序为:
- 包内资源(
pkg_resources.resource_filename('video_analyzer', 'prompts/<path>'),两种安装模式下均可用); - 包源码目录下的
prompts/(面向pip install -e .的开发模式); - 用户
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()验证了两条路径:默认包内模板可加载、临时目录中的自定义模板可被按名读取。
常见问题与排查
设计文档列出的三类典型问题,结合源码给出更具体的排查点:
- 帧分析失败
- 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,混用格式是接入新客户端时的典型错误。
- Ollama 侧:确认
- 内存占用
- 调低
frames.per_minute(默认 60,长视频尤其敏感)或用--max-frames限制帧数; - 分析后及时清理帧文件(默认
keep_frames=false会自动清理,手动调试时注意cleanup_files()的逻辑); - 按机器条件选择
--whisper-model(tiny/base/small/medium/large或本地模型路径),medium是默认值。
- 调低
- 分析质量差
- 检查
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
相关推荐
从视频到帧:用you-get+FFmpeg提取关键帧的完整指南
从视频到帧:用you get+FFmpeg提取关键帧的完整指南 引言:告别低效截图,掌握视频帧提取黑科技 你是否还在为截取视频中的高清关键帧而烦恼?手动暂停截图
网页爬虫CLIgraphify 视频/音频转写指南:Whisper 提示词策略与 transcribe 管线全解析
graphify 视频/音频转写指南:Whisper 提示词策略与 transcribe 管线全解析 在 graphify 中,视频与音频文件无法像代码或文档那
人工智能知识图谱RAGAI 技能开发工具MCP 服务AI视频分析神器video-analyzer:新手也能轻松提取视频精华的完整教程
你是否曾经面对几小时的视频素材感到无从下手?🤔 想要快速了解视频核心内容却苦于没有时间完整观看?今天我要向你介绍的这款 AI视频分析工具 video anal
人工智能AI 应用大模型计算机视觉语音本地部署视频处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考