Anime AR Stock

Vol.1

最初はサーバーで推論していた — anime-ar-stock がブラウザ完結に移るまで

  • Architecture
  • TF.js
  • Firebase Hosting
  • Cloud Run

ブラウザ — 公開 AR 体験

カメラgetUserMedia
MediaPipe Hands手検出
TF.js CNN印認識
Three.jsVFX

この4段は同じブラウザの中。推論は TF.js が実行する。

Firebase Hosting

静的配信HTML / JS / model.json / assets

Hosting 自身は推論しない。モデルを配り、推論は上のブラウザが実行する。

Cloud Run

体験ループの外Stripe / Admin / GA4

Camera → Cloud Run という矢印はない。公開 AR はこのサービスを呼ばない。

図1. 公開体験はブラウザの枠の中で閉じる。Hosting はファイルを配る。Cloud Run へカメラは繋がっていない。

この記事の結論

anime-ar-stock は、最初からブラウザ完結だったわけではない。2026年5月の試作では、カメラから切り抜いた手画像をサーバーへ送り、Express の先にある Python(sklearn の MLP)で印を分類していた。

AR として実際に使うと、効いてきたのはモデルの推論速度そのものより、1 回ごとの「切り抜き → encode → POST → Python で推論 → JSON が返る」という往復だった。印を結んでから術が出るまでの体感に、その往復が乗った。内訳(encode が何 ms で、HTTP が何 ms で、Python が何 ms か)は計測していない。

そのため 2026-06-28 に Keras の CNN を TF.js へ移した。今の公開体験は、カメラ → MediaPipe の手検出 → TF.js の印認識 → Three.js の VFX まで、ブラウザの中で閉じている。Firebase Hosting は HTML / JS / model.json / アセットを配る。Hosting 自身は推論しない。推論は、配信されたモデルを読んだユーザーのブラウザが実行する。

実際にたどった構成の変遷

コミット履歴と training/artifacts の学習ログに残っている順は、次のとおり。精度の話は Vol.4 に譲る。ここでは推論をどこで走らせていたかを書く。

  • 2026-05-18: MediaPipe のランドマークから作った 132 次元の特徴量を RandomForest で分類(1,268 → 2,400 サンプル)。成果物は training/artifacts/rf-20260518T*.json。
  • 2026-05-20〜24: 64px の手の切り抜き画像を sklearn の MLP で分類(mlp-20260520T*.json の imageSize は 64、最後の学習は 2026-05-24)。ブラウザは /api/predict-hand-sign-image に imageBase64 を POST し、Express がローカルの Python サービスへ渡していた。
  • 2026-06-28: 128px 入力の Keras CNN を TF.js に変換し、端末内推論へ移した(2665e95。同じコミットで Firebase のデプロイパイプラインを追加)。当日の学習ログ cnn-20260628T083326Z.json の imageSize は 128。
  • 現在: sklearn + Python API の経路は残している。isLocalDevBackendInferenceEnabled() は import.meta.env.DEV のときだけ true で、本番ビルドでは使わない。本番の metadata.json は backend: "tfjs-cnn"、imageSize: 128。

サーバー推論で問題になったこと

印は数百 ms 構えて初めて意味を持つので、分類そのものは 1 秒に数回で足りる、という判断は当時からあった(間隔の定数は Vol.4)。使ってみて問題になったのは、その数回のそれぞれに往復が付くことだった。切り抜いて、encode して、POST して、Python が推論して、JSON が返る。ネットワークの揺れは、印を結んでから画面が反応するまでの時間にそのまま入る。

もう一つは、公開の仕方との相性だった。公開ページはログイン不要で開ける(ログイン必須を外したのは 2026-09-06、95aff58)。誰でも開ける体験で、開いた人数に比例して推論リクエストが増える形は、サーバー側の推論コストと相性が悪かった。カメラの切り抜きがサーバーに届くことも、説明が増える。

往復の何 ms が体験を悪くしたかは、ログに残っていない。コードに残っているのは呼び出し間隔だけだ。2026-05-21(4014229)の画像推論は INFERENCE_INTERVAL_MS = 150。現行は HAND_SIGN_INFERENCE_INTERVAL_MS = 125 で、コメントの目標は約 8fps。どちらもスロットルであって、1 回の所要時間ではない。下の表で「未計測」と書いた行は、その欠測のままにしてある。

項目サーバー推論(2026-05)ブラウザ TF.js(現行の公開体験)
入力64px crop(MLP の imageSize)128px crop(model.json の InputLayer と metadata)
推論場所Express → Python(sklearn MLP)ブラウザの TF.js
ネットワーク往復あり。手画像を POST公開体験ループにはない
呼び出し間隔150ms(当時のスロットル)。達成した fps は未計測125ms(現行のスロットル。目標は約 8fps)。1 回の時間は未計測
モデルの置き場所サーバー側の joblib(training/artifacts)Hosting が約 2.4MB を配信し、ブラウザが読む
推論コストの負担サーバー(リクエストごと)利用者の端末
カメラ画像の送信あり(imageBase64)公開体験では送らない。DEV の sklearn 経路だけ残している
レイテンシの内訳未計測(encode、HTTP、Python 推論、印から HUD まで)未計測(CNN 単体、crop、印から HUD まで)。公開体験に HTTP / Python の経路はない

比較した選択肢

A. サーバー推論(5月に実装済み)
メリット
Python の sklearn をそのまま使える。モデルの差し替えはサーバー側の joblib を置くだけで済む。
デメリット
推論ごとに往復が入る。利用者数に比例して推論コストが増える。映像の切り抜きがサーバーに届く。
B. ブラウザで TF.js 推論採用
メリット
推論ごとの往復がない。計算は利用者の端末。Hosting はファイルを配るだけで、推論はブラウザの TF.js が行う。
デメリット
モデルを毎回ダウンロードさせる。端末差(GPU・発熱)を自分で吸収する必要がある。Keras → TF.js 変換の罠を踏む(Vol.4)。
C. ネイティブアプリ
メリット
カメラ・推論・音声の API を自由に使える。
デメリット
インストールの手間が入る。「リンクを開けばすぐ試せる」という体験の入口が失われる。実装はしていない。

採用理由: MediaPipe はすでにブラウザで動いていたので、CNN も寄せればカメラから描画までが 1 台で閉じる。2026-06-28 に B を採った。体感に乗っていたのは往復で、Cloud Run の請求や「本番コンテナに Python を置かない」は同じ移行の中にある。どれが何割だったかは切り分けていない。

現在の構成

firebase.json が、公開体験とそれ以外の境界になっている。frontend/dist は Firebase Hosting から静的配信する。/api/** と /admin、/admin/** だけを Cloud Run(asia-northeast1 のサービス anime-ar-engine)へ rewrite する。/models/** の Cache-Control は public, max-age=3600。

カメラ → 手検出 → 印認識 → VFX は、この rewrite を通らない。Hosting が配るのは HTML / JS / model.json / アセットで、Hosting のプロセスが推論するわけではない。model.json を読んだブラウザの TF.js が推論する。

Cloud Run 側で本番が受けているのは、Stripe の決済と、GA4 を読む管理ダッシュボード。手の切り抜きや杖注釈を保存する Express の API は同じプロセスにあるが、NODE_ENV=production では ENABLE_TRAINING_API がない限り 403 になる。本番の杖注釈は Cloud Run ではなく、管理画面から Firebase Storage と Firestore に書く(usesFirebaseWandAnnotations() が PROD で true)。

公開体験の 1 フレームは、ブラウザの中で次の順に流れる。Hands のランタイム差は Vol.3、CNN の 125ms 間引きと発動中の停止は Vol.4。ここは呼び出しの並びだけを示す。

公開体験の 1 フレーム(要点)。実装は hooks/useRasenganSession.ts
1hands.onResults((results) => {2  const trackedHands = parseTrackedHandsFromResults(results);3  classifier.tick(video, landmarks, now, pauseInference);4  layer.renderFrame(anchor, now);5});

ブラウザ完結にした代償

公開体験から、サーバーへの推論リクエストはなくなった。負担は利用者の端末に移った。計算、モデルファイルのダウンロード、GPU と CPU の差、発熱、そして 3D アセットの初回ダウンロードが、端末側に残っている。

public/models のファイルサイズ(転送時に圧縮されたあとのサイズは未計測)は次のとおり。

  • 十二支の印 CNN(hand-signs): 重み 2,273,356 bytes(2.27MB)+ model.json 152,608 bytes(149KB)≒ 2.4MB
  • 杖のキーポイントモデル(wand): 重み 2,371,569 bytes(2.37MB)+ model.json 111,566 bytes(109KB)≒ 2.5MB
  • 穢土転生のキャラクター GLB: character1 37.0MB / character2 16.7MB / character3 18.6MB

印 CNN の約 2.4MB は、handSignInferenceConfig.ts の目標 HAND_SIGN_MODEL_TARGET_BYTES(1MB)を超えている。デプロイ前の scripts/validate-deploy-models.sh が警告する 5MB よりは小さい。杖モデルも同程度の 2.5MB。一方 GLB は 1 体で 16.7〜37.0MB ある。ブラウザ完結にしたあとの初回ロードは、ML モデルより 3D アセットが支配し始めている。

実装から得た知見

  • 印認識は「推論が重いか」より「1 回ごとにネットワーク往復が入るか」で体験が変わった。数回/秒で足りる処理でも、往復が付くなら端末に寄せる判断になった。往復の ms は未計測のまま、この判断をしている。
  • サーバー推論の経路は捨てずに DEV 限定で残した。学習データを集めている間は、Python 側でモデルを差し替える方が速い。
  • ブラウザに移すと、サーバーの推論コストはクライアントの計算、モデルのダウンロード、GPU / CPU 差、発熱、3D アセットのダウンロードへ移る。今のファイルサイズでは、CNN 約 2.4MB と杖モデル約 2.5MB より、GLB の 37.0MB / 16.7MB / 18.6MB の方が初回ロードを支配し始めている。
  • Cloud Run は推論のためには残していない。秘密が要る処理(Stripe、管理ダッシュボード)をサーバーに置くと、フロントに秘密を置かずに済む。学習データ用の API は本番では止めてある。
  • サーバー代が単純に消えたわけではない。計算と、通信と、アセット配信の責任を、どこに置くかが変わった。