FastVLM ガイド一覧
手順
- 1
WebGPU と shader-f16 を確認して Node.js 22+ を準備。
- 2
例を取得・展開し、依存を入れて Vite を起動。
- 3
レシート例で Run し $15.00 を確認。
- 4
Worker、キャンセル、JSON 保存を確認し HTTPS 向けにビルド。
例
Browser
curl -fLO https://fastvlm.net/examples/fastvlm-webgpu.zip
unzip fastvlm-webgpu.zip
cd fastvlm-webgpu
npm install
npm run dev
# Open the localhost URL printed by Vite.
# Production: npm run build, then host all of dist/ over HTTPS. 動く画像質問アプリを作る
このサイトと同じ推論 Worker を使う Vite + TypeScript の完全な例です。画像入力、サンプル、逐次回答、進捗、キャンセル、JSON 保存を含みます。
Node.js 22+、WebGPU と shader-f16 対応ブラウザを使用。開発は localhost、本番は HTTPS。初回は Hugging Face から約1.1 GBを取得し、GPU メモリも必要です。
最初の結果を確認
Vite の localhost URL を開き、レシート例を選択して Run。回答に $15.00 が含まれるか確認し、JSON を保存します。読み込み中の Cancel と再実行も確認してください。
アプリの仕組み
- メインスレッドは画像をデコードして長辺1024 pxに制限し、画像と質問を Worker に送ります。
- Worker はモデルを必要時に読み込み、回答を逐次送信。最後に回答と時間を返し、次の質問でモデルを再利用します。
- Cancel は Worker を終了します。次回はセッションを作り直しますが、ブラウザとオリジンによっては重みのキャッシュを利用できます。
時間の定義
準備は取得・キャッシュ・初期化を含みます。画像→回答は前処理から生成終了まで。最初のテキストは非空テキストまでの時間で、厳密な最初のトークン測定ではありません。JSON に画像は含みません。
ソースを読む: src/main.ts
src/main.ts
import { checkBrowserCapability } from './capabilities';
import type { WorkerResponse } from './protocol';
const input = document.querySelector<HTMLInputElement>('#image')!;
const prompt = document.querySelector<HTMLTextAreaElement>('#prompt')!;
const preview = document.querySelector<HTMLImageElement>('#preview')!;
const status = document.querySelector<HTMLElement>('#status')!;
const answer = document.querySelector<HTMLElement>('#answer')!;
const run = document.querySelector<HTMLButtonElement>('#run')!;
const cancel = document.querySelector<HTMLButtonElement>('#cancel')!;
const sample = document.querySelector<HTMLButtonElement>('#sample')!;
const exportButton = document.querySelector<HTMLButtonElement>('#export')!;
let worker: Worker | null = null;
let image = '';
let busy = false;
let inputVersion = 0;
let result: Extract<WorkerResponse, { type: 'result' }> | null = null;
let resultPrompt = '';
function setBusy(value: boolean) {
busy = value;
input.disabled = prompt.disabled = sample.disabled = value;
run.disabled = value || !image || !prompt.value.trim();
cancel.hidden = !value;
exportButton.disabled = value || !result;
}
function stop() { worker?.terminate(); worker = null; setBusy(false); }
async function selectImage(blob: Blob) {
const version = ++inputVersion;
result = null; image = ''; preview.hidden = true;
answer.textContent = ''; status.textContent = '';
setBusy(true);
const url = URL.createObjectURL(blob);
try {
const decoded = new Image(); decoded.src = url; await decoded.decode();
if (version !== inputVersion) return;
const scale = Math.min(1, 1024 / Math.max(decoded.width, decoded.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(decoded.width * scale));
canvas.height = Math.max(1, Math.round(decoded.height * scale));
const context = canvas.getContext('2d')!;
context.fillStyle = 'white'; context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(decoded, 0, 0, canvas.width, canvas.height);
image = canvas.toDataURL('image/png'); preview.src = image; preview.hidden = false;
} catch { status.textContent = 'Cannot read this image. Choose a JPEG, PNG or WebP.'; }
finally { URL.revokeObjectURL(url); if (version === inputVersion) setBusy(false); }
}
input.onchange = () => {
const file = input.files?.[0]; input.value = '';
if (!file) return;
if (!['image/jpeg','image/png','image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
image = ''; preview.hidden = true; result = null; answer.textContent = ''; setBusy(false);
status.textContent = 'Use a JPEG, PNG or WebP of at most 10 MB.'; return;
}
void selectImage(file);
};
sample.onclick = async () => {
const version = ++inputVersion;
setBusy(true);
try {
const response = await fetch('/sample.png'); if (!response.ok) throw new Error('Sample unavailable');
const blob = await response.blob(); if (version !== inputVersion) return;
await selectImage(blob);
} catch { if (version === inputVersion) { setBusy(false); status.textContent = 'Sample unavailable. Choose a local image.'; } }
};
prompt.oninput = () => setBusy(busy);
cancel.onclick = () => { inputVersion++; stop(); status.textContent = 'Cancelled. Run again when ready.'; };
run.onclick = async () => {
if (busy || !image || !prompt.value.trim()) return;
result = null; answer.textContent = ''; setBusy(true);
status.textContent = 'Checking this browser…';
const version = ++inputVersion;
const capability = await checkBrowserCapability();
if (version !== inputVersion) return;
if (!capability.supported) { setBusy(false); status.textContent = 'WebGPU with shader-f16 is required. See the Python guide below.'; return; }
resultPrompt = prompt.value.trim();
try {
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }: MessageEvent<WorkerResponse>) => {
if (version !== inputVersion) return;
if (data.type === 'loading') status.textContent = 'Downloading/preparing model…';
if (data.type === 'progress') status.textContent = `Model files read: ${data.loadedMB} MB; preparing model…`;
if (data.type === 'running') status.textContent = 'Reading image…';
if (data.type === 'text') answer.textContent = data.text;
if (data.type === 'result') {
result = data; answer.textContent = data.text; setBusy(false);
status.textContent = `Preparation: ${data.timing.load_ms} ms · First text: ${data.timing.first_text_ms ?? '—'} ms · Image to answer: ${data.timing.inference_ms} ms`;
}
if (data.type === 'error') { status.textContent = `Failed during ${data.phase}: ${data.message}`; stop(); }
};
worker.onerror = () => { if (version === inputVersion) { status.textContent = 'Worker failed. Retry or use local Python.'; stop(); } };
worker.postMessage({ image, prompt: resultPrompt });
} catch { status.textContent = 'Could not start the worker.'; stop(); }
};
exportButton.onclick = () => {
if (!result) return;
const url = URL.createObjectURL(new Blob([JSON.stringify({ ...result, prompt: resultPrompt }, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = 'fastvlm-result.json'; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
void checkBrowserCapability().then(value => {
document.querySelector('#capability')!.textContent = value.supported ? 'WebGPU and shader-f16 available. Free GPU memory is also required.' : 'This browser does not meet the WebGPU / shader-f16 requirements. See the Python guide below.';
});
window.addEventListener('pagehide', () => { inputVersion++; stop(); });
ソースを読む: src/worker.ts
src/worker.ts
import { AutoModelForImageTextToText, AutoProcessor, RawImage, TextStreamer, env } from '@huggingface/transformers';
import type { LlavaProcessor, PreTrainedModel, Tensor } from '@huggingface/transformers';
import type { RunRequest, WorkerResponse } from './protocol';
env.allowLocalModels = false;
const modelId = 'onnx-community/FastVLM-0.5B-ONNX';
let model: PreTrainedModel | null = null;
let processor: LlavaProcessor | null = null;
let busy = false;
const send = (message: WorkerResponse) => self.postMessage(message);
self.onmessage = async ({ data }: MessageEvent<RunRequest>) => {
if (busy) return;
busy = true;
let phase: 'load' | 'inference' = 'load';
const loadStart = performance.now();
try {
if (!model || !processor) {
send({ type: 'loading' });
const files = new Map<string, number>();
let lastMB = -1;
const progress_callback = (info: { status: string; file?: string; loaded?: number }) => {
if (info.status !== 'progress' || !info.file || typeof info.loaded !== 'number') return;
files.set(info.file, info.loaded);
const loadedMB = Math.floor([...files.values()].reduce((sum, bytes) => sum + bytes, 0) / 1_000_000);
if (loadedMB !== lastMB) { lastMB = loadedMB; send({ type: 'progress', loadedMB }); }
};
processor = await AutoProcessor.from_pretrained(modelId, { progress_callback }) as LlavaProcessor;
model = await AutoModelForImageTextToText.from_pretrained(modelId, {
progress_callback,
device: 'webgpu',
dtype: { embed_tokens: 'fp16', vision_encoder: 'q4', decoder_model_merged: 'q4' },
});
}
const loadMs = Math.round(performance.now() - loadStart);
send({ type: 'loaded', duration: loadMs });
phase = 'inference';
send({ type: 'running' });
const start = performance.now();
const image = await RawImage.fromURL(data.image);
const prompt = processor.apply_chat_template([
{ role: 'system', content: 'You are a helpful visual assistant. Answer the question accurately and concisely.' },
{ role: 'user', content: `<image>${data.prompt}` },
], { add_generation_prompt: true });
const inputs = await processor(image, prompt, { add_special_tokens: false });
let firstText: number | null = null;
let streamed = '';
const streamer = new TextStreamer(processor.tokenizer!, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: (chunk: string) => {
if (chunk && firstText === null) firstText = performance.now() - start;
streamed += chunk;
send({ type: 'text', text: streamed });
},
});
const output = await model.generate({ ...inputs, max_new_tokens: 192, do_sample: false, repetition_penalty: 1.2, streamer }) as Tensor;
const inputLength = inputs.input_ids.dims.at(-1)!;
const generated = output.slice(null, [inputLength, null]);
const text = processor.batch_decode(generated, { skip_special_tokens: true })[0].trim();
send({ type: 'result', model: modelId, text, timing: {
load_ms: loadMs,
inference_ms: Math.round(performance.now() - start),
first_text_ms: firstText === null ? null : Math.round(firstText),
generated_tokens: generated.dims.at(-1) ?? null,
} });
} catch (error) {
// No input or raw exception text leaves the worker in telemetry.
send({ type: 'error', phase, message: error instanceof Error ? error.message : 'Inference failed' });
if (phase === 'load') { model = null; processor = null; }
} finally { busy = false; }
};
ビルドと配置
npm run build の dist/ を JavaScript・WASM と一緒に HTTPS サイトへ配置。モデルは訪問者のブラウザが取得します。
実行できない場合
- shader-f16 がない場合はモデル取得前に Python を案内。
- 取得バイト数と初期化を区別。総量不明なら割合を作らない。
- GPU メモリ不足では他のモデルタブを閉じる。
- Worker / WASM の404は dist の全ファイルと base path を確認。
一次資料: Transformers.js WebGPU · ONNX model · Example source