SmolVLM2-2.2B を用いたフレーム処理によるローカル動画要約パイプライン
研究者が軽量な画像理解モデル「SmolVLM2-2.2B」を活用し、ローカル環境で動画のフレームを処理して要約する新しいパイプラインを開発した。
キーポイント
軽量モデルによるローカル処理の実現
「SmolVLM2-2.2B」という比較的小規模な画像理解モデルを採用することで、クラウド依存なしでローカル環境において動画フレームの分析を可能にした。
プライバシーとコスト効率の向上
データを外部サーバーに送信する必要がないため、機密情報の漏洩リスクが低減され、計算リソースや通信コストの削減が期待できる。
フレームベース要約パイプラインの構築
動画から抽出した複数のフレームをモデルに入力し、各フレームの内容を理解・統合することで、全体を要約する自動化システムを構築している。
重要な引用
軽量な画像理解モデル「SmolVLM2-2.2B」を活用
ローカル環境で動画のフレームを処理して要約
影響分析・編集コメントを表示
影響分析
このアプローチは、大規模なクラウドモデルへの依存を減らし、エッジデバイスやオンプレミス環境での動画解析の実用性を高める重要な一歩です。特に機密性の高い映像データを扱う企業や、ネットワーク接続が不安定な現場において、ローカルで動作する高品質な要約ツールとしての価値が大きいと考えられます。
編集コメント
大規模モデルが主流となる中、軽量モデルを巧みに活用してローカル環境での実用性を高める事例は、AI の民主化とプライバシー保護の観点から非常に示唆に富んでいます。
image**
# イントロダクション
ほとんどの動画理解ツールは、以下の 2 つの陣営のいずれかに分類されます。最初の陣営はクラウド API を必要とし、映像をアップロードして他者のサーバーで処理され、動画の分単位で課金されます。2 つ目の陣営はローカルで動作しますが、開発者が通常保有していないような GPU クラスターを要求します:70B 以上のモデルであり、複数の A100 グラフィックボードが必要で、クリップあたり数分かかります。これらどちらの選択肢も、すでに所有するワークステーション上で、1 日分の会議録画、講義シリーズ、あるいはセキュリティ映像を処理したい開発者にとっては機能しません。
2025 年 2 月 20 日に Hugging Face がリリースした SmolVLM2-2.2B-Instruct は、この計算式を変えます。これは 5.2 GB の GPU メモリ、RTX 3060、MacBook Pro M2、そして無料の Google Colab T4 タイプで動作します。Video-MME(標準的な長尺動画理解ベンチマーク)では、既存のすべての 2B スケールモデルを上回る性能を発揮します。この組み合わせ、つまり消費者向けハードウェアと実際に信頼できる結果が得られる点は、この記事の核心です。
この記事で構築するプロジェクトは、任意のビデオファイルを入力とし、設定可能な間隔でフレームを抽出し、SmolVLM2-2.2B を用いてバッチ処理で分析し、構造化された JSON 形式の要約(各フレームごとのシーン説明、タイムスタンプ付きの重要な瞬間、アクションアイテム、そして最終的なナラティブを含む)を出力するローカルパイプラインです。この同じパイプラインは、コードを変更することなく、会議の録画、講義、監視映像も処理します。
SmolVLM2-2.2B が RTX 3060 で動作しながら、より大規模なモデルを上回るビデオタスク性能を発揮できる理由は、画像をトークン化する際の設計上の判断にあります。
多くのビジョン・ランゲージモデルは、高密度で画像をトークン化します。例えば Qwen2-VL は、単一の画像を表すために最大 16,000 トークンを使用します。そのような密度で 50 フレームをこのモデルに供給すると、800,000 トークンを消費することになり、これはあらゆるコンシューマー向け GPU のコンテキスト予算をはるかに超えます。SmolVLM2 は、ピクセルシャッフル戦略 を採用しており、384x384 画像パッチを 81 トークンに圧縮します。50 フレームは約 4,050 の画像トークンとなり、単一の推論呼び出しで処理可能な規模になります。この圧縮こそが、SmolVLM2 のプリフィルスループットが Qwen2-VL-2B よりも 3.3〜4.5 倍速く、生成スループットは 7.5〜16 倍速い 理由であり、これはマーケティング上の主張ではなく、トークン予算の違いによる直接的な帰結です。
このモデルは3つのサイズで提供されています。256M と 500M のバリアントはモバイルおよびエッジデバイス向けに設計されており、256M はスマートフォン上で動作可能です。一方、本パイプラインには 2.2B が最適な選択です。これは、信頼性の高いマルチシーン要約を生成するために十分な動画ベンチマークスコアを持つ唯一のサイズであり、Video-MME で 52.1、MLVU で 55.2、MVBench で 46.27 を記録しています。これに対し、500M バリアントはそれぞれ 42.2、47.3、39.73 です。
コードを書く前に、この動画理解のアプローチについても理解しておく価値があります。SmolVLM2 にはネイティブの動画エンコーダーは備わっておらず、動画を画像のシーケンスとして扱います。公式のリファレンスパイプライン は、動画あたり最大 50 枚の均等サンプリングされたフレームを抽出し、内部のフレームリサイズ処理をバイパスして、それらを単一のチャットメッセージ内のマルチ画像シーケンスとして渡します。このアプローチは CinePile で 27.14% のスコアを獲得し、映画制作における動画理解の分野では InternVL2 (2B) と Video-LLaVA (7B) の間に位置しています。これはモデルのサイズや、動画が学習対象の唯一の要素ではなかったことを考慮すると、非常に優れた結果です。
# 環境設定
**
ハードウェア要件:
機能**
最小限
推奨
GPU VRAM
6 GB (RTX 3060)
12–16 GB (RTX 4080)
Apple Silicon
M2 8 GB (MPS パス)
M2 Pro / M3 16 GB
システム RAM
16 GB
32 GB
ディスク
空き容量 10 GB
20 GB+ SSD
Colab
T4 (無料枠)
A100 (Colab Pro)
Python パッケージ:
Python 3.10+ が必要です
python --version
python -m venv smolvlm2-env
source smolvlm2-env/bin/activate # macOS / Linux
smolvlm2-env\Scripts\activate # Windows
SmolVLM-2 の安定版ブランチからインストール -- SmolVLM2 サポートに必須
pip install git+https://github.com/huggingface/transformers@v4.49.0-SmolVLM-2
コア依存ライブラリ
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
pip install \
opencv-python \
Pillow \
numpy \
num2words \
accelerate
Flash Attention 2 for CUDA -- NVIDIA GPU で大幅に高速化
Apple Silicon および CPU ではスキップしてください。CUDA のみ対応です
pip install flash-attn --no-build-isolation
decord -- SmolVLM2 のネイティブ動画入力パスに必須(セクション 5 で使用)
pip install decord
**
注**: num2words パッケージは目立たない依存関係です。SmolVLM2 のプロセッサは、自然言語トレーニングパターンとの一貫性のために数値を単語表現に変換するためにこれを使用します(例:3 → "three")。これは このウォークスルー で説明されています。これを省略すると、プロセッサがロードされた際に目立たないインポートエラーが発生します。
デバイス確認(モデル読み込み前に実行してください):
device_check.py
実行: python device_check.py
import torch
def detect_device():
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
vram = torch.cuda.get_device_properties(0).total_memory / 1e9
print(f"CUDA: {name} ({vram:.1f} GB VRAM)")
return "cuda", torch.bfloat16, "flash_attention_2"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
print("Apple Silicon MPS detected")
return "mps", torch.float16, "eager"
else:
print("CPU fallback (slow -- consider Colab T4)")
return "cpu", torch.float32, "eager"
if __name__ == "__main__":
device, dtype, attn = detect_device()
print(f"Device: {device} | dtype: {dtype} | attn: {attn}")
Run it with:
python device_check.py
# Building the Foundation of the Pipeline
**
Before SmolVLM2 sees anything, you need frames. The frame extractor converts a video file into a list of PIL** (Python Imaging Library) images with timestamps attached, one pair per extracted frame.
異なるユースケースでは、2 つのモードが重要です。均一サンプリングは動画の全期間にわたってフレームを均等に分散させ、コンテンツに関係なくすべての内容を網羅することを保証します。これは、特定のセクションを見逃すことが許されない会議や講義に適した選択です。一方、キーフレームサンプリングは、シーンカット、新しいスライド、または新しい話者など、視覚的コンテンツが大幅に変化する箇所からのみフレームを抽出するため、フレーム数を削減し、注目すべき瞬間に焦点を当てます。これは監視やハイライト抽出により適しています。
frame_extractor.py
Prerequisites: pip install opencv-python Pillow numpy
Usage: from frame_extractor import FrameExtractor
import cv2
import numpy as np
from PIL import Image
class FrameExtractor:
"""
SmolVLM2 の推論のために動画フレームを PIL Images として抽出します。
各抽出されたフレームは、秒単位のタイムスタンプとペアになります。
SmolVLM2 は画像あたり約 81 ビジュアルトークンを使用します。50 フレームの場合、これは約 4,050 のイメージトークンに相当し、コンシューマー GPU 上で VRAM の圧力が生成品質に影響を与える前の実用的な上限です。
"""
MAX_FRAMES = 50
def __init__(self, max_frames: int = MAX_FRAMES):
"""
Args:
max_frames: 抽出されるフレームのハードキャップ。デフォルトの 50 は、
SmolVLM2 の参照パイプラインでテストされた上限と一致します。
"""
self.max_frames = max_frames
def uniform_sample(self, video_path: str) -> list[tuple[float, Image.Image]]:
"""
動画の全期間にわたって均等に配置されたフレームを抽出します。
推奨される用途:会議録画、講義、チュートリアル、コースコンテンツ。
戻り値:
時系列順の (秒単位のタイムスタンプ, PIL_Image) のリスト。
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise IOError(f"動画を開けません: {video_path}")
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
n_extract = min(self.max_frames, total_frames)
# 最初のフレームから最後のフレームまで均等に広がるフレームインデックスを構築
indices = np.linspace(0, total_frames - 1, n_extract, dtype=int)
results = []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
ret, frame = cap.read()
if not ret:
continue
timestamp = round(idx / fps, 2)
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results.append((timestamp, Image.fromarray(rgb)))
cap.release()
return results
def keyframe_sample(
self, video_path: str, diff_threshold: float = 30.0
) -> list[tuple[float, Image.Image]]:
"""
視覚的なコンテンツが著しく変化するフレームを抽出します。
推奨される用途:監視映像、イベント検出、ハイライト抽出。
連続するグレースケールフレーム間の平均絶対画素差を変化信号として使用します。この差分が diff_threshold を超えた場合、新しいキーフレームが記録されます。
Args:
diff_threshold: シーン変更とみなすための平均画素差。
30.0 はほとんどの商用コンテンツで機能します。
値を小さくするとより敏感になり、大きくすると取得されるフレーム数が減ります。
Returns:
時系列順に並べられた (timestamp_seconds, PIL_Image) のリスト。self.max_frames で上限が設定されています。
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise IOError(f"Cannot open video: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
results = []
prev_gray = None
idx = 0
while len(results) < self.max_frames:
ret, frame = cap.read()
if not ret:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is None:
# Always capture the first frame as a baseline
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results.append((round(idx / fps, 2), Image.fromarray(rgb)))
else:
diff = np.mean(np.abs(gray.astype(float) - prev_gray.astype(float)))
if diff > diff_threshold:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results.append((round(idx / fps, 2), Image.fromarray(rgb)))
prev_gray = gray
idx += 1
cap.release()
return results
Start every new video type with uniform_sample. If you find too many redundant frames (five nearly-identical slides in a row), switch to keyframe_sample and tune diff_threshold down from 30 to 20 until the extracted set feels representative without being redundant.
# Loading SmolVLM2 and Running Single-Frame Inference
**
With frames in hand, here is the complete model loading and first-inference pattern. The important details: AutoModelForImageTextToText is the correct class (not the generic AutoModelForCausalLM), and on CUDA, you should enable Flash Attention 2**, which provides meaningful latency improvements on multi-image inputs.
smolvlm2_loader.py
Prerequisites: transformers from v4.49.0-SmolVLM-2 branch, torch, flash-attn (CUDA only)
Run: python smolvlm2_loader.py your_video.mp4
import sys
import torch
from PIL import Image
from transformers import AutoProcessor, AutoModelForImageTextToText
MODEL_ID = "HuggingFaceTB/SmolVLM2-2.2B-Instruct"
def load_model():
"""
SmolVLM2-2.2B およびそのプロセッサを読み込みます。
CUDA 環境では自動的に Flash Attention 2 を選択し、それ以外では eager モードを使用します。
初回実行時には、~/.cache/huggingface/hub ディレクトリに約 4.5 GB の重み(ウェイト)がダウンロードされます。
"""
if torch.cuda.is_available():
dtype = torch.bfloat16
device = "cuda"
attn = "flash_attention_2"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
dtype = torch.float16
device = "mps"
attn = "eager"
else:
dtype = torch.float32
device = "cpu"
attn = "eager"
print(f"Loading {MODEL_ID} on {device}...")
processor = AutoProcessor.from_pretrained(MODEL_ID)
model = AutoModelForImageTextToText.from_pretrained(
MODEL_ID,
torch_dtype=dtype,
_attn_implementation=attn,
).to(device)
model.eval()
print(f"Model ready on {device}")
return model, processor
def describe_frame(
model,
processor,
frame: Image.Image,
prompt: str = "Describe what is happening in this frame in detail. Note any text, people, objects, or actions visible.",
max_new_tokens: int = 256,
) -> str:
"""
単一の PIL Image に対して SmolVLM2 の推論を実行します。
チャットテンプレートは、メッセージ内で画像コンテンツをテキストコンテンツの前に配置することを想定しています。これはトレーニングデータの形式に準拠しており、信頼性の高い出力を得るために重要です。
Args:
frame: FrameExtractor から取得した PIL Image
prompt: このフレームについてモデルに質問する内容
max_new_tokens: トークン単位での最大応答長
Returns:
平文字列としてのモデルの応答
"""
messages = [
{
"role": "user",
"content": [
# 画像をテキストの前に配置 -- SmolVLM2 のトレーニング形式に一致
{"type": "image"},
{"type": "text", "text": prompt},
],
}
]
apply_chat_template はメッセージを整形し、視覚トークンのプレースホルダーを注入します
input_text = processor.apply_chat_template(
messages,
add_generation_prompt=True,
)
inputs = processor(
images=[frame],
text=input_text,
return_tensors="pt",
).to(model.device)
with torch.no_grad():
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False, # 一貫した構造化出力を得るための貪欲デコーディング
)
新たに生成されたトークンのみをデコード -- 入力プロンプトを除去
new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
return processor.decode(new_tokens, skip_special_tokens=True).strip()
── クイック整合性チェック ────────────────────────────────────────────────────────
if __name__ == "__main__":
from frame_extractor import FrameExtractor
if len(sys.argv) < 2:
print("Usage: python smolvlm2_loader.py ")
sys.exit(1)
model, processor = load_model()
extractor = FrameExtractor(max_frames=5)
frames = extractor.uniform_sample(sys.argv[1])
ts, first_frame = frames[0]
print(f"\nDescribing frame at {ts}s...")
description = describe_frame(model, processor, first_frame)
print(f"\n{description}")
実行方法:
python smolvlm2_loader.py your_video.mp4
返される記述は、整合性チェックのためのものです。モデルが最初のフレーム内の可視テキスト、人物、オブジェクト、およびアクションを正しく識別できる場合、パイプラインは正常に動作しています。もし非常に短いか、明らかに間違った応答が得られる場合は、transformers のバージョンが v4.49.0-SmolVLM-2 ブランチのものか確認してください。執筆時点では、安定版の Hugging Face リリースにはまだ SmolVLM2 のサポートが含まれていません。
# 実世界プロジェクトの構築(会議録要約サマライザー)
**
以下が完全なパイプラインです。VideoSummarizer クラスは、フレーム抽出器、モデル、および二段階推論戦略を結びつけます:第一段階では各フレームごとの記述を生成し、第二段階ではそれらの記述を統合して、物語的な要約と抽出されたアクションアイテムを含む構造化 JSON レポートを作成します。
この2段階の設計は意図的なものです。モデルに一度に1枚のフレームだけを記述させることは、焦点を絞った達成可能なタスクであり、正確で具体的な記述を生み出します。一方、30 枚のフレーム記述を統合して一貫した物語へと作り上げることは異なるタスクであり、これは結合された記述を入力として別々の呼び出しで行う方が、1 回のパスで両方を行おうとするよりもうまく処理できます。
video_summarizer.py
前提条件: 同じディレクトリに frame_extractor.py と smolvlm2_loader.py が存在すること
実行方法: python video_summarizer.py meeting_recording.mp4 --output summary.json
import re
import json
import argparse
from dataclasses import dataclass, field
import cv2
import torch
from frame_extractor import FrameExtractor
from smolvlm2_loader import load_model, describe_frame
── データモデル ───────────────────────────────────────────────────────────────
@dataclass
class FrameDescription:
timestamp: float
frame_index: int
description: str
翻訳全文
翻訳全文
見えるものを簡潔かつ完全に記述してください:
- 誰または何が写っているか(人、ホワイトボード、スクリーン、スライド)
- 読み取れるテキスト(スライドのタイトル、ホワイトボードの内容、画面の内容)
- 見られる活動(プレゼンテーション、議論、書き込み、聴講)
必ず JSON 形式で返してください。translation フィールドのみ。他のフィールド (technical_terms など) は一切追加しないこと — 余計なフィールドを書こうとして本文翻訳がトークン上限で打ち切られる事故を防ぐため:
{"translation": "翻訳全文"}
Keep your response to 2-3 sentences.
翻訳全文
- アクションアイテム:会議で言及または示唆された具体的なタスクやフォローアップ事項の箇条書き。各項目はダッシュ(-)から始めてください。
- 重要な瞬間:[MM:SS] 形式のタイムスタンプ付き、最も重要な 3〜5 の瞬間を列挙した箇条書き。
必ず JSON 形式で返してください。translation フィールドのみ。他のフィールド (technical_terms 等) は一切追加しないこと — 余計なフィールドを書こうとして本文翻訳がトークン上限で打ち切られる事故を防ぐため:
{"translation": "翻訳全文"}
各セクションの見出しを明確にして回答してください。
def parse_action_items(text: str) -> list[str]:
"""合成出力から箇条書きのアクションアイテムを抽出する。"""
items = []
for line in text.split("\n"):
stripped = line.strip()
if re.match(r"^[-*•]\s+", stripped) or re.match(r"^\d+\.\s+", stripped):
clean = re.sub(r"^[-*•\d.]+\s*", "", stripped).strip()
if clean and len(clean) > 5:
items.append(clean)
return items
def parse_key_moments(text: str) -> list[dict]:
"""合成出力からタイムスタンプ付きの重要な瞬間を抽出する。"""
moments = []
pattern = re.compile(r"\[(\d{2}:\d{2})\]\s*(.+)")
for match in pattern.finditer(text):
moments.append({
"timestamp_label": match.group(1),
"description": match.group(2).strip()
})
return moments
── メイン要約クラス ─────────────────────────────────────────────────────
class VideoSummarizer:
"""
SmolVLM2-2.2B を使用したエンドツーエンドのローカル動画要約ツール。
二段階戦略:フレームごとの説明と、合成されたナラティブ(物語)。
スキャン画像、デジタルデータ、ライブ録画いずれの動画にも対応可能。
"""
def __init__(self, batch_size: int = 8):
"""
Args:
batch_size: 推論バッチごとに記述するフレーム数。
VRAM に応じて調整してください:8 GB で 8、16 GB で 16。
各フレームは約 81 の視覚トークン(visual tokens)を使用するため、バッチサイズを小さくするとピーク時の VRAM 使用量が減ります。
"""
self.model, self.processor = load_model()
self.extractor = FrameExtractor(max_frames=50)
self.batch_size = batch_size
def _get_duration(self, video_path: str) -> float:
cap = cv2.VideoCapture(video_path)
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
cap.release()
return round(frames / fps, 2)
def summarize(self, video_path: str, mode: str = "uniform") -> VideoSummary:
"""
ビデオファイルを要約します。
Args:
video_path: ビデオファイルへのパス(mp4、avi、mov、mkv)
mode: 均一なカバレッジには "uniform"、シーン変化には "keyframe" を指定
Returns:
フレームごとの記述、ナラティブ、およびアクションアイテムを含む VideoSummary
"""
duration = self._get_duration(video_path)
print(f"Video: {video_path} ({duration:.0f}s)")
# ── Pass 1: フレームの抽出 ─────────────────────────────────────────
if mode == "keyframe":
frames = self.extractor.keyframe_sample(video_path)
else:
frames = self.extractor.uniform_sample(video_path)
print(f"Extracted {len(frames)} frames -- describing in batches of {self.batch_size}...")
# ── Pass 2: Describe each frame ────────────────────────────────────
descriptions: list[FrameDescription] = []
for batch_start in range(0, len(frames), self.batch_size):
batch = frames[batch_start : batch_start + self.batch_size]
for local_idx, (timestamp, img) in enumerate(batch):
global_idx = batch_start + local_idx
print(f" [{global_idx + 1}/{len(frames)}] Describing frame at {timestamp}s...")
desc = describe_frame(
self.model,
self.processor,
img,
prompt=FRAME_PROMPT,
max_new_tokens=128, # Keep frame descriptions concise
)
descriptions.append(FrameDescription(
timestamp=timestamp,
frame_index=global_idx,
description=desc,
))
# ── Pass 3: Synthesis ──────────────────────────────────────────────
print("\nRunning synthesis pass...")
synthesis_prompt = build_synthesis_prompt(descriptions, duration)
synthesis_messages = [
{
"role": "user",
"content": [{"type": "text", "text": synthesis_prompt}],
}
]
synthesis_text_input = self.processor.apply_chat_template(
synthesis_messages,
add_generation_prompt=True,
)
# Synthesis is text-only -- no images in this pass
synthesis_inputs = self.processor(
text=synthesis_text_input,
return_tensors="pt",
).to(self.model.device)
with torch.no_grad():
synthesis_ids = self.model.generate(
**synthesis_inputs,
max_new_tokens=512,
do_sample=False,
)
synthesis_new = synthesis_ids[0][synthesis_inputs["input_ids"].shape[-1]:]
synthesis_output = self.processor.decode(synthesis_new, skip_special_tokens=True).strip()
action_items = parse_action_items(synthesis_output)
key_moments = parse_key_moments(synthesis_output)
return VideoSummary(
video_path=video_path,
duration_seconds=duration,
frames_analyzed=len(descriptions),
frame_descriptions=descriptions,
narrative_summary=synthesis_output,
action_items=action_items,
key_moments=key_moments,
)
def to_json(self, summary: VideoSummary) -> str:
"""VideoSummary をフォーマットされた JSON としてシリアライズする。"""
return json.dumps({
"video": summary.video_path,
"duration_seconds": summary.duration_seconds,
"frames_analyzed": summary.frames_analyzed,
"narrative": summary.narrative_summary,
"action_items": summary.action_items,
"key_moments": summary.key_moments,
"frame_descriptions": [
{
"timestamp": d.timestamp,
"timestamp_label": f"{int(d.timestamp // 60):02d}:{int(d.timestamp % 60):02d}",
"description": d.description,
}
for d in summary.frame_descriptions
],
}, indent=2, ensure_ascii=False)
── エントリーポイント ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="SmolVLM2-2.2B を使用して動画を要約する")
parser.add_argument("video", help="入力動画ファイルへのパス")
parser.add_argument("--output", default="summary.json", help="出力 JSON ファイルのパス")
parser.add_argument("--mode", default="uniform", choices=["uniform", "keyframe"])
parser.add_argument("--batch-size", type=int, default=8)
args = parser.parse_args()
必ず JSON 形式で返してください。translation フィールドのみ。他のフィールド (technical_terms 等) は一切追加しないこと — 余計なフィールドを書こうとして本文翻訳がトークン上限で打ち切られる事故を防ぐため:
{"translation": "翻訳全文"}
summarizer = VideoSummarizer(batch_size=args.batch_size)
result = summarizer.summarize(args.video, mode=args.mode)
output_str = summarizer.to_json(result)
with open(args.output, "w", encoding="utf-8") as f:
f.write(output_str)
print(f"\nSummary saved to {args.output}")
print(f"Frames analyzed: {result.frames_analyzed}")
print(f"Action items found: {len(result.action_items)}")
for item in result.action_items:
print(f" - {item}")
実行方法:
Uniform sampling(均一サンプリング)(デフォルト) -- ミーティングや講義に最適
python video_summarizer.py meeting_2026_06_14.mp4 --output meeting_summary.json
Keyframe sampling (キーフレームサンプリング) -- イベント検出、監視カメラ映像に最適
python video_summarizer.py security_footage.mp4 --mode keyframe --output events.json
VRAM に合わせてバッチサイズを調整(8 GB の VRAM なら 8, 16 GB なら 16)
python video_summarizer.py long_lecture.mp4 --batch-size 4 --output lecture.json
サンプル出力 (summary.json):
動画ファイル:meeting_2026_06_14.mp4
再生時間:3247.0 秒
解析済みフレーム数:50 フレーム
ナラティブ(要約):本会議は第 3 四半期の製品計画に焦点を当てて行われました。
アクションアイテム(実施事項):
- API デザイン文書の完成を 6 月末までに完了させる
- 7 月 1 日にテストスプリントのキックオフをスケジュールする
- ステークホルダーへ更新されたガントチャートを共有する
重要な瞬間(キーモーメント):
- タイムスタンプラベル:00:00 — チーム紹介とアジェンダの概要
- タイムスタンプラベル:12:30 — 画面に表示された API アーキテクチャ図の確認
- タイムスタンプラベル:41:15 — ホワイトボードへのアクションアイテムの要約
# VRAM を考慮したフレームのバッチ処理
VRAM(ビデオメモリ)を考慮してフレームをバッチ処理することは、大規模な動画要約パイプラインにおいて極めて重要です。SmolVLM2-2.2B などの軽量モデルを使用する場合でも、一度に処理するフレーム数(バッチサイズ)を適切に設定しないと、GPU メモリ不足により処理が中断したり、パフォーマンスが著しく低下したりする可能性があります。
最適なバッチサイズを見つけるには、まず対象とするハードウェアの VRAM 容量を確認し、各フレームの解像度やモデルの推論に必要なメモリ量を計算する必要があります。一般的には、VRAM の使用率が 80〜90% を超えない範囲で最大のバッチサイズを選択することが推奨されます。
また、動的なバッチ処理(Dynamic Batching)や、フレームの解像度を自動的に調整する機能を活用することで、より柔軟かつ効率的な処理が可能になります。特に、長時間の動画を扱う場合、メモリ使用量を監視しながらバッチサイズを適応的に変更する手法が有効です。
実装においては、PyTorch や TensorFlow などのフレームワークで提供されるメモリ管理機能を積極的に利用し、必要に応じてキャッシュを解放したり、非同期処理を活用したりすることが重要です。これにより、安定した動作と高速な処理を実現できます。
最終的には、ハードウェアの制約とタスクの要件のバランスを取りながら、VRAM を最大限に活用するバッチ戦略を採用することが、ローカル動画要約パイプラインの成功につながります。
VideoSummarizer におけるバッチサイズは、VRAM バジェット内に収めるための主要な調整項目です。大きすぎるとメモリ不足エラーが発生し、小さすぎると不必要に処理速度が低下します。以下に計算式を示します。
SmolVLM2-2.2B の重みは bfloat16 形式で約 4.5 GB を占有します。各フレームは推論呼び出しにおいて約 81 個の画像トークンを貢献し、2.2B スケールではトークンあたりの KV キャッシュオーバーヘッドは約 0.5 MB です。VRAM の 20% を余裕分として残す場合:
vram_calculator.py
パイプラインを実行する前に GPU に安全なバッチサイズを見積もる
def compute_batch_size(vram_gb: float, tokens_per_frame: int = 81) -> int:
"""
与えられた VRAM バジェットに対して、推論バッチあたりのフレーム数を見積もります。
引数:
vram_gb: GPU で利用可能な VRAM の容量(ギガバイト単位)
tokens_per_frame: 画像あたりの視覚トークン数(SmolVLM2 の場合 81)
戻り値:
安全なバッチサイズ、最小 1、最大 50
"""
MODEL_GB = 4.5 # bfloat16 形式の SmolVLM2-2.2B の重み (weights) サイズ
HEADROOM = 0.80 # 利用可能な VRAM の最大 80% を使用
MB_PER_TOKEN = 0.5 / 1024 # 2.2B スケールにおける KV トークンあたりのメモリ使用量 (GB、概算値)
usable_gb = vram_gb * HEADROOM
inference_budget = max(0.0, usable_gb - MODEL_GB)
frames = int(inference_budget / (tokens_per_frame * MB_PER_TOKEN))
return max(1, min(frames, 50))
if __name__ == "__main__":
for vram in [6.0, 8.0, 12.0, 16.0, 24.0]:
print(f" {vram:.0f} GB VRAM → batch_size = {compute_batch_size(vram)}")
一般的な VRAM のティアに対してこのコードを実行すると、上限の目安がわかります:
6 GB VRAM → バッチサイズ = 16
8 GB VRAM → バッチサイズ = 30
12 GB VRAM → バッチサイズ = 50
16 GB VRAM → バッチサイズ = 50
24 GB VRAM → バッチサイズ = 50
何か失敗した際にゼロからやり直すことが許されないような長編動画の場合、生成されるたびに各フレームの説明を永続化する JSON Lines (JSONL) ストリーミングライターを追加してください:
jsonl_writer.py -- 長編動画処理のためのドロップインチェックポイントサポート
import json
class JSONLWriter:
"""
生成されたフレーム記述を JSONL ファイルに書き込むクラスです。
長時間の動画においてチェックポイントからの再開を可能にします -- 例えば、50 フレーム中 30 フレームで推論が失敗した場合でも、JSONL を再読み込みして既に処理済みのフレームをスキップできます。
"""
def __init__(self, path: str):
self.path = path
self._fh = open(path, "a", encoding="utf-8") # チェックポイントからの再開のためにアペンドモードで開く
def write(self, record: dict):
"""1 つのフレームレコードを書き込み、即座にディスクへフラッシュします。"""
self._fh.write(json.dumps(record, ensure_ascii=False) + "\n")
self._fh.flush()
def already_processed(self) -> set[int]:
"""チェックポイントファイルに含まれる既に処理済みのフレームインデックスのセットを返します。"""
processed = set()
try:
with open(self.path, "r", encoding="utf-8") as f:
for line in f:
record = json.loads(line)
processed.add(record.get("frame_index", -1))
except FileNotFoundError:
pass
return processed
def close(self):
self._fh.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
# パイプラインの拡張(タイムスタンプと JSONL ストリーミング)
このパイプラインからの出力 JSON は既にフレームレベルでタイムスタンプが付与されています。これをより検索可能にするには、1 つの追加が必要です:動画プレイヤーのスクラバーに直接対応する、すべてのフレーム記述におけるクリーンな MM:SS** ラベルです。
出力を動画レビューインターフェースですぐに使用可能にしたい場合は、この後処理ステップを to_json() 関数に追加してください:
def timestamp_label(seconds: float) -> str:
"""小数秒を MM:SS または HH:MM:SS の形式のラベルに変換します。"""
total = int(seconds)
h, remainder = divmod(total, 3600)
m, s = divmod(remainder, 60)
if h > 0:
return f"{h:02d}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
バッチバッファ方式を JSONL(JSON Lines)出力に置き換えることで、データベースや Slack 通知、ドキュメントインデクサーなどの下流システムへストリーミングしたい長尺動画の出力が可能になります。これにより、90 分間の動画処理が完了するのを待たずに、最初のフレームの説明を 30 秒時点で即座に取得できます。
JSONL 書き込み機能と JSONLWriter.already_processed() を組み合わせることで、チェックポイントからの再開(resume-from-checkpoint)を実装できます。例えば、50 フレーム中 35 フレームでパイプラインがクラッシュした場合でも、再起動すると既存のチェックポイントを読み取り、最初の 35 フレームをスキップして 36 フレーム目から処理を継続します。長尺動画においては、ゼロから開始するよりも大幅な時間短縮になります。
# 結論
**
SmolVLM2-2.2B は、能力とサイズのトレードオフ曲線上において、実際に有用な位置に存在します。単一のコンシューマー向け GPU で実行できるほど小さく、かつ実務ワークフローで実際に役立つ動画要約を生成するのに十分な能力を持っています。フレームを画像として扱うアプローチにより、実装はクリーンに保たれています:特殊なビデオエンコーダーも、カスタムアテンションの実装も不要です。標準的な transformers API を使用し、PIL 形式の画像を入力とするだけです。
本記事で取り上げている会議要約ツールはテンプレートとして機能します。FRAME_PROMPT をあなたのドメイン向けに調整したプロンプトに置き換え、build_synthesis_prompt() 関数を変更して、ユースケースにとって重要な構造化フィールドを抽出すれば、同じパイプラインが講義の録画、セキュリティ映像、製品デモのウォークスルー、スポーツハイライトなどにも適用可能です。フレームごとの記述をまず行い、その後に要約を行うという 2 パスのパターンは、モデルが個々のフレームを正確に記述し、記述間での統合も信頼性高く行うため、これらすべてのケースで通用します。
50 フレームという制限は出発点であり、上限ではありません。VRAM の容量が大きいハードウェアでは、max_frames を 75 または 100 に増やして実験を行ってください。品質はフレームのカバレッジがある程度まで比例して向上し、要約パスでもより多くの素材を処理できるため、その恩恵を受けます。
**
Shittu Olumide は、最先端の技術を活用して説得力のある物語を構築することに情熱を注ぐソフトウェアエンジニアでありテクニカルライターです。細部への鋭い眼差しと複雑な概念を簡潔に説明する才能を持っています。また、Shittu は Twitter でも活動しています。
原文を表示

**
# Introduction
Most video understanding tools fall into one of two camps. The first camp requires a cloud API; your footage is uploaded, processed on someone else's servers, and billed per minute of video. The second camp runs locally but demands the kind of GPU cluster most developers do not have: 70B+ models that need multiple A100s and take minutes per clip. Neither option works for a developer who wants to process a day's worth of meeting recordings, a lecture series, or security footage on a workstation they already own.
SmolVLM2-2.2B-Instruct, released by Hugging Face on February 20, 2025, changes the calculation. It runs on 5.2 GB of GPU RAM, an RTX 3060, a MacBook Pro M2, and the free Google Colab T4 tier. On Video-MME**, the standard long-form video understanding benchmark, it outperforms every existing 2B-scale model. That combination, consumer hardware paired with results that actually hold up, is what this article is built around.
The project we will build in this article: a local pipeline that takes any video file, extracts frames at configurable intervals, analyzes them in batches with SmolVLM2-2.2B, and outputs a structured JSON summary, including per-frame scene descriptions, key moments with timestamps, action items, and a final narrative. The same pipeline handles meeting recordings, lectures, and surveillance footage without changing a line of code.
**
The reason SmolVLM2-2.2B can run on an RTX 3060 while outperforming larger models on video tasks is a design decision about how images are tokenized.
Most vision-language models tokenize images at high density. Qwen2-VL, for example, uses up to 16,000 tokens to represent a single image. Feeding 50 frames to such a model at that density would consume 800,000 tokens, far beyond any consumer GPU's context budget. SmolVLM2 uses a pixel shuffle strategy** that compresses each 384x384 image patch to 81 tokens. Fifty frames become approximately 4,050 image tokens, manageable in a single inference call. That compression is why SmolVLM2's prefill throughput runs 3.3 to 4.5 times faster and generation throughput runs 7.5 to 16 times faster than Qwen2-VL-2B, not as a marketing claim but as a direct consequence of the token budget difference.
The model comes in three sizes. The 256M and 500M variants are designed for mobile and edge devices; the 256M can run on a phone. The 2.2B is the right choice for this pipeline. It is the only size with strong enough video benchmark scores to produce reliable multi-scene summaries: Video-MME of 52.1, MLVU of 55.2, and MVBench of 46.27, against the 500M's 42.2, 47.3, and 39.73, respectively.
The video understanding approach is also worth understanding before you write any code. SmolVLM2 does not have a native video encoder; it treats video as a sequence of images. The official reference pipeline extracts up to 50 evenly sampled frames per video, bypasses internal frame resizing, and passes them as a multi-image sequence inside a single chat message. That approach scored 27.14% on CinePile, positioning it between InternVL2 (2B) and Video-LLaVA (7B) on cinematic video understanding, a strong result given the model's size and that video was not the only thing it was trained for.
# Setting Up the Environment
**
Hardware requirements:
Feature**
Minimum
Recommended
GPU VRAM
6 GB (RTX 3060)
12–16 GB (RTX 4080)
Apple Silicon
M2 8 GB (MPS path)
M2 Pro / M3 16 GB
System RAM
16 GB
32 GB
Disk
10 GB free
20 GB+ SSD
Colab
T4 (free tier)
A100 (Colab Pro)
Python packages:
# Python 3.10+ required
python --version
python -m venv smolvlm2-env
source smolvlm2-env/bin/activate # macOS / Linux
smolvlm2-env\Scripts\activate # Windows
# Install from the stable SmolVLM-2 branch -- required for SmolVLM2 support
pip install git+https://github.com/huggingface/transformers@v4.49.0-SmolVLM-2
# Core dependencies
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
pip install \
opencv-python \
Pillow \
numpy \
num2words \
accelerate
# Flash Attention 2 for CUDA -- significantly faster on NVIDIA GPUs
# Skip this on Apple Silicon and CPU -- it is CUDA-only
pip install flash-attn --no-build-isolation
# decord -- required for SmolVLM2's native video input path (used in Section 5)
pip install decordNote: The num2words package is a non-obvious dependency. SmolVLM2's processor uses it to convert numeric digits to word representations (e.g. 3 → "three") for consistency with natural language training patterns, as explained in this walkthrough. Omitting it causes a silent import error when the processor loads.
Device check (run this before loading the model):
# device_check.py
# Run: python device_check.py
import torch
def detect_device():
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
vram = torch.cuda.get_device_properties(0).total_memory / 1e9
print(f"CUDA: {name} ({vram:.1f} GB VRAM)")
return "cuda", torch.bfloat16, "flash_attention_2"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
print("Apple Silicon MPS detected")
return "mps", torch.float16, "eager"
else:
print("CPU fallback (slow -- consider Colab T4)")
return "cpu", torch.float32, "eager"
if __name__ == "__main__":
device, dtype, attn = detect_device()
print(f"Device: {device} | dtype: {dtype} | attn: {attn}")Run it with:
python device_check.py# Building the Foundation of the Pipeline
**
Before SmolVLM2 sees anything, you need frames. The frame extractor converts a video file into a list of PIL** (Python Imaging Library) images with timestamps attached, one pair per extracted frame.
Two modes matter for different use cases. Uniform sampling distributes frames evenly across the full video duration, guaranteeing coverage of everything regardless of content. This is the right choice for meetings and lectures where you cannot afford to miss a section. Keyframe sampling extracts frames only where the visual content changes significantly, such as scene cuts, a new slide, or a new speaker, which reduces the frame count and focuses attention on distinct moments. This is better for surveillance and highlight extraction.
# frame_extractor.py
# Prerequisites: pip install opencv-python Pillow numpy
# Usage: from frame_extractor import FrameExtractor
import cv2
import numpy as np
from PIL import Image
class FrameExtractor:
"""
Extracts video frames as PIL Images for SmolVLM2 inference.
Each extracted frame is paired with its timestamp in seconds.
SmolVLM2 uses ~81 visual tokens per image. At 50 frames that is
roughly 4,050 image tokens -- the practical upper limit before VRAM
pressure affects generation quality on consumer GPUs.
"""
MAX_FRAMES = 50
def __init__(self, max_frames: int = MAX_FRAMES):
"""
Args:
max_frames: Hard cap on extracted frames. Default 50 matches
the SmolVLM2 reference pipeline's tested upper limit.
"""
self.max_frames = max_frames
def uniform_sample(self, video_path: str) -> list[tuple[float, Image.Image]]:
"""
Extract evenly spaced frames across the full video duration.
Best for: meeting recordings, lectures, tutorials, course content.
Returns:
List of (timestamp_seconds, PIL_Image) in chronological order.
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise IOError(f"Cannot open video: {video_path}")
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
n_extract = min(self.max_frames, total_frames)
# Build frame indices spread evenly from first to last frame
indices = np.linspace(0, total_frames - 1, n_extract, dtype=int)
results = []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
ret, frame = cap.read()
if not ret:
continue
timestamp = round(idx / fps, 2)
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results.append((timestamp, Image.fromarray(rgb)))
cap.release()
return results
def keyframe_sample(
self, video_path: str, diff_threshold: float = 30.0
) -> list[tuple[float, Image.Image]]:
"""
Extract frames where visual content changes significantly.
Best for: surveillance, event detection, highlight extraction.
Uses mean absolute pixel difference between consecutive grayscale frames
as the change signal. When the diff exceeds diff_threshold, a new
keyframe is recorded.
Args:
diff_threshold: Mean pixel difference to treat as a scene change.
30.0 works for most commercial content.
Lower = more sensitive, higher = fewer frames.
Returns:
List of (timestamp_seconds, PIL_Image) in chronological order,
capped at self.max_frames.
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise IOError(f"Cannot open video: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
results = []
prev_gray = None
idx = 0
while len(results) < self.max_frames:
ret, frame = cap.read()
if not ret:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is None:
# Always capture the first frame as a baseline
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results.append((round(idx / fps, 2), Image.fromarray(rgb)))
else:
diff = np.mean(np.abs(gray.astype(float) - prev_gray.astype(float)))
if diff > diff_threshold:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results.append((round(idx / fps, 2), Image.fromarray(rgb)))
prev_gray = gray
idx += 1
cap.release()
return resultsStart every new video type with uniform_sample. If you find too many redundant frames (five nearly-identical slides in a row), switch to keyframe_sample and tune diff_threshold down from 30 to 20 until the extracted set feels representative without being redundant.
# Loading SmolVLM2 and Running Single-Frame Inference
**
With frames in hand, here is the complete model loading and first-inference pattern. The important details: AutoModelForImageTextToText is the correct class (not the generic AutoModelForCausalLM), and on CUDA, you should enable Flash Attention 2**, which provides meaningful latency improvements on multi-image inputs.
# smolvlm2_loader.py
# Prerequisites: transformers from v4.49.0-SmolVLM-2 branch, torch, flash-attn (CUDA only)
# Run: python smolvlm2_loader.py your_video.mp4
import sys
import torch
from PIL import Image
from transformers import AutoProcessor, AutoModelForImageTextToText
MODEL_ID = "HuggingFaceTB/SmolVLM2-2.2B-Instruct"
def load_model():
"""
Load SmolVLM2-2.2B and its processor.
Automatically selects Flash Attention 2 on CUDA, eager mode elsewhere.
First run downloads ~4.5 GB of weights to ~/.cache/huggingface/hub.
"""
if torch.cuda.is_available():
dtype = torch.bfloat16
device = "cuda"
attn = "flash_attention_2"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
dtype = torch.float16
device = "mps"
attn = "eager"
else:
dtype = torch.float32
device = "cpu"
attn = "eager"
print(f"Loading {MODEL_ID} on {device}...")
processor = AutoProcessor.from_pretrained(MODEL_ID)
model = AutoModelForImageTextToText.from_pretrained(
MODEL_ID,
torch_dtype=dtype,
_attn_implementation=attn,
).to(device)
model.eval()
print(f"Model ready on {device}")
return model, processor
def describe_frame(
model,
processor,
frame: Image.Image,
prompt: str = "Describe what is happening in this frame in detail. Note any text, people, objects, or actions visible.",
max_new_tokens: int = 256,
) -> str:
"""
Run SmolVLM2 inference on a single PIL Image.
The chat template expects image content before text content in the
message -- this mirrors the training data format and is important
for reliable output.
Args:
frame: A PIL Image (from FrameExtractor)
prompt: What to ask the model about this frame
max_new_tokens: Maximum response length in tokens
Returns:
Model response as a plain string
"""
messages = [
{
"role": "user",
"content": [
# Image placed before text -- matches SmolVLM2 training format
{"type": "image"},
{"type": "text", "text": prompt},
],
}
]
# apply_chat_template formats the message and injects visual token placeholders
input_text = processor.apply_chat_template(
messages,
add_generation_prompt=True,
)
inputs = processor(
images=[frame],
text=input_text,
return_tensors="pt",
).to(model.device)
with torch.no_grad():
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False, # Greedy decoding for consistent structured output
)
# Decode only the newly generated tokens -- strip the input prompt
new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
return processor.decode(new_tokens, skip_special_tokens=True).strip()
# ── Quick sanity check ────────────────────────────────────────────────────────
if __name__ == "__main__":
from frame_extractor import FrameExtractor
if len(sys.argv) < 2:
print("Usage: python smolvlm2_loader.py ")
sys.exit(1)
model, processor = load_model()
extractor = FrameExtractor(max_frames=5)
frames = extractor.uniform_sample(sys.argv[1])
ts, first_frame = frames[0]
print(f"\nDescribing frame at {ts}s...")
description = describe_frame(model, processor, first_frame)
print(f"\n{description}")How to run:
python smolvlm2_loader.py your_video.mp4The description you get back is your sanity check. If the model correctly identifies visible text, people, objects, and actions in the first frame, the pipeline is working. If you get a very short or obviously wrong response, verify that the transformers version is from the v4.49.0-SmolVLM-2 branch; the stable Hugging Face release does not yet include SmolVLM2 support at the time of writing.
# Building the Real-World Project (Meeting Recording Summarizer)
**
Here is the full pipeline. The VideoSummarizer class ties together the frame extractor, the model, and a two-pass inference strategy: the first pass generates per-frame descriptions, and the second pass synthesizes those descriptions into a structured JSON report with a narrative summary and extracted action items.
The two-pass design is deliberate. Asking the model to describe a single frame at a time is a focused, achievable task; it produces accurate, concrete descriptions. Asking it to synthesize 30 frame descriptions into a coherent narrative is a different task, and it handles that better as a separate call with the concatenated descriptions as input than if you tried to do both in one pass.
# video_summarizer.py
# Prerequisites: frame_extractor.py and smolvlm2_loader.py in the same directory
# Run: python video_summarizer.py meeting_recording.mp4 --output summary.json
import re
import json
import argparse
from dataclasses import dataclass, field
import cv2
import torch
from frame_extractor import FrameExtractor
from smolvlm2_loader import load_model, describe_frame
# ── Data models ───────────────────────────────────────────────────────────────
@dataclass
class FrameDescription:
timestamp: float
frame_index: int
description: str
@dataclass
class VideoSummary:
video_path: str
duration_seconds: float
frames_analyzed: int
frame_descriptions: list[FrameDescription]
narrative_summary: str
action_items: list[str] = field(default_factory=list)
key_moments: list[dict] = field(default_factory=list)
# ── Per-frame prompt ──────────────────────────────────────────────────────────
FRAME_PROMPT = """You are analyzing a frame from a recorded meeting.
Describe what you see concisely but completely:
- Who or what is visible (people, whiteboards, screens, slides)
- Any readable text (slide titles, whiteboard content, screen content)
- The apparent activity (presenting, discussing, writing, listening)
Keep your response to 2-3 sentences."""
# ── Synthesis prompt ──────────────────────────────────────────────────────────
def build_synthesis_prompt(descriptions: list[FrameDescription], duration: float) -> str:
"""Build the second-pass prompt that synthesizes frame descriptions into a report."""
frames_text = "\n".join(
f"[{int(d.timestamp // 60):02d}:{int(d.timestamp % 60):02d}] {d.description}"
for d in descriptions
)
return f"""Below are time-stamped descriptions of frames from a {duration:.0f}-second meeting recording.
{frames_text}
Based on these descriptions, provide:
1. NARRATIVE SUMMARY: A 3-5 sentence summary of what the meeting covered, who participated (if visible), and what decisions or conclusions were reached.
2. ACTION ITEMS: A bullet list of concrete tasks or follow-ups mentioned or implied in the meeting. Start each with a dash (-).
3. KEY MOMENTS: A bullet list of the 3-5 most significant moments with their timestamps in [MM:SS] format.
Format your response with clear headings for each section."""
# ── Output parser ─────────────────────────────────────────────────────────────
def parse_action_items(text: str) -> list[str]:
"""Extract bullet-point action items from the synthesis output."""
items = []
for line in text.split("\n"):
stripped = line.strip()
if re.match(r"^[-*•]\s+", stripped) or re.match(r"^\d+\.\s+", stripped):
clean = re.sub(r"^[-*•\d.]+\s*", "", stripped).strip()
if clean and len(clean) > 5:
items.append(clean)
return items
def parse_key_moments(text: str) -> list[dict]:
"""Extract key moments with timestamps from the synthesis output."""
moments = []
pattern = re.compile(r"\[(\d{2}:\d{2})\]\s*(.+)")
for match in pattern.finditer(text):
moments.append({
"timestamp_label": match.group(1),
"description": match.group(2).strip()
})
return moments
# ── Main summarizer class ─────────────────────────────────────────────────────
class VideoSummarizer:
"""
End-to-end local video summarizer using SmolVLM2-2.2B.
Two-pass strategy: per-frame descriptions + synthesis narrative.
Works on scanned, digital, and live-recorded videos alike.
"""
def __init__(self, batch_size: int = 8):
"""
Args:
batch_size: Frames to describe per inference batch.
Tune based on VRAM: 8 for 8 GB, 16 for 16 GB.
Each frame uses ~81 visual tokens; lower batch = less peak VRAM.
"""
self.model, self.processor = load_model()
self.extractor = FrameExtractor(max_frames=50)
self.batch_size = batch_size
def _get_duration(self, video_path: str) -> float:
cap = cv2.VideoCapture(video_path)
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
cap.release()
return round(frames / fps, 2)
def summarize(self, video_path: str, mode: str = "uniform") -> VideoSummary:
"""
Summarize a video file.
Args:
video_path: Path to the video file (mp4, avi, mov, mkv)
mode: "uniform" for even coverage, "keyframe" for scene changes
Returns:
VideoSummary with per-frame descriptions, narrative, and action items
"""
duration = self._get_duration(video_path)
print(f"Video: {video_path} ({duration:.0f}s)")
# ── Pass 1: Extract frames ─────────────────────────────────────────
if mode == "keyframe":
frames = self.extractor.keyframe_sample(video_path)
else:
frames = self.extractor.uniform_sample(video_path)
print(f"Extracted {len(frames)} frames -- describing in batches of {self.batch_size}...")
# ── Pass 2: Describe each frame ────────────────────────────────────
descriptions: list[FrameDescription] = []
for batch_start in range(0, len(frames), self.batch_size):
batch = frames[batch_start : batch_start + self.batch_size]
for local_idx, (timestamp, img) in enumerate(batch):
global_idx = batch_start + local_idx
print(f" [{global_idx + 1}/{len(frames)}] Describing frame at {timestamp}s...")
desc = describe_frame(
self.model,
self.processor,
img,
prompt=FRAME_PROMPT,
max_new_tokens=128, # Keep frame descriptions concise
)
descriptions.append(FrameDescription(
timestamp=timestamp,
frame_index=global_idx,
description=desc,
))
# ── Pass 3: Synthesis ──────────────────────────────────────────────
print("\nRunning synthesis pass...")
synthesis_prompt = build_synthesis_prompt(descriptions, duration)
synthesis_messages = [
{
"role": "user",
"content": [{"type": "text", "text": synthesis_prompt}],
}
]
synthesis_text_input = self.processor.apply_chat_template(
synthesis_messages,
add_generation_prompt=True,
)
# Synthesis is text-only -- no images in this pass
synthesis_inputs = self.processor(
text=synthesis_text_input,
return_tensors="pt",
).to(self.model.device)
with torch.no_grad():
synthesis_ids = self.model.generate(
**synthesis_inputs,
max_new_tokens=512,
do_sample=False,
)
synthesis_new = synthesis_ids[0][synthesis_inputs["input_ids"].shape[-1]:]
synthesis_output = self.processor.decode(synthesis_new, skip_special_tokens=True).strip()
action_items = parse_action_items(synthesis_output)
key_moments = parse_key_moments(synthesis_output)
return VideoSummary(
video_path=video_path,
duration_seconds=duration,
frames_analyzed=len(descriptions),
frame_descriptions=descriptions,
narrative_summary=synthesis_output,
action_items=action_items,
key_moments=key_moments,
)
def to_json(self, summary: VideoSummary) -> str:
"""Serialize a VideoSummary to formatted JSON."""
return json.dumps({
"video": summary.video_path,
"duration_seconds": summary.duration_seconds,
"frames_analyzed": summary.frames_analyzed,
"narrative": summary.narrative_summary,
"action_items": summary.action_items,
"key_moments": summary.key_moments,
"frame_descriptions": [
{
"timestamp": d.timestamp,
"timestamp_label": f"{int(d.timestamp // 60):02d}:{int(d.timestamp % 60):02d}",
"description": d.description,
}
for d in summary.frame_descriptions
],
}, indent=2, ensure_ascii=False)
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Summarize a video with SmolVLM2-2.2B")
parser.add_argument("video", help="Path to the input video file")
parser.add_argument("--output", default="summary.json", help="Output JSON file path")
parser.add_argument("--mode", default="uniform", choices=["uniform", "keyframe"])
parser.add_argument("--batch-size", type=int, default=8)
args = parser.parse_args()
summarizer = VideoSummarizer(batch_size=args.batch_size)
result = summarizer.summarize(args.video, mode=args.mode)
output_str = summarizer.to_json(result)
with open(args.output, "w", encoding="utf-8") as f:
f.write(output_str)
print(f"\nSummary saved to {args.output}")
print(f"Frames analyzed: {result.frames_analyzed}")
print(f"Action items found: {len(result.action_items)}")
for item in result.action_items:
print(f" - {item}")How to run:
# Uniform sampling (default) -- best for meetings and lectures
python video_summarizer.py meeting_2026_06_14.mp4 --output meeting_summary.json
# Keyframe sampling -- best for event detection, surveillance
python video_summarizer.py security_footage.mp4 --mode keyframe --output events.json
# Adjust batch size for your VRAM (8 for 8 GB VRAM, 16 for 16 GB)
python video_summarizer.py long_lecture.mp4 --batch-size 4 --output lecture.jsonSample output (summary.json):
{
"video": "meeting_2026_06_14.mp4",
"duration_seconds": 3247.0,
"frames_analyzed": 50,
"narrative": "The meeting focused on Q3 product planning ...",
"action_items": [
"Finalize API design document by end of June",
"Schedule testing sprint kickoff for July 1",
"Share updated Gantt chart with stakeholders"
],
"key_moments": [
{"timestamp_label": "00:00", "description": "Team introductions and agenda overview"},
{"timestamp_label": "12:30", "description": "API architecture diagram reviewed on screen"},
{"timestamp_label": "41:15", "description": "Action items summarized on whiteboard"}
]
}# Batching Frames with VRAM Awareness
The batch size in VideoSummarizer is the primary knob for staying within your VRAM budget. Too large and you hit out-of-memory errors. Too small and you slow down unnecessarily. Here is the calculation:
SmolVLM2-2.2B weights occupy roughly 4.5 GB in bfloat16. Each frame contributes approximately 81 image tokens to the inference call, and at 2.2B scale, KV cache overhead is approximately 0.5 MB per token. Leaving 20% VRAM as headroom:
# vram_calculator.py
# Estimate a safe batch size for your GPU before running the pipeline
def compute_batch_size(vram_gb: float, tokens_per_frame: int = 81) -> int:
"""
Estimate frames per inference batch for a given VRAM budget.
Args:
vram_gb: Available GPU VRAM in gigabytes
tokens_per_frame: Visual tokens per image (81 for SmolVLM2)
Returns:
Safe batch size, minimum 1, maximum 50
"""
MODEL_GB = 4.5 # SmolVLM2-2.2B weights in bfloat16
HEADROOM = 0.80 # Use at most 80% of total VRAM
MB_PER_TOKEN = 0.5 / 1024 # GB per KV token at 2.2B scale (rough)
usable_gb = vram_gb * HEADROOM
inference_budget = max(0.0, usable_gb - MODEL_GB)
frames = int(inference_budget / (tokens_per_frame * MB_PER_TOKEN))
return max(1, min(frames, 50))
if __name__ == "__main__":
for vram in [6.0, 8.0, 12.0, 16.0, 24.0]:
print(f" {vram:.0f} GB VRAM → batch_size = {compute_batch_size(vram)}")Running this against a few common VRAM tiers gives a sense of the ceiling:
6 GB VRAM → batch_size = 16
8 GB VRAM → batch_size = 30
12 GB VRAM → batch_size = 50
16 GB VRAM → batch_size = 50
24 GB VRAM → batch_size = 50For long videos where you cannot afford to restart from zero if something fails, add a JSON Lines (JSONL) streaming writer that persists each frame's description as it is generated:
# jsonl_writer.py -- drop-in checkpoint support for long-video processing
import json
class JSONLWriter:
"""
Writes frame descriptions to a JSONL file as they are produced.
Enables resume-from-checkpoint on long videos -- if inference fails at
frame 30 of 50, re-read the JSONL and skip already-processed frames.
"""
def __init__(self, path: str):
self.path = path
self._fh = open(path, "a", encoding="utf-8") # Append mode for resume
def write(self, record: dict):
"""Write one frame record and flush immediately to disk."""
self._fh.write(json.dumps(record, ensure_ascii=False) + "\n")
self._fh.flush()
def already_processed(self) -> set[int]:
"""Return the set of frame indices already in the checkpoint file."""
processed = set()
try:
with open(self.path, "r", encoding="utf-8") as f:
for line in f:
record = json.loads(line)
processed.add(record.get("frame_index", -1))
except FileNotFoundError:
pass
return processed
def close(self):
self._fh.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()# Extending the Pipeline (Timestamps and JSONL Streaming)
The output JSON from this pipeline is already timestamped at the frame level. Making it more searchable requires one addition: a clean MM:SS** label on every frame description that maps directly to the video player's scrubber.
Add this post-processing step to to_json() if you want the output to be directly usable in a video review interface:
def timestamp_label(seconds: float) -> str:
"""Convert decimal seconds to MM:SS or HH:MM:SS label."""
total = int(seconds)
h, remainder = divmod(total, 3600)
m, s = divmod(remainder, 60)
if h > 0:
return f"{h:02d}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"For long-form video output that you want to stream into a downstream system (a database, a Slack notification, a document indexer), replace the batch buffer approach with a JSONL output where each line is one frame's record. That means the first frame's description is available 30 seconds into a 90-minute video's processing, rather than waiting for the full pipeline to complete before writing anything.
Pair the JSONL writer with JSONLWriter.already_processed() to implement resume-from-checkpoint: if the pipeline crashes at frame 35 of 50, restart it and it will read the existing checkpoint, skip the first 35 frames, and continue from frame 36. On long videos, this saves significant time over starting from zero.
# Conclusion
**
SmolVLM2-2.2B sits at a genuinely useful point on the capability-size trade-off curve. Small enough to run on a single consumer GPU, capable enough to produce video summaries that are actually useful for real workflows. The frame-as-image approach keeps the implementation clean: no exotic video encoders, no custom attention implementations, just the standard transformers API with PIL images as input.
The meeting summarizer in this article is the template. Replace FRAME_PROMPT with a prompt tuned to your domain, change build_synthesis_prompt() to extract whatever structured fields matter for your use case, and the same pipeline works for lecture recordings, security footage, product demo walkthroughs, or sports highlights. The two-pass pattern, per-frame description first and synthesis second, holds across all of those because the model describes individual frames accurately and synthesizes across descriptions reliably.
The 50-frame limit is a starting point, not a ceiling. On higher-VRAM hardware, increase max_frames to 75 or 100 and experiment. Quality scales with frame coverage up to a point, and your synthesis pass benefits from more material to work with.
Shittu Olumide** is a software engineer and technical writer passionate about leveraging cutting-edge technologies to craft compelling narratives, with a keen eye for detail and a knack for simplifying complex concepts. You can also find Shittu on Twitter.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み