
Введение
Большинство инструментов для анализа видео относятся к одному из двух типов. Первые требуют отправки файла в облако: ваш материал загружается, обрабатывается на чужих серверах, а счёт выставляется за каждую минуту. Вторые работают локально, но требуют таких графических кластеров, которых у большинства разработчиков просто нет — модели на 70+ млрд параметров, несколько A100, и на одни ролик уходят минуты. Ни тот, ни другой вариант не подходит тому, кто хочет обрабатывать дневные архивы совещаний, лекций или записи с камер наблюдения на собственной рабочей станции.
SmolVLM2-2.2B-Instruct, выпущенная Hugging Face 20 февраля 2025 года, меняет расклад. Модель запускается на 5,2 ГБ видеопамяти — подойдут 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 токенов — совершенно неподъёмно для пользовательской видеокарты. В SmolVLM2 применена стратегия pixel shuffle, сжимающая каждый фрагмент 384×384 до 81 токена. Пятьдесят кадров превращаются примерно в 4050 «картиночных» токенов, что укладывается в один вызов. Благодаря этому пропускная способность при префиллинге оказывается в 3,3–4,5 раза выше, а при генерации — в 7,5–16 раз выше, чем у Qwen2-VL-2B; это прямое следствие разницы в токенном бюджете.
Модель существует в трёх размерах. Версии 256M и 500M ориентированы на мобильные и граничные устройства; 256M способна работать даже на телефоне. Для нашего пайплайна правильный выбор — 2.2B. Только у неё достаточно сильные результаты в видеобенчмарках, чтобы формировать надёжные сводки по нескольким сценам: Video-MME 52.1, MLVU 55.2, MVBench 46.27, тогда как 500M показывает 42.2, 47.3 и 39.73 соответственно.
Стоит разобраться и в подходе к видео. У SmolVLM2 нет отдельного видеокодера — она воспринимает видео как последовательность изображений. Официальный референсный пайплайн извлекает до 50 равномерно распределённых кадров, обходит внутреннее изменение размеров и передаёт их как многоизображение в одном сообщении чата. Такой подход набрал 27,14% на бенчмарке CinePile, разместившись между InternVL2 (2B) и Video-LLaVA (7B) в задачах понимания кинематографического видео — отличный результат с учётом размера модели и того, что видео не было единственной областью её обучения.
Настройка окружения
Требования к оборудованию:
| Характеристика | Минимальные | Рекомендованные |
|---|---|---|
| Видеопамять GPU | 6 ГБ (RTX 3060) | 12–16 ГБ (RTX 4080) |
| Apple Silicon | M2 8 ГБ (путь MPS) | M2 Pro / M3 16 ГБ |
| Системная RAM | 16 ГБ | 32 ГБ |
| Диск | 10 ГБ свободно | 20 ГБ+ 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 что-либо увидит, нужны кадры. Класс FrameExtractor превращает видео в список PIL-изображений с метками времени — по одной паре на каждый извлечённый кадр.
Для разных сценариев важны два режима. Равномерная выборка распределяет кадры по всей длительности ролика, гарантируя покрытие всего контента, что идеально для встреч и лекций, где нельзя пропустить ни одной части. Выборка по ключевым кадрам извлекает кадры только в моменты значительных визуальных изменений — смена сцены, новый слайд, другой докладчик. Это сокращает число кадров и фокусирует внимание на отличимых моментах, что больше подходит для наблюдения и выделения ярких событий.
# 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. Если окажется слишком много повторяющихся кадров (пять почти одинаковых слайдов подряд), переключитесь на 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 связывает извлечение кадров, модель и двухпроходную стратегию инференса: первый проход генерирует описание каждого кадра, второй собирает эти описания в структурированный JSON-отчёт с нарративом и списком задач.
Двухпроходный дизайн выбран сознательно. Попросить модель описать один кадр — конкретная, выполнимая задача, которая даёт точные описания. Синтезировать из 30 описаний связный рассказ — совсем другая задача, и отдельный вызов с конкатенированными описаниями на входе работает лучше, чем попытка сделать всё за один раз.
# 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}")Запуск:
# Равномерная выборка (по умолчанию) — лучше всего для встреч и лекций
python video_summarizer.py meeting_2026_06_14.mp4 --output meeting_summary.json
# Выборка по ключевым кадрам — для обнаружения событий и видеонаблюдения
python video_summarizer.py security_footage.mp4 --mode keyframe --output events.json
# Подстройка размера батча под видеопамять (8 для 8 ГБ VRAM, 16 для 16 ГБ)
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"}
]
}Пакетная обработка кадров с учётом видеопамяти
Размер батча в VideoSummarizer — главный рычаг, влияющий на потребление VRAM. Слишком большой — ошибка нехватки памяти, слишком маленький — неоправданное замедление. Прикинем в цифрах.
Веса SmolVLM2-2.2B занимают около 4,5 ГБ в bfloat16. Каждый кадр добавляет примерно 81 визуальный токен, а для модели масштаба 2.2B накладные расходы KV-кэша составляют около 0,5 МБ на токен. Оставляя 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)}")Запуск на типичных видеокартах даёт такую картину:
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 уже содержит временные метки каждого кадра. Чтобы сделать результат удобным для поиска и навигации, достаточно добавить чистую метку в формате ММ:СС, которая точно соответствует ползунку видеоплеера.
Добавьте этот постобработочный шаг в метод 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, индексатор документов, — замените буферизованный подход на потоковую запись в JSONL, где каждая строка — запись об одном кадре. Тогда описание первого кадра станет доступно уже через 30 секунд после начала обработки полуторачасового видео, а не после завершения всего пайплайна.
Связка с JSONLWriter.already_processed() реализует возобновление с контрольной точки: если пайплайн упал на 35-м кадре из 50, при перезапуске он прочитает существующий чекпоинт, пропустит первые 35 кадров и продолжит с 36-го. Для длинных видео это экономит заметное время по сравнению с началом с нуля.
Заключение
SmolVLM2-2.2B находится в той точке кривой «возможности–размер», где модель действительно полезна. Достаточно компактна для одной пользовательской видеокарты и при этом выдаёт сводки, пригодные для реальной работы. Подход «кадр как изображение» сохраняет реализацию чистой: никаких экзотических видеокодеров, никаких нестандартных механизмов внимания — только стандартный API transformers с PIL-изображениями на входе.
Суммаризатор совещаний из этой статьи — шаблон. Замените FRAME_PROMPT запросом, настроенным под вашу предметную область, измените build_synthesis_prompt(), чтобы извлекались нужные вам структурированные поля, и тот же пайплайн заработает для лекций, записей с камер наблюдения, демонстраций продуктов или спортивных нарезок. Двухпроходный паттерн — сначала описание каждого кадра, затем синтез — универсален, потому что модель точно описывает отдельные кадры и надёжно обобщает множество описаний.
Ограничение в 50 кадров — не потолок, а отправная точка. На оборудовании с большим объёмом VRAM увеличьте max_frames до 75 или 100 и экспериментируйте. Качество растёт с количеством охваченных кадров до определённого предела, и этап синтеза только выигрывает от большего объёма материала.