Anime AR Stock

Vol.4

十二支の印を CNN で分類する — RandomForest から 6,267 枚の CNN まで

  • TF.js
  • CNN
  • Keras
  • on-device
描画FPSと推論FPS描画 rAF ~60fpsCNN 推論 125ms ≒ 8fps0ms250ms500ms
図4. 描画は毎フレーム、CNN は 125ms 間隔。熱とメインスレッドを守る。

モデルの変遷(学習ログより)

training/artifacts に残っている学習ログの値です。精度はいずれも学習データからランダムに 20% を取り分けた検証データでの値です。

  • RandomForest(2026-05-18): ランドマーク由来の 132 次元特徴量。1,268 サンプルで 88.6%、2,400 サンプルで 97.5%。
  • MLP(2026-05-20〜24): 64px の手の切り抜き画像を sklearn で。2,401 → 3,433 サンプルで 86〜88% 前後。
  • CNN(2026-06-28): ImageNet 学習済みの MobileNetV2 を凍結してベースにした転移学習、128px 入力。同じ 3,433 サンプルで 99.1%。
  • CNN(2026-09-22、現行): 6,267 サンプルで 99.4%。クラスごとの枚数は最少の「未」253 枚から最多の「丑」771 枚まで偏りがある。

MLP と CNN を同じ 3,433 サンプルで比べると 87.9% → 99.1% で、画像の空間構造を使えるかどうかの差がはっきり出ています。

ただし、この 99% 台をそのまま実力とは見ていません。検証データは同じ撮影セッションの連続フレームからランダムに抜いているので、学習データとほぼ同じ画像が検証側にも入ります。初めて使う人・初めての部屋の照明での精度は、この数字より低いはずです。

推論の頻度: 125ms 間隔、発動中は停止

印は数百 ms 構えて初めて意味を持つので、毎フレーム推論する必要はありません。HAND_SIGN_INFERENCE_INTERVAL_MS = 125(約 8fps)を tick() の中で守り、呼び出し側は毎フレーム tick() を呼ぶだけにしています。softmax の信頼度が 0.45 未満のラベルは捨てます。

もう一つの節約が「術の発動中は推論しない」ことです。螺旋丸が手の上で回っている間に印を認識しても使い道がないうえ、CNN と VFX(Bloom 付きの Three.js)が同じメインスレッドと GPU を取り合います。セッションフックは tick() に pauseInference を渡し、Call Studio でも「術の再生中は CNN を止める」変更を 2026-09-23 に入れています。

lib/handSignInferenceConfig.ts / hooks/useRasenganSession.ts
1export const HAND_SIGN_INFERENCE_INTERVAL_MS = 125; // ~8 fps2export const HAND_SIGN_CONFIDENCE_THRESHOLD = 0.45;3export const HAND_SIGN_MODEL_TARGET_BYTES = 1 * 1024 * 1024; // 目標 1MB4export const HAND_SIGN_MODEL_MAX_BYTES = 5 * 1024 * 1024;    // 警告 5MB5 6// useRasenganSession: 術の発動中は推論を止める7const pauseInference = trigger.getPhase() === 'rasenganActive';8classifier.tick(sourceVideo, inferenceLandmarks, now, pauseInference, false);

ハマった点: エラーも出さずにタブが固まる

2026年8月、狐の印(chainsaw_kon)用に学習し直した CNN を読み込むと、タブが完全に応答しなくなりました。エラーもコンソール出力も無く、ただ固まります。

原因は Keras → TF.js の変換経路にありました。学習スクリプトは TF_USE_LEGACY_KERAS=1 で旧 Keras から書き出しており、その経路では凍結した MobileNetV2 のベースが「入れ子の Functional モデル」として出力されます。TF.js で読める形にするため、入れ子のレイヤーをトップレベルへ引き上げる(hoist する)パッチを自前で当てていましたが、参照先のレイヤー名だけを書き換え、node_index(多くは 1)を残していました。引き上げた後のレイヤーは 1 回しか呼ばれない(node 0 しか無い)ので、存在しないノードを指す参照が残り、TF.js のトポロジカルなモデル構築がそれを解決しようとして無限ループしていました。

lib/tfjsModelLoad.ts(replaceInboundLayerRef の要点)
1for (const connection of node) {2  if (Array.isArray(connection) && connection[0] === fromName) {3    connection[0] = toName;4    // 引き上げたレイヤーは node 0 しか持たない。古い index を残すと5    // 存在しないノードを指し、TF.js のモデル構築がエラーを出さずに止まる6    if (resetNodeIndex && connection.length >= 2) {7      connection[1] = 0;8    }9  }10}

修正はブラウザ側のパッチ(tfjsModelLoad.ts)と、学習時に書き出す Python 側(train_hand_sign_model.py)の両方に入れました。片方だけ直すと、次に学習し直したモデルでまた再発するからです。既存の十二支モデルと杖モデルは Keras 3 から書き出していたので、この経路を通らず影響がありませんでした。

実装から得た知見

  • 検証精度 99% は「同じセッションの別フレーム」での数字にすぎない。連続フレームをランダム分割すると、検証は簡単になりすぎる。
  • 同じデータで MLP 87.9% → CNN 99.1%。手の重なりを見分けるタスクでは、モデル構造の選択がデータ量より効いた。
  • 一番安い推論は「しない推論」。間引き(125ms)に加えて、結果を使わない時間帯(術の発動中)は止める。
  • Keras → TF.js 変換の不具合はエラーにならずに固まることがある。モデル読み込みには必ずタイムアウトを付け、変換パッチは学習側とブラウザ側で同じ修正を入れる。