Transformers.js v4入門|WebGPUで画像分類AIをブラウザ実行する

写真をAIで分類:猫の写真とTransformers.js・WebGPUの見出し ブラウザAI
カテゴリー
ブラウザAI
公開日
2026.09.20

はじめに

Webサイトへ画像認識機能を追加するとき、一般的には画像をクラウド上のAI APIへ送信し、サーバーから判定結果を受け取ります。

一方、Transformers.jsを使うと、対応する学習済みモデルをWebブラウザへ読み込み、利用者の端末内で推論を実行できます。WebGPUに対応した環境ではGPUを利用でき、対応していない環境ではWebAssembly(WASM)によるCPU実行へ切り替えられます。

この記事では、HTML、CSS、JavaScriptの3ファイルで、次の画像分類アプリを作ります。

  • 利用者が端末内の画像を選択する
  • 選択した画像をプレビューする
  • AIモデルのダウンロード進捗を表示する
  • 画像に写っているものの候補を確率順に表示する
  • WebGPUの初期化に失敗した場合はWASMへ切り替える
  • 未選択、画像以外のファイル、推論失敗を画面に表示する

Python、APIキー、AI用のバックエンドサーバーは使用しません。

Transformers.jsとは

Transformers.jsは、Hugging Faceが公開している、学習済みTransformerモデルをJavaScriptから実行するためのライブラリです。

自然言語処理だけでなく、画像分類、物体検出、音声認識、埋め込みベクトル作成など複数のタスクに対応しています。内部では、ブラウザ向けの推論基盤としてONNX Runtimeを利用します。

Python版のtransformersと名前は似ていますが、インストールするパッケージは別です。

Python版:       transformers
JavaScript版:  @huggingface/transformers

本記事ではJavaScript版の4.2.0を使用します。古い記事で見かける@xenova/transformersではなく、現在の公式パッケージ名である@huggingface/transformersを指定してください。

WebGPUとWASMの役割

Transformers.jsのpipeline()device: "webgpu"を渡すと、対応するモデルの演算をWebGPUで実行します。

const classifier = await pipeline("image-classification", MODEL_ID, {
  device: "webgpu",
});

ただし、navigator.gpuが存在するだけで、必ず推論に成功するとは限りません。GPUドライバー、モデルが使う演算、ブラウザ実装、使用できるGPUメモリなどによって、初期化中や推論中に失敗する場合があります。

そのため今回のアプリでは、次の順序で実行基盤を決めます。

  1. navigator.gpuが存在すればWebGPUでモデルを初期化する
  2. WebGPU初期化が失敗したらWASMで初期化し直す
  3. navigator.gpuがなければ最初からWASMを使う

WASM実行はCPUを使用するため、一般にWebGPUより時間がかかる可能性があります。しかし、実際の速度はモデル、GPU、CPU、ブラウザ、量子化方式によって変わります。「WebGPUなら必ず何倍速い」と固定値で説明することはできません。

必要な環境

  • Windows 11、macOS、LinuxなどのモダンなOS
  • ES Modulesを利用できるブラウザ
  • 初回のモデル取得に必要なインターネット接続
  • ローカルHTTPサーバー
  • Node.js 22.12以降の22系、または24系(Viteの実行環境)

WebGPUの利用には対応ブラウザとGPUが必要です。WASMへフォールバックできるため、WebGPUがない端末でもアプリ自体は利用できます。

なお、モデル取得後の推論処理はブラウザ内で行われますが、この構成が完全なオフラインアプリという意味ではありません。初回表示時にはJavaScriptとモデルをCDNやHugging Face Hubから取得します。また、実際のサイトにアクセス解析や外部送信処理を追加すれば、その通信は別途発生します。

プロジェクトを作成する

PowerShellで作業用フォルダーを作成します。

mkdir transformers-image-classifier
cd transformers-image-classifier
npm init -y
npm install @huggingface/transformers@4.2.0
npm install --save-dev vite@7.3.6

次の3ファイルを作成します。

transformers-image-classifier/
├─ index.html
├─ style.css
└─ main.js

HTMLを作成する

index.htmlへ次を記述します。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ブラウザ画像分類</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <main>
      <h1>ブラウザ画像分類</h1>
      <p>画像は推論APIへ送信せず、このブラウザ内で分類します。</p>

      <label for="image-file">分類する画像</label>
      <input id="image-file" type="file" accept="image/*" />
      <button id="classify-button" type="button">画像を分類する</button>

      <p id="status" role="status" aria-live="polite">モデルは未読み込みです。</p>
      <progress id="progress" max="100" value="0" hidden></progress>

      <img id="preview" alt="選択した画像のプレビュー" hidden />

      <h2>判定結果</h2>
      <ol id="results"></ol>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

role="status"aria-live="polite"を付けることで、モデルの読み込み状況やエラーが変化したとき、支援技術へ通知しやすくしています。

見た目を整える

style.cssへ次を記述します。

:root {
  font-family: system-ui, sans-serif;
  color: #1f2937;
  background: #f8fafc;
}

body {
  margin: 0;
}

main {
  width: min(720px, calc(100% - 32px));
  margin: 40px auto;
}

input,
button {
  display: block;
  margin-top: 12px;
}

button {
  padding: 10px 16px;
  border: 0;
  border-radius: 6px;
  color: white;
  background: #1d4ed8;
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.6;
}

progress,
#preview {
  width: 100%;
  margin-top: 16px;
}

#preview {
  max-height: 420px;
  object-fit: contain;
  background: white;
}

画像分類処理を実装する

main.jsへ次を記述します。

import { pipeline } from "@huggingface/transformers";

const MODEL_ID =
  "onnx-community/mobilenetv4_conv_small.e2400_r224_in1k";

const fileInput = document.querySelector("#image-file");
const button = document.querySelector("#classify-button");
const status = document.querySelector("#status");
const progress = document.querySelector("#progress");
const preview = document.querySelector("#preview");
const results = document.querySelector("#results");

let classifierPromise;
let selectedObjectUrl;

function updateDownloadProgress(event) {
  if (event.status !== "progress" || !Number.isFinite(event.progress)) {
    return;
  }

  progress.hidden = false;
  progress.value = Math.max(0, Math.min(100, event.progress));
  status.textContent = `モデルを読み込んでいます: ${progress.value.toFixed(0)}%`;
}

async function createClassifier() {
  const options = {
    progress_callback: updateDownloadProgress,
  };

  if ("gpu" in navigator) {
    try {
      status.textContent = "WebGPUでモデルを初期化しています。";
      const classifier = await pipeline("image-classification", MODEL_ID, {
        ...options,
        device: "webgpu",
      });
      return { classifier, backend: "WebGPU" };
    } catch (error) {
      console.warn("WebGPU initialization failed. Falling back to WASM.", error);
      status.textContent = "WebGPUを利用できなかったためWASMへ切り替えます。";
    }
  }

  const classifier = await pipeline("image-classification", MODEL_ID, {
    ...options,
    device: "wasm",
    dtype: "q8",
  });
  return { classifier, backend: "WASM" };
}

function getClassifier() {
  classifierPromise ??= createClassifier().catch((error) => {
    classifierPromise = undefined;
    throw error;
  });
  return classifierPromise;
}

function renderResults(predictions) {
  results.replaceChildren();

  for (const prediction of predictions) {
    const item = document.createElement("li");
    const percent = (prediction.score * 100).toFixed(1);
    item.textContent = `${prediction.label}: ${percent}%`;
    results.append(item);
  }
}

fileInput.addEventListener("change", () => {
  const file = fileInput.files?.[0];

  if (selectedObjectUrl) {
    URL.revokeObjectURL(selectedObjectUrl);
    selectedObjectUrl = undefined;
  }

  results.replaceChildren();

  if (!file) {
    preview.hidden = true;
    return;
  }

  if (!file.type.startsWith("image/")) {
    fileInput.value = "";
    preview.hidden = true;
    status.textContent = "画像ファイルを選択してください。";
    return;
  }

  selectedObjectUrl = URL.createObjectURL(file);
  preview.src = selectedObjectUrl;
  preview.hidden = false;
  status.textContent = "画像を選択しました。";
});

button.addEventListener("click", async () => {
  const file = fileInput.files?.[0];

  if (!file || !selectedObjectUrl) {
    status.textContent = "先に画像ファイルを選択してください。";
    return;
  }

  button.disabled = true;
  fileInput.disabled = true;
  const imageUrl = selectedObjectUrl;
  results.replaceChildren();

  try {
    const { classifier, backend } = await getClassifier();
    progress.hidden = true;
    status.textContent = `${backend}で画像を分類しています。`;

    const predictions = await classifier(imageUrl, { top_k: 5 });
    renderResults(predictions);
    status.textContent = `${backend}で分類が完了しました。`;
  } catch (error) {
    console.error(error);
    status.textContent =
      "分類に失敗しました。通信状態、対応ブラウザ、空きメモリを確認してください。";
  } finally {
    button.disabled = false;
    fileInput.disabled = false;
    progress.hidden = true;
  }
});

コードのポイント

モデルの初期化を1回だけ行う

モデルはファイルサイズが大きく、初期化にも時間がかかります。クリックのたびにpipeline()を呼び出すと、同じページ内でモデルを何度も準備することになります。

そこでclassifierPromiseへ初期化中または初期化済みのPromiseを保存しています。

classifierPromise ??= createClassifier();

初期化が失敗したときは値をundefinedへ戻し、通信状態などを改善した後に再試行できるようにします。

Object URLを解放する

URL.createObjectURL()で作成したURLは、ファイルデータへの参照を保持します。新しい画像を選んだときに古いURLをURL.revokeObjectURL()で解放し、不要なメモリ保持を避けます。

結果をinnerHTMLへ入れない

分類ラベルはモデルから返る文字列です。今回のコードではtextContentを使い、文字列をHTMLとして解釈しないようにしています。

アプリを起動する

PowerShellで次を実行します。

npx vite

表示されたローカルURLをブラウザで開き、写真を選択して「画像を分類する」を押します。

初回はライブラリとモデルの取得に時間がかかります。分類結果が表示されるまで待ってください。進捗は取得中のファイル単位で変わるため、100%の表示だけでは全体の完了を意味しません。処理中は画像を変更できないようにしています。

期待する表示例は次のとおりです。

tiger, Panthera tigris: 61.5%
tiger cat: 30.1%
tabby, tabby cat: 0.2%

同じ画像でも、モデル、量子化方式、ライブラリのバージョンによって確率が変わる可能性があります。記事内の数値と完全一致することを正常動作の条件にはしません。

動作確認で見る項目

正常系

  1. JPEG、PNG、WebP画像を選択できる
  2. 選択画像がプレビューされる
  3. 初回のみモデル読み込み進捗が表示される
  4. 判定候補が最大5件表示される
  5. 2回目の分類では同じモデルインスタンスを再利用する

エラー系

  1. 画像を選択せずボタンを押す
  2. ファイル選択をキャンセルする
  3. 開発者ツールでオフラインにして初回モデル取得を試す
  4. WebGPUを無効にしてWASMへ切り替わることを確認する
  5. 処理中にボタンを連打できないことを確認する

よくあるトラブル

navigator.gpuがない

ブラウザまたは実行環境がWebGPUに対応していません。今回のコードではWASMへ切り替わります。WebGPUのためだけに、一般利用者へ実験的なブラウザフラグ変更を強制する構成は避けた方が安全です。

モデルのダウンロードが止まる

モデルはHugging Face Hubから取得します。ネットワーク、プロキシ、コンテンツブロッカー、CDN障害などを確認してください。ブラウザの開発者ツールにあるNetworkパネルを見ると、失敗したファイルを確認できます。

WebGPUでは失敗するがWASMなら動く

WebGPU APIの存在確認と、対象モデルを正常に実行できることは別です。GPU初期化、演算子、メモリなどが原因になり得ます。今回のように初期化そのものをtry...catchで囲み、WASMへ切り替えます。

初回表示が重い

ブラウザ内AIでは、利用者の端末へモデルを取得する必要があります。軽いモデルを選ぶ、読み込み前にデータ量を説明する、必要になってから初期化する、再訪問時のキャッシュを活用するといった設計が必要です。

向いている用途と向いていない用途

向いている用途は、画像を外部の推論APIへ送りたくない簡易分類、オフライン対応を検討するPWA、学習用デモ、API利用料を抑えたい小規模処理などです。

一方、低性能端末を含む全利用者へ一定の速度を保証したい場合、大規模モデルが必要な場合、モデルを利用者へ配布できない場合、サーバー側で結果を一元管理したい場合には向きません。

また、端末内推論は入力画像をサーバーへ送らない構成を作りやすいものの、Webアプリ全体のプライバシーを自動的に保証するわけではありません。外部スクリプト、ログ、解析ツールを含めて通信を確認してください。

関連記事

まとめ

Transformers.js v4では、pipeline()を使って画像分類モデルをJavaScriptから実行できます。

実用的なWebアプリにするには、成功時のコードだけでなく、モデル読み込み状況、二重初期化の防止、WebGPU失敗時のWASMフォールバック、入力エラー、モデル取得失敗まで扱うことが重要です。

次の記事では、Transformers.jsで日本語文章の埋め込みベクトルを作り、ブラウザだけで意味検索を実装します。

参考リンク

コメント

タイトルとURLをコピーしました