以前の記事で、私たちは3つのオープンソースのビジョン言語モデルをゼロショット物体検出でベンチマークし、不快な結論に至りました:最も速い候補であるPhi-3.5-vision-instructでも、NVIDIA L4でフレームあたり4.45秒かかります。LLaVA-v1.6は8.13秒です。ライブビデオストリームを処理する必要があるどんなアプリケーションにおいても、これらの数値は不適格です。しかし、VLMsが本質的にリアルタイムワークロードと互換性がないという結論には、さらに検討が必要です。その8秒の数値は、一般的なゼロショット検出タスクで測定され、モデルに任意のシーン中の任意の物体について推論させるものでした。制約された問題ではどうなるでしょうか?モデルに閉じた語彙、固定された解像度、確定的なデコーディング戦略、非ブロッキングの推論パイプラインを与えた場合呢?
本記事はその質問に答えます。Googleのコンパクトなビジョン言語モデルであるPaliGemmaを使用して、NVIDIA RTX A4500で約0.8~1.2秒ごフレームで実行されるリアルタイムビデオ分類システムを構築しました。これは、比較可能なプロフェッショナルハードウェアにおけるLLaVAに対する6~8倍の改善であり、完全にアーキテクチャの決定を通じて、ハードウェアのアップグレードではなく達成しました。それを実現した4つのパターンはこちらです。
パリジェマはなぜ?
建築設計に入る前に、モデルの選択自体も説明に値する。なぜなら、PaliGemmaは実用的な価値に対して開発者文献における表現が著しく不足しているからだ。PaliGemmaはGoogleが構築した30億パラメータのビジョン言語モデルで、SigLIPビジョンエンコーダとGemma言語バックボーンを組み合わせている。LLaVA-7BやPhi-3.5-visionと比較すると、規模は約半分で、同じハードウェア上でのVRAM消費を直接的に低減し、推論速度も向上させる。分類タスクにおいてより重要なのは、イメージキャプション、ビジュアルクエスチョンアンサリング、オブジェクトロケーションを含む幅広いビジョン理解ベンチマークで明示的にファインチューニングされたということだ。つまり、私たちが引き出そうとするような制約的で構造的なレスポンスに対して強い事前知識を持っているということだ。
import torch
from transformers import PaliGemmaProcessor, PaliGemmaForConditionalGeneration
MODEL_PATH = "./paligemma_offline"
model = PaliGemmaForConditionalGeneration.from_pretrained(
MODEL_PATH,
torch_dtype=torch.bfloat16,
device_map="auto",
local_files_only=True
).eval()
processor = PaliGemmaProcessor.from_pretrained(
MODEL_PATH,
local_files_only=True
)
ここで注目すべき点が二つあります。bfloat16で読み込む代わりにfloat32を使うと、分類タスクにおける精度の低下は無視できるレベルでVRAMの使用量が半分になります。local_files_only=True フラグはオフライン環境の便利さだけでなく、プロダクションシステムでは初期化時のネットワークラウンドトリップを削除し、推論環境が完全に再現可能であることを保証します.
パターン1:解像度を遅延のノブとして
リアルタイムVLMパイプラインにおいて最も影響的な決定は入力解像度です。VLMは画像をパッチに分割し、それぞれのパッチをトークンのシーケンスとしてエンコードします。1280×720のフレームは448×448のクロップよりもはるかに大きなトークンシーケンスを生成し、トランスフォーマーのアテンションがシーケンス長に二乗比例するため、解像度は線形のコストではなく指数的なコストです。ゼロショットオブジェクト検出では、空間精度が重要ですが、ダウンサンプリングは実際のトレードオフです。しかし、シーンレベルの分類タスクでは、「このフレームの主な感情は何か?」と尋ねるのではなく、「すべてのオブジェクトのピクセル座標を教えて」と尋ねる場合、448×448は十分な意味情報を保持しています。
import cv2
import numpy as np
from PIL import Image
def preprocess_frame(frame_bgr: np.ndarray) -> Image.Image:
# Resize to inference resolution before any VLM processing
frame_small = cv2.resize(frame_bgr, (448, 448))
# Convert BGR (OpenCV) to RGB (PIL/transformers)
return Image.fromarray(cv2.cvtColor(frame_small, cv2.COLOR_BGR2RGB))
重要な洞察は、解像度は実際にタスクが要求する情報の粒度に基づいて選択されるべきであり、入力ストリームの解像度に基づいて選択されるべきではないということです。カメラが1080pでキャプチャするけれども、分類タスクが五つの感情状態を区別するだけなら、あなたは使うことのない情報に対して莫大な計算コストを払っていることになります。
パターン2:閉じた語彙での確定的デコーディング
標準的なVLMの使用法では、モデルを無限に続くテキストジェネレーターとして扱います。プロンプトを与えると、確率分布からサンプリングし、自然言語のレスポンスを受け取り、それをパースします。これは前の記事で議論した脆弱性問題の原因であり、また遅延の大きな原因でもあります:高いサンプリングmax_new_tokens は、モデルが生成する各トークンに対して完全な自己回帰ループを実行することを意味します。分類タスクの場合、これを完全に無視できます。モデルに見ているものを説明するように求める代わりに、その出力を有効なラベルの固定された語彙に制限し、それを表現するために必要な最小限のトークン数に生成を制限します。
# A generic set of states tailored to your specific domain
VALID_CLASSES = ['active', 'idle', 'error', 'offline', 'unknown']
def classify_frame(model: torch.nn.Module, processor, image: Image.Image) -> str:
prompt = (
f"<image> What is the current operational state shown in this frame? "
f"You MUST choose ONLY ONE from: {VALID_CLASSES}."
)
inputs = processor(
text=prompt,
images=image,
return_tensors="pt"
).to(model.device, model.dtype)
with torch.inference_mode():
output_ids = model.generate(
**inputs,
max_new_tokens=10, # A single class label needs at most 2-3 tokens
do_sample=False # Greedy decoding: deterministic, faster, no temperature needed
)
raw_output = processor.decode(
output_ids[0][inputs.input_ids.shape[-1]:],
skip_special_tokens=True
).strip().lower()
# Sanitize to alphabetic characters only
return ''.join(filter(str.isalpha, raw_output))
設定do_sample=Falseはモデルをgreedy decodingに切り替え、各ステップで最も確率の高いトークンを選択します。これによりサンプリングのオーバーヘッドが削除され、出力が完全に確定的になります:同一の入力は常に同一の出力を生成するため、デバッグと次に説明する時系列のスムージングパターンにとって不可欠です。Themax_new_tokens=10 キャップは、ラベルを生成した直後にモデルがほぼすぐに生成を停止することを意味し、誰も要しない説明テキストを続けて生成することではなく。
その結果、あなたは3BパラメータのVLMを高機能で意味に敏感な分類器として、生成モデルではなく使っていることになります。自然言語プロンプティングのゼロショットの柔軟性と、専用の分類ヘッドに近い推論特性を得られます。
時系列平滑法による予測安定性
確定的デコーディングであっても、ライブビデオを処理するVLMはノイズの多い予測を生み出します。照明の変化、動きのぼかし、部分隠蔽、一時的な視覚的アーティファクトが、モデルに連続するフレーム間で一貫性のないラベルを出力させます。生の各フレーム予測を直接下流システムにパイプに接続すると、揺らぎがあり、信頼性の低い出力が得られます。解決策は時系列平滑化です:単一の予測を信頼する代わりに、最近の予測のローリングウィンドウを維持し、多数決を出力します。
from collections import deque, Counter
class TemporalSmoother:
def __init__(self, window_size: int = 5):
self.history = deque(maxlen=window_size)
def update(self, prediction: str) -> str:
self.history.append(prediction)
# Return the most common prediction in the window
return Counter(self.history).most_common(1)[0][0]
私たちの推論速度における5フレームのウィンドウは、約4~6秒のタイムコンテキストに相当します。これは一時的なノイズを吸収しながら、本物の状態変化に反応し続けるのに十分です。ウィンドウサイズは主要なチューニングパラメータです:大きなウィンドウは安定性が高いですが反応が遅くなり、小さなウィンドウは反応が速いですがノイズが多いです。大多数の分類タスクでは、3~7フレームが実用的な範囲をカバーします。
パターン4: 分離された共有状態アーキテクチャを使用した非同期推論
前の3つのパターンは推論呼び出し自体を最適化します。このパターンはより根本的なシステム的な問題に対応しています:0.8秒から1.2秒かかるVLM推論呼び出しは、実行しているスレッドをその間完全にブロックします。ビデオキャプチャと推論が同じスレッドで実行されている場合、ストリームは推論の速度でカクつく代わりに、カメラの速度でカクつきます。
素朴な解決策は、Pythonの標準ライブラリのqueue.Queueを使用してスレッド間でフレームを渡すことです。しかし、これによりコンシューマー間の競合が生じます:レンダリングスレッドとAIスレッドが同じキューから読み取ると、フレームを消費し合い、お互いのデータを奪い合って、深刻な視覚的なステータスの停止と推論サイクルのスキップを引き起こします。本格的な解決策は非同期共有状態パターンです。 で粒度化されたロックが行われます。ビデオキャプチャスレッドはプロデューサーとして機能し、継続的に共有される「最新フレーム」ポインタを上書きします。レンダリングスレッド(メインスレッド上で実行され、macOSおよびWaylandでのOpenCV UI操作に必須)とAIバックグラウンドスレッドは独立したコンシューマーとして動作し、次のサイクルに備え準備ができたときに最新フレームをローカルメモリにコピーします.
import threading
import time
import numpy as np
import cv2
import torch
from typing import Optional, Any
class SharedState:
"""
Thread-safe state container.
The lock is strictly granular: it is only held for memory assignment/copying,
never during expensive I/O or AI inference operations.
"""
def __init__(self):
self.latest_frame: Optional[np.ndarray] = None
self.prediction: str = "WAITING"
self.lock = threading.Lock()
self.running: bool = True
shared = SharedState()
def video_capture_worker(source: int = 0) -> None:
"""Reads frames at hardware speed and updates the shared state."""
cap = cv2.VideoCapture(source)
while shared.running:
ret, frame = cap.read()
if not ret:
time.sleep(0.01)
continue
with shared.lock:
# Overwrite with the freshest data.
# Pointer assignment is fast enough to barely hold the lock.
shared.latest_frame = frame
cap.release()
def inference_worker(model: torch.nn.Module, processor: Any) -> None:
"""Consumes the latest frame at the AI's maximum throughput rate."""
smoother = TemporalSmoother(window_size=5)
while shared.running:
with shared.lock:
# Deep copy to prevent OpenCV from mutating the array during inference
frame = shared.latest_frame.copy() if shared.latest_frame is not None else None
if frame is None:
time.sleep(0.05)
continue
try:
image = preprocess_frame(frame)
raw_pred = classify_frame(model, processor, image)
smoothed_pred = smoother.update(raw_pred)
with shared.lock:
shared.prediction = smoothed_pred
except torch.cuda.OutOfMemoryError:
# Handle temporary VRAM spikes gracefully without killing the thread
with shared.lock:
shared.prediction = "OOM_ERROR"
time.sleep(1.0)
except Exception as e:
# Catch corrupt frames or tensor mismatches
with shared.lock:
shared.prediction = "ERROR"
# Initialize background workers as Daemon threads
threads = [
threading.Thread(target=video_capture_worker, args=(0,), daemon=True),
threading.Thread(target=inference_worker, args=(model, processor), daemon=True),
]
for t in threads:
t.start()
# Main Thread UI Loop
# UI libraries (cv2.imshow) must run on the main thread to prevent OS-level crashes.
while shared.running:
with shared.lock:
frame = shared.latest_frame.copy() if shared.latest_frame is not None else None
label = shared.prediction
if frame is not None:
cv2.putText(frame, label.upper(), (30, 50),
cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 255, 0), 3)
cv2.imshow("Live Classification", frame)
# 30 FPS rendering limit (33ms) + graceful shutdown
if cv2.waitKey(33) & 0xFF == ord('q'):
shared.running = False
cv2.destroyAllWindows()
ここでの重要な設計原則は粒度の細かいロックです:ロックが取得され、NumPy配列がメモリ内にコピーされる(これはマイクロ秒単位でかかる)、そしてロックがすぐに解放されます。1秒のVLM推論呼び出しの間にロックを保持すると、すべての3つのコンポーネントがシリアライズされ、そのアーキテクチャの目的を達しないことになります。この構造では、あなたのビデオキャプチャスレッドはハードウェアのフレームレート(例えば30fps)で動作し、レンダリングループは30fpsでフレームを表示し、推論スレッドは自分自身の非同期レート(1fps)で動作します。3つのシステムは時系列上で独立しており、それぞれのハードウェアの制限のみによって制限されます。
ベンチマークの概要
NVIDIA RTX A4500(20GB GDDR6、Ampereアーキテクチャ)でPaliGemmaを使用し、bfloat16で三つのストリームのライブビデオシナリオで完全なパイプラインを実行すると、非常に安定したパフォーマンスプロファイルが得られます。入力解像度を448 × 448に制限し、greedyデコーディング戦略を通じて出力を最大10個の新しいトークンに制限すると(do_sample=False), システムはフレームごとに0.8秒から1.2秒の推論遅延を達成します。5フレームの時系列平滑化ウィンドウと組み合わせると、この構成は信頼性のある状態分類を保証し、分離されたアーキテクチャによりビデオキャプチャスレッドは推論のボトルネックに依存せず安定した25fpsを維持できます。
比較として、LLaVA-v1.6-Mistral-7BがNVIDIA L4でオープンボキャブラリーゼロショット検出を実行する際、1フレームあたり8.13秒かかります。ハードウェアが直接等価ではありませんが、そのギャップの大きさは、アーキテクチャの制約が、生の計算能力ではなく、差の大部分を説明していることを確認します.
このアーキテクチャが意味を成る時
このパターンは、タスクが固定されたラベルセットに分類可能で、ライブストリームの連続処理が必要であり、静的な画像のバッチ分析ではなく、データプライバシー要件が外部APIにフレームを送信することを排除し、秒単位の遅延を100ms単位の遅延よりも許容できる場合に適しています。コンベアベルト速度での真のリアルタイムレスポンスが必要な場合、50ms単位の遅延は交渉不可能な場合、これは正しいツールではありません。その場合、あなたはYOLOの領域に戻り、前の記事で説明したようなパイプラインを使用します:VLMを利用してデータセットを一夜で自動アノテーションし、その後、生産デプロイ用の専用の軽量分類器をトレーニングします。
結論
「VLMsは動画処理には遅すぎる」という主張と「VLMsは生産用動画パイプラインで動作する」という主張の間のギャップは、主にハードウェアの問題ではありません。それはアーキテクチャの問題です。PaliGemmaのようなコンパクトなモデルを選択すること、タスクが実際に必要とする解像度に制限すること、閉じた語彙での確定されたデコーディングを強制すること、予測を時間的に滑らかにすること、推論をキャプチャとレンダリングから分離すること:これらはどれもより大きなGPUを必要としません。それらは、モデルに実際に何を求めているかを慎重に考えること、そしてその制約を逆にするのではなく、それに基づいてパイプラインを構築することを必要とします。
モデルのロードからマルチスレッド推論までの完全なパターンは、150行以下のPythonで収まる。ライブビデオストリーム上のゼロショットセマンティック分類にとっては、それが合理的な価格だ。












