SmolVLM2-2.2B を用いたフレーム処理によるローカル動画要約パイプライン
本文の状態
日本語全文を表示中
詳細モードで約23分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
KDnuggets
Hugging Face が発表した SmolVLM2-2.2B は、低消費電力の GPU でも動作しながら大規模モデルに匹敵する性能を発揮し、ローカル環境での動画要約パイプライン構築を可能にする技術的進展である。
AI深層分析を開く2026年8月6日 18:14
AI深層分析
キーポイント
消費者ハードウェアでの実行可能性
SmolVLM2-2.2B は 5.2 GB の GPU メモリで動作し、RTX 3060 や MacBook Pro M2 といった一般的なワークステーションや Google Colab の無料 T4 タイアでも利用可能である。
ベンチマークにおける高性能
動画理解の標準的長尺評価指標 Video-MME において、同規模(2B スケール)の既存モデルすべてを上回る性能を示している。
トークン化戦略による効率化
画像を高密度にトークン化する従来の手法に対し、384x384 の画像パッチを 81 トークンに圧縮するピクセルシャッフル戦略を採用し、コンテキスト予算の制約を克服している。
汎用的なローカルパイプライン
動画ファイルからフレームを抽出し、バッチ処理で分析して構造化 JSON 形式の要約(シーン説明、タイムスタンプ付きキーポイント、アクションアイテム)を出力する一貫したワークフローを提供する。
SmolVLM2-2.2Bの高速化とコンテキスト効率
ピクセルシャッフル戦略により画像パッチを圧縮し、50フレームでも約4,050トークンに抑えることで、大規模モデルより大幅な推論速度向上を実現する。
重要な引用
SmolVLM2-2.2B-Instruct, released by Hugging Face on February 20, 2025, changes the calculation.
On Video-MME, the standard long-form video understanding benchmark, it outperforms every existing 2B-scale model.
SmolVLM2 uses a pixel shuffle strategy that compresses each 384x384 image patch to 81 tokens.
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
編集コメントを表示
編集コメント
この技術は、動画分析の民主化を加速させる重要な一歩である。開発者は高価なインフラ投資なしで、自社のワークステーション上で高度な AI 機能を試行錯誤できるようになるため、実装のスピードが劇的に向上するだろう。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。

導入
現在の動画理解ツールの多くは、大きく分けて二つのカテゴリに分類されます。一つ目はクラウド API に依存するタイプで、動画をアップロードして他社のサーバーで処理し、動画の長さに応じて課金される仕組みです。二つ目はローカル環境で動作しますが、70B 以上の大規模モデルを複数台の A100 GPU で動かす必要があり、クリップあたりの処理に数分かかるような、開発者が通常備えていないほどの高性能な GPU クラスターを要求するタイプです。
どちらも、すでに所有しているワークステーションで、一日分の会議録画や講義シリーズ、あるいは監視映像を処理したい開発者にとっては現実的な選択肢ではありません。
2025 年 2 月 20 日に Hugging Face がリリースした SmolVLM2-2.2B-Instruct は、この状況を劇的に変えるものです。このモデルは、GPU メモリ 5.2 GB の RTX 3060 や MacBook Pro M2、そして無料の Google Colab T4 タイアでも動作します。標準的な長尺動画理解ベンチマークである Video-MME では、既存の 2B パラメータ規模のモデルすべてを上回る性能を発揮しました。この記事は、コンシューマー向けハードウェアと、実際に信頼できる結果という組み合わせを軸に構成されています。
この記事で構築するプロジェクトは、任意の動画ファイルをローカル環境で処理し、設定可能な間隔でフレームを抽出した上で、SmolVLM2-2.2B を用いてバッチ分析を行うパイプラインです。出力されるのは構造化された JSON で、各フレームのシーン説明、タイムスタンプ付きの重要な瞬間、アクションアイテム、そして最終的な要約ナレーションが含まれます。このパイプラインは、コードを一切変更することなく、会議の録画や講義資料、監視映像などあらゆる動画に対応します。
SmolVLM2-2.2B が RTX 3060 のようなコンシューマー向け GPU で動作しながら、より大規模なモデルを上回る性能を発揮できる理由は、画像をトークン化する際の設計思想にあります。
多くのビジョン・ランゲージモデルは高密度な方法で画像をトークン化します。例えば 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) の間に位置づけられました。動画が学習対象の唯一ではないことやモデルサイズの小ささを考慮すると、映画作品の理解においてこの結果は非常に優秀です。
# 環境構築
**
必要なハードウェア:
</article>
| 機能 | 最小要件 | 推奨要件 |
|---|---|---|
| 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+ 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 decord注意:
num2wordsパッケージは、一見すると必須に見えない依存関係です。SmolVLM2 のプロセッサはこのパッケージを使用して、数値を単語表記に変換します(例:3 → "three")。これは自然言語でのトレーニングパターンとの整合性を保つために行われます。詳細は こちらのウォークスルー で解説されています。このパッケージを省略すると、プロセッサの読み込み時に静かなインポートエラーが発生します。
モデルを読み込む前に実行するデバイス確認:
# 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}")以下のコマンドで実行してください:
python device_check.pyパイプラインの基盤構築
SmolVLM2 が映像を処理する前に、まずフレームを用意する必要があります。フレーム抽出器は動画ファイルを、タイムスタンプ付きの PIL(Python Imaging Library)画像リストに変換します。抽出された各フレームごとに、1 組の画像と時刻情報が紐付けられます。
用途に応じて重要なモードが 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:
"""
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 results新しい動画タイプでは、まず uniform_sample を使用してください。連続するスライドが 5 枚以上もほぼ同一である場合、keyframe_sample に切り替え、抽出されたセットが冗長にならずに代表性を保てるよう、diff_threshold の値を 30 から 20 まで下げて調整します。
SmolVLM2 の読み込みと単一フレーム推論の実行
フレームを準備したら、モデルの読み込みと最初の推論を行うための完全なパターンを示します。重要なポイントは以下の通りです。
AutoModelForImageTextToText が正しいクラスであり、汎用的な AutoModelForCausalLM ではありません。CUDA 環境では Flash Attention 2 を有効にしてください。これにより、複数画像の入力に対して遅延を大幅に削減できます。
# 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}")実行方法:
python smolvlm2_loader.py your_video.mp4返された説明は、システムが正常に動作しているかを確認するためのチェックです。最初のフレームでモデルが見えるテキスト、人物、物体、行動を正しく識別できていれば、パイプラインは機能しています。もし回答が極端に短かったり、明らかに間違っている場合は、transformers のバージョンが v4.49.0-SmolVLM-2 ブランチからのものであるか確認してください。執筆時点では、安定版の Hugging Face リリースにはまだ SmolVLM2 のサポートが含まれていません。
# 実世界でのプロジェクト構築(会議録音要約ツール)
以下に、完全なパイプラインの概要を示します。VideoSummarizer クラスは、フレーム抽出器、モデル、そして 2 段階推論戦略を統合しています。1 段目で各フレームの説明を生成し、2 段目ではそれらの説明を構造化された JSON レポートにまとめ、物語的な要約と抽出されたアクションアイテムを作成します。
この 2 段階設計には明確な意図があります。一度に単一のフレームだけを記述させるのは、焦点が絞られた達成可能なタスクであり、正確で具体的な記述を生み出します。一方、30 枚のフレーム説明を統合して一貫した物語を作成するのは別のタスクです。これを 1 回の処理で行おうとするよりも、結合された説明を入力として別々の呼び出しで処理する方が、モデルは得意としています。
# 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}")実行方法:
# 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.jsonサンプル出力 (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"}
]
}VRAM 意識的なフレームのバッチ処理
VideoSummarizer におけるバッチサイズは、VRAM の予算内に収めるための主要な調整項目です。大きすぎるとメモリ不足エラーが発生し、小さすぎると不必要に処理速度が低下します。計算式は以下の通りです。
SmolVLM2-2.2B の重みは bfloat16 形式で約 4.5 GB を占有します。各フレームは推論呼び出しに対して約 81 個の画像トークンを追加し、2.2B スケールではトークンあたりの KV キャッシュオーバーヘッドは約 0.5 MB です。VRAM の 20% を余裕分として残す場合:
# 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)}")一般的な VRAM タイプでこの計算を実行すると、上限がどの程度か把握できます。
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 = 50長時間の動画では、何らかの原因で処理が失敗した際に最初からやり直すことができない場合、各フレームの説明を生成するたびに保存する JSON Lines (JSONL) ストリーミングライターを追加してください。
# 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()# パイプラインの拡張(タイムスタンプと JSONL ストリーミング)
このパイプラインから出力される JSON は、すでにフレームレベルでタイムスタンプが付与されています。これをより検索しやすくするには、動画プレイヤーのスクラバーに直接対応する、明確な MM:SS** ラベルを各フレームの説明に追加する必要があります。
出力結果を動画レビューインターフェースですぐに使用可能にしたい場合は、to_json() 関数に以下の後処理ステップを追加してください。
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}"データベースや Slack の通知、ドキュメントインデックスなど、下流のシステムへストリーミング出力したい長時間動画の場合には、バッチバッファ方式ではなく、各行が 1 フレーム分の記録となる JSONL 形式に置き換えてください。これにより、90 分間の動画処理において 30 秒経過した時点で最初のフレームの説明を利用可能にし、パイプライン全体の完了を待たずに書き込みを開始できるようになります。
JSONL ワイターと JSONLWriter.already_processed() を組み合わせてチェックポイントからの再開機能を実装できます。例えば、50 フレーム中 35 フレーム目でパイプラインがクラッシュした場合でも、再起動時に既存のチェックポイントを読み込み、最初の 35 フレームをスキップして 36 フレーム目から処理を継続します。長時間動画においては、最初からやり直すよりも大幅な時間短縮が可能です。
# 結論
SmolVLM2-2.2B は、能力とサイズの間で非常に有用なバランス点に位置しています。 単一のコンシューマー向け GPU で動作するほど小さくありながら、実際のワークフローで役立つ動画要約を生成できるだけの能力を持っています。このフレーム画像アプローチにより、実装はシンプルに保たれています。特殊なビデオエンコーダーも独自のアテンション実装も不要です。標準的な transformers API を使い、PIL 形式の画像を入力とするだけです。
この記事で紹介する会議要約ツールは、あくまでテンプレートです。FRAME_PROMPT をあなたの専門分野に合わせて調整したプロンプトに置き換え、build_synthesis_prompt() 関数を変更して、ユースケースに必要な構造化されたフィールドを抽出できるようにすれば、同じパイプラインで講義の録画、セキュリティ映像、製品デモのウォークスルー、スポーツのハイライトなどにも応用できます。フレームごとの記述をまず行い、その後に要約を行うという 2 フェーズのパターンは、モデルが個々のフレームを正確に記述し、記述内容に基づいて信頼性の高い要約を生成できるため、あらゆるケースで有効です。
50 フレームという制限はスタート地点であり、上限ではありません。VRAM の容量が大きいハードウェアでは、max_frames を 75 や 100 に増やして実験してみてください。品質はフレームのカバー率が高まるにつれて向上しますが、ある点まではその傾向が続きます。また、要約フェーズでも、より多くの素材を処理できるため、結果の質が向上します。
シトゥ・オムイデ氏は、ソフトウェアエンジニアであり技術ライターです。最先端の技術を駆使して説得力のある物語を紡ぐことに情熱を注ぎ、細部への鋭い眼と複雑な概念を簡潔に説明する才能を持っています。また、Twitter でも活動しています。
AI算出
技術分析ainew評価高い
SmolVLM2-2.2B のトークン化戦略(ピクセルシャッフル)による効率化という技術的洞察と、RTX 3060 などのコンシューマー向けハードウェアでの実装可能性を詳細に解説しており、開発者が再現可能な技術分析として価値が高い。
6つの評価軸を見る
- AI関連度
- 100
- 情報源の信頼性
- 50
- 新規性
- 75
- 調べる価値
- 75
- 重複の少なさ
- 100
- 日本での有用性
- 25
関連記事
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み