Skip to content

FASTVLM ガイド · Browser

FastVLM WebGPU 画像アプリを作る

Vite の完全な例で、ローカル画像質問、逐次回答、キャンセル、JSON 保存を実装します。

FastVLM ガイド一覧

手順

  1. 1

    WebGPU と shader-f16 を確認して Node.js 22+ を準備。

  2. 2

    例を取得・展開し、依存を入れて Vite を起動。

  3. 3

    レシート例で Run し $15.00 を確認。

  4. 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 と再実行も確認してください。

アプリの仕組み

  1. メインスレッドは画像をデコードして長辺1024 pxに制限し、画像と質問を Worker に送ります。
  2. Worker はモデルを必要時に読み込み、回答を逐次送信。最後に回答と時間を返し、次の質問でモデルを再利用します。
  3. 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 を確認。
Python で実行 →

一次資料: Transformers.js WebGPU · ONNX model · Example source

一次情報で確認

API、モデルファイル、依存バージョンは変わる可能性があります。このページを実装マップとして使い、リンク先の公式文書で現在のコマンドとライセンスを確認してください。

公式ソースを開く