背景や目的
これまでに、VLMに静止画像を与え、その内容を分析するアプリを試作してきました。
(記事:1.VLMで熊問題に対策
2.Gemma4でVLM利用アプリを試作(SFTあり))
そこから発展して今回は、撮影済みの画像をアップロードして分析するのではなく、スマホのカメラ映像をそのままリアルタイムに処理させてみます。
画像を扱うAIはとても便利で、可能性を感じています。今回はそのアプリを具体的に作る時の基礎情報を得るために、ミニ版を試作してみます。VLMに興味がある方は、ご覧ください。
試作品の概要
今回は、スマホのブラウザで動くアプリにします。ストリーミングを開始すると、カメラ映像をVLMで解析し、その結果文を画面内に表示するアプリを試作します。
アプリの画面は以下のようにしたいです。




最初にカメラ起動ボタンを表示し、ボタン押下後にスマホのバックカメラ映像を表示します。その後、画面をタップすることで映像を推論し、画面下部に防災リスクの説明文を表示します。
実行環境は以下の通りです。
・実行サーバー(端末):スマホの標準ブラウザ(Android/Google Chrome)
・実行環境(サーバ):AWS Lambda関数
・VLM:クラウドAPI
VLMモデルの選別
VLMモデルには、GPT、Gemini、Claude、Gemmaなど多くの種類があります。
大きく分けると、クラウドAPIとして利用するモデルと、OSSとして別途環境構築して使うモデルがあります。
代表的な違いは以下の通りです。
クラウドAPI:
| 項目 | 特徴 |
|---|---|
| モデル例 | GPT-5.5, Claude Opus 4.8, Gemini 3.5等 |
| セットアップ | APIキーのみ |
| 推論速度 | 高速 |
| コスト | 従量課金が一般的 |
| カスタマイズ | 追加学習等は基本的に不可 |
OSS:
| 項目 | 特徴 |
|---|---|
| モデル例 | Gemma4, Qwen3-VL, InternVL3.5等 |
| セットアップ | モデル推論環境の構築が必要 |
| 推論速度 | 構築する環境に依存 |
| コスト | 基本無料(環境の費用はかかる) |
| カスタマイズ | ファインチューニング可能 |
今回は推論結果の評価はしないので、低コストなクラウドAPI:GPT-4o miniを使用します。
手順サマリー
今回の試作は、以下の流れで進めます。
- コード作成
アプリに必要なPGファイルを準備します。 - デプロイ
コード作成完了後、AWS Lambdaにコードをデプロイします。 - 動作確認
スマホでブラウザを開き、Lambda 関数URLにアクセスして、カメラ映像をタップし分析を実行します。
コード作成
まずコードを用意します。
今回はAWS Lambdaで動作させるので、Lambda用の以下2ファイルにしておきます。
・lambda_function.py
・scanner.html
事前準備として、OpenAI GPT APIを使用するため、APIキーを用意しておきます。未作成の場合は、OpenAIアカウントでAPIキーを作成してから進めます。
サーバー側PGとしてlambda_function.pyを作成します。
このPGでは、画面から送信されたカメラ画像を受け取り、OpenAI GPT APIへ送信して、解析結果文を返します。
import json
import os
import urllib.request
import urllib.error
import base64
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
DEFAULT_MODEL = os.environ.get('MODEL', 'gpt-4o-mini')
OPENAI_ENDPOINT = 'https://api.openai.com/v1/chat/completions'
SYSTEM_PROMPT = 'あなたは画像を簡潔に説明するアシスタントです。'
DEFAULT_USER_PROMPT = 'この画像に写っている物体を20語以内で簡潔に説明してください。'
# Load HTML once at cold start
HTML_PATH = os.path.join(os.path.dirname(__file__), 'scanner.html')
try:
with open(HTML_PATH, 'r', encoding='utf-8') as f:
HTML = f.read()
except FileNotFoundError:
HTML = '<h1>scanner.html not found in deployment package.</h1>'
def lambda_handler(event, context):
method = (
event.get('requestContext', {})
.get('http', {})
.get('method', 'GET')
.upper()
)
# ── CORS preflight ──
if method == 'OPTIONS':
return _cors_response()
# ── GET / — serve the HTML app ──
if method == 'GET':
return {
'statusCode': 200,
'headers': {
'Content-Type': 'text/html; charset=utf-8',
'Access-Control-Allow-Origin': '*',
},
'body': HTML,
}
# ── POST / — scan image ──
if method == 'POST':
try:
raw_body = event.get('body', '{}')
if event.get('isBase64Encoded', False):
raw_body = base64.b64decode(raw_body).decode('utf-8')
body = json.loads(raw_body)
except Exception as e:
return _json(400, {'error': f'Invalid request body: {e}'})
image_b64 = body.get('image', '')
model = body.get('model', DEFAULT_MODEL)
user_prompt = body.get('prompt', DEFAULT_USER_PROMPT)
if not image_b64:
return _json(400, {'error': 'Missing image field'})
if not OPENAI_API_KEY:
return _json(500, {'error': 'OPENAI_API_KEY not set in environment variables'})
try:
result = call_openai(image_b64, model, user_prompt)
return _json(200, {'result': result})
except Exception as e:
return _json(500, {'error': str(e)})
# ── method not allowed ──
return _json(405, {'error': f'Method {method} not allowed'})
def call_openai(image_b64, model, user_prompt):
"""Call OpenAI chat completions API using only stdlib urllib."""
payload = json.dumps({
'model': model,
'max_tokens': 512,
'messages': [
{
'role': 'system',
'content': SYSTEM_PROMPT
},
{
'role': 'user',
'content': [
{
'type': 'text',
'text': user_prompt
},
{
'type': 'image_url',
'image_url': {
'url': f'data:image/jpeg;base64,{image_b64}',
'detail': 'low'
}
}
]
}
]
}, ensure_ascii=False).encode('utf-8')
req = urllib.request.Request(
OPENAI_ENDPOINT,
data=payload,
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {OPENAI_API_KEY}',
},
method='POST'
)
with urllib.request.urlopen(req, timeout=30) as res:
data = json.loads(res.read().decode('utf-8'))
return data['choices'][0]['message']['content']
def _json(status_code, data):
return {
'statusCode': status_code,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
'body': json.dumps(data, ensure_ascii=False),
}
def _cors_response():
return {
'statusCode': 200,
'headers': {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
},
'body': '',
}コード内11〜13行目に、システムプロンプトとユーザープロンプトを設定しました。
今回は、画像内の物体を短く説明する以下の内容にしました。
システムプロンプト
| あなたは画像を簡潔に説明するアシスタントです。 |
ユーザプロンプト
| この画像に写っている物体を20語以内で簡潔に説明してください。 |
次に、画面表示用のHTMLファイル scanner.htmlを作成します。
このHTMLでは、スマホのカメラ映像を表示し、タップした画像をLambdaへ送信します。
Lambdaから返ってきた結果文は、画面内チャットに表示します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>VLMリアルタイム推論アプリ</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DotGothic16&family=Share+Tech+Mono&display=swap');
:root {
--bg: #f5f7fa;
--panel: #ffffff;
--border: #e0e4ea;
--accent: #1a7f5a;
--accent2: #d93025;
--accent3: #1a73e8;
--warn: #e8820c;
--text: #1c2b3a;
--dim: #8a9ab0;
--scan-line: rgba(0,0,0,0.015);
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Share Tech Mono', monospace;
min-height: 100dvh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(0deg, var(--scan-line) 0px, var(--scan-line) 1px, transparent 1px, transparent 3px);
pointer-events: none;
z-index: 999;
}
header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.logo {
font-family: 'DotGothic16', monospace;
font-size: 16px;
color: var(--accent);
letter-spacing: 2px;
}
.logo span { color: var(--accent2); }
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--dim);
display: inline-block;
margin-right: 6px;
transition: all 0.3s;
}
.status-dot.ready { background: var(--accent); }
.status-dot.error { background: var(--accent2); }
.status-dot.thinking { background: var(--warn); animation: pulse 0.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.3} }
.status-text { font-size: 11px; color: var(--dim); letter-spacing: 1px; }
/* ── camera ── */
.camera-wrap {
position: relative;
width: 100%;
background: #000;
aspect-ratio: 4/3;
overflow: hidden;
flex-shrink: 0;
cursor: pointer;
user-select: none;
}
/* tap hint ring */
.camera-wrap::after {
content: 'TAP TO SCAN';
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: var(--accent);
letter-spacing: 2px;
opacity: 0;
transition: opacity 0.4s;
white-space: nowrap;
pointer-events: none;
}
.camera-wrap.ready::after { opacity: 0.7; }
.camera-wrap.analyzing::after { content: '解析中...'; opacity: 0.9; color: var(--warn); }
video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
canvas { display: none; }
/* corner brackets */
.corner { position: absolute; width: 20px; height: 20px; transition: border-color 0.3s; }
.corner.tl { top:8px; left:8px; border-top:2px solid var(--accent); border-left:2px solid var(--accent); }
.corner.tr { top:8px; right:8px; border-top:2px solid var(--accent); border-right:2px solid var(--accent); }
.corner.bl { bottom:8px; left:8px; border-bottom:2px solid var(--accent); border-left:2px solid var(--accent); }
.corner.br { bottom:8px; right:8px; border-bottom:2px solid var(--accent); border-right:2px solid var(--accent); }
.camera-wrap.analyzing .corner { border-color: var(--warn) !important; }
/* flash overlay on tap */
.flash {
position: absolute;
inset: 0;
background: white;
opacity: 0;
pointer-events: none;
transition: opacity 0.05s;
}
.flash.go { opacity: 0.35; }
/* ripple on tap */
.ripple {
position: absolute;
border-radius: 50%;
border: 2px solid var(--accent);
width: 60px; height: 60px;
pointer-events: none;
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
.ripple.animate {
animation: rippleOut 0.5s ease-out forwards;
}
@keyframes rippleOut {
to { transform: translate(-50%, -50%) scale(3); opacity: 0; }
}
/* scan beam — plays once on tap */
.scan-beam {
position: absolute;
left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 12px var(--accent);
top: 0;
opacity: 0;
}
.scan-beam.go {
opacity: 1;
animation: scanDown 0.8s linear forwards;
}
@keyframes scanDown { from { top:0% } to { top:100%; opacity:0; } }
/* no-cam overlay */
.no-cam {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 8px; background: #e8edf2;
}
.no-cam span { font-size: 11px; color: var(--dim); letter-spacing: 1px; }
/* ── controls ── */
.controls {
padding: 10px 16px;
display: flex;
gap: 8px;
background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-bottom: 1px solid var(--border);
align-items: center;
}
.btn {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
letter-spacing: 1px;
padding: 10px 16px;
border: 1px solid var(--dim);
background: transparent;
color: var(--dim);
cursor: pointer;
transition: all 0.2s;
flex: 1;
text-transform: uppercase;
}
.btn:active { transform: scale(0.97); }
.btn.primary { border-color: var(--accent); color: var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.btn.secondary { border-color: var(--border); font-size: 11px; flex: 0 0 auto; padding: 10px 12px; }
.btn.secondary:active { border-color: var(--accent3); color: var(--accent3); }
.btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── output panel ── */
.output-panel {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.result-card {
border: 1px solid var(--border);
background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
padding: 12px;
animation: slideIn 0.3s ease;
position: relative;
overflow: hidden;
}
.result-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
}
.result-card::before { background: var(--accent3); }
.result-card.thinking-card::before { background: var(--warn); }
@keyframes slideIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.card-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.card-time { font-size:10px; color:var(--dim); letter-spacing:1px; }
.card-badge { font-size:9px; padding:2px 6px; letter-spacing:1px; text-transform:uppercase; }
.card-badge.result { color:var(--accent3); border:1px solid var(--accent3); }
.card-badge.thinking{ color:var(--warn); border:1px solid var(--warn); }
.card-body { font-size:13px; line-height:1.7; color:var(--text); white-space:pre-wrap; }
.thinking-dots::after { content:'.'; animation: dots 1.2s infinite; }
@keyframes dots { 0%{content:'.'} 33%{content:'..'} 66%{content:'...'} }
/* ── output label ── */
.output-label {
padding: 8px 16px;
font-size: 10px;
letter-spacing: 2px;
color: var(--dim);
background: var(--bg);
border-bottom: 1px solid var(--border);
text-transform: uppercase;
}
/* ── empty state ── */
.empty-state { text-align:center; padding:32px 20px; color:var(--dim); font-size:12px; letter-spacing:1px; line-height:2.2; }
.empty-state .big { font-family:'DotGothic16',monospace; font-size:36px; color:var(--border); display:block; margin-bottom:8px; }
.empty-state .hint { font-size:18px; display:block; margin-bottom:8px; animation: bounceY 1.5s infinite; }
@keyframes bounceY { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
</style>
</head>
<body>
<header>
<div class="logo">VLM<span>リアルタイム推論</span></div>
<div style="display:flex;align-items:center;gap:6px">
<span class="status-dot" id="statusDot"></span>
<span class="status-text" id="statusText">STANDBY</span>
</div>
</header>
<div class="camera-wrap" id="cameraWrap" onclick="handleTap(event)">
<video id="video" autoplay playsinline muted></video>
<canvas id="canvas"></canvas>
<div class="corner tl"></div>
<div class="corner tr"></div>
<div class="corner bl"></div>
<div class="corner br"></div>
<div class="scan-beam" id="scanBeam"></div>
<div class="flash" id="flash"></div>
<div class="ripple" id="ripple"></div>
<div class="no-cam" id="noCam">
<span>[ カメラ未起動 ]</span>
</div>
</div>
<div class="controls">
<button class="btn primary" id="startBtn" onclick="toggleCamera()">▶ カメラ起動</button>
</div>
<div class="output-label">結果表示欄(タップ後処理)</div>
<div class="output-panel" id="outputPanel">
<div class="empty-state" id="emptyState">
<span class="hint">👆</span>
カメラを起動して<br>映像をタップしてスキャン
</div>
</div>
<script>
const DEFAULT_PROMPT = 'この画像に写っている物体を20語以内で簡潔に説明してください。';
let stream = null;
let analyzing = false;
let facingMode = "environment"; // back camera only
let scanCount = 0;
let cameraReady = false;
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const startBtn = document.getElementById('startBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const scanBeam = document.getElementById('scanBeam');
const flash = document.getElementById('flash');
const ripple = document.getElementById('ripple');
const cameraWrap = document.getElementById('cameraWrap');
const noCam = document.getElementById('noCam');
const outputPanel= document.getElementById('outputPanel');
const emptyState = document.getElementById('emptyState');
// ── camera toggle ──
function toggleCamera() {
if (cameraReady) {
stopCamera();
} else {
initCamera();
}
}
function stopCamera() {
if (stream) stream.getTracks().forEach(t => t.stop());
stream = null;
cameraReady = false;
video.srcObject = null;
noCam.style.display = 'flex';
cameraWrap.classList.remove('ready', 'analyzing');
startBtn.textContent = '▶ カメラ起動';
setStatus('standby', 'STANDBY');
if (emptyState) emptyState.style.display = 'block';
}
// ── camera ──
async function initCamera() {
const ok = await startCamera();
if (ok) {
startBtn.textContent = '■ カメラ停止';
}
}
async function startCamera() {
try {
if (stream) stream.getTracks().forEach(t => t.stop());
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode, width:{ideal:1280}, height:{ideal:720} },
audio: false
});
video.srcObject = stream;
noCam.style.display = 'none';
cameraWrap.classList.add('ready');
cameraReady = true;
setStatus('ready', 'READY');
if (emptyState) emptyState.style.display = 'none';
return true;
} catch(e) {
setStatus('error', 'CAM ERROR');
addResult('error', `カメラにアクセスできません: ${e.message}`);
return false;
}
}
// back camera only — flip disabled
// ── tap handler ──
async function handleTap(e) {
if (!cameraReady) return;
if (analyzing) return; // block double-tap during analysis
// show ripple at tap position
const rect = cameraWrap.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
ripple.style.left = x + 'px';
ripple.style.top = y + 'px';
ripple.classList.remove('animate');
void ripple.offsetWidth; // reflow
ripple.classList.add('animate');
// flash
flash.classList.add('go');
setTimeout(() => flash.classList.remove('go'), 120);
// scan beam
scanBeam.classList.remove('go');
void scanBeam.offsetWidth;
scanBeam.classList.add('go');
await doScan();
}
// ── capture & analyze ──
async function doScan() {
analyzing = true;
scanCount++;
cameraWrap.classList.remove('ready');
cameraWrap.classList.add('analyzing');
setStatus('thinking', 'ANALYZING...');
// capture frame
canvas.width = video.videoWidth || 640;
canvas.height = video.videoHeight || 480;
ctx.drawImage(video, 0, 0);
const imageData = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
const thinkingCard = addResult('thinking', null);
const model = 'gpt-4o-mini';
const prompt = DEFAULT_PROMPT;
try {
// Send image to Colab proxy — API key never leaves the server
const res = await fetch('/api/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageData, model, prompt })
});
const data = await res.json();
if (!res.ok) {
thinkingCard.remove();
addResult('error', `API エラー: ${data?.error || 'HTTP ' + res.status}`);
setStatus('error', 'API ERROR');
} else {
const text = data.result || '(応答なし)';
thinkingCard.remove();
addResult('result', text);
setStatus('ready', 'READY');
}
} catch(e) {
thinkingCard.remove();
addResult('error', `ネットワークエラー: ${e.message}`);
setStatus('error', 'NET ERROR');
}
analyzing = false;
cameraWrap.classList.remove('analyzing');
cameraWrap.classList.add('ready');
}
// ── UI helpers ──
function setStatus(type, text) {
statusDot.className = 'status-dot';
if (type !== 'standby') statusDot.classList.add(type === 'ready' ? 'ready' : type);
statusText.textContent = text;
}
function addResult(type, text) {
const card = document.createElement('div');
card.className = `result-card ${type === 'thinking' ? 'thinking-card' : ''}`;
const now = new Date();
const time = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
if (type === 'thinking') {
card.innerHTML = `
<div class="card-meta">
<span class="card-time">${time} — #${scanCount}</span>
<span class="card-badge thinking">ANALYZING</span>
</div>
<div class="card-body thinking-dots">解析中</div>`;
} else {
card.innerHTML = `
<div class="card-meta">
<span class="card-time">${time} — #${scanCount}</span>
${type === 'error' ? '<span class="card-badge thinking">ERROR</span>' : ''}
</div>
<div class="card-body">${escHtml(text)}</div>`;
}
outputPanel.insertBefore(card, outputPanel.firstChild);
return card;
}
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
// ── init ── (no settings to load)
</script>
</body>
</html>これでPG作成の部分は完了です。
デプロイ
今回は、AWS Lambda 関数上で実行する構成にしました。
次に、AWS Lambda 関数を作成し、先ほど作成したコードを登録します。
AWS上にLambda関数を以下の手順で作成します。
・AWSコンソールでLambdaメニューに遷移し、トップ画面で「関数の作成」を押します。
(※AWSアカウントの作成など基本設定はここでは割愛します)

・ 画面「関数の作成」では、以下の設定値で、作成します。
| 設定項目 | 値 |
|---|---|
| 関数名 | vlm_realtime ※お好きな名称で |
| ランタイム | python3.12 |
| アーキテクチャ | x86_64(デフォルト) |
| 実行ロール | 新規ロール(デフォルト) |
| 関数 URL を有効化 | チェック |
| 認証タイプ | NONE |

これでLambda関数作成の部分は完了です。
次は準備したPGをアップして、設定して、デプロイします。
・先ほど作成した2つファイル(lambda_function.pyと1つのHTML)を手元に準備できたら、Lambda関数にセットします。
AWSコンソールのLambda内コードソースは以下のようになっていればOKです。

あわせて、OpenAI GPT APIを使用するためのAPIキーを環境変数に設定します。
・「設定」→「環境変数」→「編集」からキー情報を入力し、「保存」で設定してください。

最後に「Deploy」を押して、コードをデプロイします。

以下の確認メッセージが表示されればOKです。これでセットアップは完了です。

動作確認
スマホ上のブラウザでLambda関数URLにアクセスして動作を確認を早速ためしてみます!
初回アクセスすると、カメラ利用の許可を求めるポップアップが表示されました。
・「サイトへのアクセス時は許可」をクリックします。
その後、カメラを起動し、映像をタップして分析を実行します。
以下は、その動作を確認したデモ動画です。
良いですね!
まとめ
今回は、VLMを使ったリアルタイム画像分析アプリを試作しました。
スマホのブラウザからカメラ映像を取得し、クラウドAPIに送信して分析する流れを確認しました。今回は推論結果の評価は行いませんが、GPTのminiモデルの応答速度は速かったです。
今回得た事として、VLM画像分析アプリを実装する流れを確認できました。読者の方にとっても、実装イメージを持つ参考になれば幸いです。次は、実際の製品で試してみます。
やはり推論の精度が気になるので、ニーズにマッチできるか色々と確認してみます。
VLMದು ಹೆಚ್ಚಿನ ಉಪಯೋಗಗಳನ್ನು ಅನ್ವೇಷಿಸೋಣ
(VLMの活用先をさらに検討しましょう)
