0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NARUTO風の「印」を結ぶとブラウザで術が出るゲームをAI駆動開発で作った話

0
Posted at

NARUTO風の「印」を結ぶとブラウザで術が出るゲームを Claude Code と一緒に作った

Webcam の前で干支 12 種類のハンドシールを結ぶと、ブラウザ上で「分身」「口寄せ」などの術エフェクトが発動するミニゲームを、Claude Code と一緒に作りました。

手の検出には MediaPipe Hand Landmarker、印の分類には MobileNetV3-Small を使っています。両手の ROI を切り出してから CNN に渡す構成にしたところ、最終的に test accuracy 94.8% 前後、macro F1 0.95 前後まで出ました。

ただし、データセットはまだ小さく、撮影環境もかなり限定的です。この記事の数字は「手元のテストセットではそれなりに動いた」くらいの温度感で読んでください。

配布は Cloudflare Pages。サーバー側で Python は動かさず、推論も演出もブラウザ内で完結させています。

リポジトリ:https://github.com/K5-DoM/Ninja-like_hands_seal
デモ:https://ninja-like-hands-seal.pages.dev/


要約

作ったものは、NARUTO ファンが「印を結べた!術が出た!」を体験できる Web ミニゲームです。

構成は次のとおりです。

  • 学習:PyTorch
  • 推論:ブラウザ上の TypeScript + onnxruntime-web
  • 手の検出:MediaPipe Tasks Vision / Hand Landmarker
  • デプロイ:Cloudflare Pages

今回のポイントは、主にこの 5 つでした。

  1. 背景や服装の影響を減らすため、CNN に渡す前に MediaPipe Hand Landmarker で両手 bbox をクロップした。
  2. 学習側と Web 側で クロップスケールが少しでもずれると精度が露骨に落ちることにハマった。
  3. 印ごとに結びやすさが違うので、accept / reject のしきい値を クラスごとに分けた
  4. TemporalSmoother で「N フレーム中 M フレーム以上、同じラベルが安定した瞬間」だけ trigger を発火させ、連発や暴発を防いだ。
  5. Gradio / Hugging Face Spaces 案をやめ、最終的に Cloudflare Pages で静的配信した。

1. 何を作ったのか

ブラウザで Webcam を許可してスタートすると、次のような流れで遊べます。

  1. お手本の印が画面右に表示される。例:寅 → 巳 → 未
  2. プレイヤーがその通りに印を結ぶ
  3. 1 印確定するごとに「◎」が付き、SE が鳴る
  4. 全部結び切ると、画面フラッシュ → 術スプライト再生 → SE が発動する

「認識精度を競うアプリ」というより、成功体験ファーストの Web ゲームとして作っています。

難易度は 4 段階です。

Lv 印数 シーケンス
1 3 分身の術 寅 → 巳 → 未
2 4 千手裏剣の術 寅 → 子 → 巳 → 寅
3 5 蛙喚びの術 亥 → 犬 → 酉 → 申 → 未
4 6 大火球の術 巳 → 未 → 申 → 亥 → 午 → 寅

寅の印

そのほかに、印を 1 つだけ判定する単印テストモードと、ランダム / 1→12 ループで遊べる Endless モードも用意しました。


2. 全体のパイプライン

推論時の流れは次のようになっています。

[Webcam frame]
     │
     ▼
MediaPipe Hand Landmarker       ← live_stream モード
     │  両手の landmarks → 両手を内包する bbox
     ▼
BoxSmoother (EMA, α=0.6)        ← bbox を時間方向に平滑化
     │
     ▼
shrinkBox(scale=0.7)            ← bbox を中心から縮小
     │
     ▼
crop & resize 224x224
     │
     ▼
MobileNetV3-Small (ONNX)        ← FP32
     │  softmax → top-1, top-2
     ▼
Acceptance check                ← per-seal threshold + margin
     │
     ▼
TemporalSmoother (window=7, min_count=5)
     │  ラベルが安定し、変化した瞬間だけ trigger=true
     ▼
JutsuChallenge / EndlessChallenge → effect 再生

Webcam のフレーム全体をそのまま CNN に入れるのではなく、まず MediaPipe で両手を見つけ、両手を含む ROI だけを切り出してから分類器に渡しています。


3. データセット

撮影画像は、次のような階層で保存しました。

captures/<set_id>/<class_id 2桁>/

規模は合計 1,200 枚前後です。12 クラスなので、1 クラスあたり約 100 枚になります。

class_id は十二支の順に "01""12" を割り当てています。

クラス数はコードにハードコードせず、captures/ 直下のディレクトリ名から動的に取得するようにしました。後から印を増やしたくなったとき、フォルダを追加するだけで学習を回せるようにしたかったためです。

結局、今回は 12 印のまま完成させましたが、この設計にしておいたのはよかったです。


4. 前処理:なぜ「両手 ROI」を切るのか

最初は、Webcam 画像全体をそのまま CNN に渡していました。

しかし、すぐに次のような問題が出ました。

  • 服の色や背景の家具がラベルと相関してしまう
  • 顔が映ると推論精度が大きく下がる
  • データセットに少ない構図になると、一気に挙動が不安定になる

典型的な「背景を見て当ててしまう」問題です。

そこで、MediaPipe Hand Landmarker で両手の landmarks を取り、両手を内包する bbox だけを CNN に渡すようにしました。学習側では prepare_hand_rois.pyhand_roi_manifest.csv を作り、Web 側でも同じ考え方で ROI を切っています。

この変更で、次のメリットがありました。

  • 背景や服装の情報を訓練信号からかなり削れる
  • 手が見つからないフレームを roi_ok=false として学習対象から除外できる
  • 学習側と推論側で同じ前処理を共有しやすい

ただし、ROI を切ればすべて解決するわけではありませんでした。

最初は、長方形に切り出した ROI をそのまま正方形にリサイズしていたため、画像が歪みます。するとモデルが「手の形」ではなく「歪み方」を手がかりにしてしまうような挙動が出ました。

そこで最終的には、最初から正方形に近い形でクロップし、中心から少し縮小した領域を学習データとして使う方針にしました。そのうえで、輝度・彩度・アフィン変換などのデータ拡張を強めにかけています。


5. モデル:MobileNetV3-Small を 2 段階 fine-tune

目標は、CPU のラップトップ PC でもブラウザ上で 10fps 程度の推論を回すことでした。

候補は次の 3 つです。

  • MobileNetV3-Small
  • EfficientNet-B0
  • ResNet-18

最終的には、速度と精度のバランスを見て MobileNetV3-Small + 224x224 にしました。

学習は 2 段階です。

  1. head-only:5 epoch。feature extractor を freeze して classifier だけ更新。
  2. full fine-tune:最大 12 epoch、patience=6。全層を解放して lr=2e-4、AdamW で更新。

ベスト epoch は validation macro F1 で選びました。

最終的に使った rgb_run_hand_010 の結果は次のとおりです。

metric value
test accuracy 0.9478
test macro F1 0.9505
mean confidence 0.978

データ拡張はかなり強めに入れています。

  • color jitter:brightness 0.4, contrast 0.3
  • affine:±12°, ±10% translate, scale 0.85–1.15, shear 5°
  • RandomResizedCrop:scale 0.7–1.0
  • RandomErasing:p=0.25

このあたりは、「強すぎて落ちる」「弱すぎて test に転移しない」を何度か行き来しました。最終的には、コミット 5d6763b データ拡張を強化。 のあたりで現在の設定に落ち着いています。


6. ONNX エクスポートと量子化

本番推論では PyTorch を使わず、モデルを ONNX にエクスポートして onnxruntime-web で動かしています。

静的配信の Web アプリに torch や Python 実行環境を持ち込むのは、依存関係の重さや起動時間の面で厳しいです。そのため、学習は PyTorch、本番推論は ONNX Runtime Web、という分担にしました。

最終的には FP32 の ONNX モデルを使っています。

INT8 量子化モデルも作ったのですが、手元の実機では精度がほぼ壊滅しました。原因の切り分けはまだできていません。少なくとも今回のゲームでは、モデルサイズ削減よりも体験の安定性を優先して、FP32 を採用しました。

通常の Wi-Fi 環境であれば、モデル読み込み込みでも 1 分以内には遊べるようになる体感です。


7. Web 側のリアルタイム推論ループ

Web 側のメインループは src/pipeline/pipeline.ts にあります。

requestAnimationFrame で毎フレーム呼び出しつつ、内部では INFER_PERIOD_MS=100 として 10fps にスロットリングしています。

ざっくり書くと、次のような構造です。

// 1) MediaPipe Hand Landmarker で box を取る(live_stream モード)
const handResult = this.hand.detect(this.video, nowMs);
const smoothedBox = this.boxSmoother.update(handResult.box ?? null);

// 2) 中心から cropScale で縮小する
const finalBox = shrinkBox(smoothedBox, w, h, this.flags.cropScale);

// 3) crop → 224x224 → CHW float32
const tensor = this.prep.toTensor(this.prep.crop(this.video, finalBox));

// 4) ONNX Runtime Web
const out = await this.session.run({ [this.inputName]: tensor });
const probs = softmax(out[this.session.outputNames[0]].data as Float32Array);

// 5) per-seal threshold で accept / reject
const accepted = computeAcceptance({ predLabel, predScore, predMargin, ... });

// 6) TemporalSmoother で trigger を出す
const sm = this.smoother.update(predLabel, predScore, accepted);
if (sm.trigger) challenge.feed(sm.stableLabel);

MediaPipe の video モードと live_stream モードの違いにもハマりました。

一度 revert して、再度直したりしました。モードの違いで bbox の出方がずれると、学習時の ROI と推論時の ROI が微妙に変わり、精度が落ちます。


8. ハマりどころ:学習と推論でクロップスケールがずれる

一番長くハマったのが、Python では当たるのに、ブラウザでは当たらないという問題でした。

原因は、Python 側と Web 側でクロップスケールが一致していなかったことです。

Python 側では、bbox を中心から縮小して CNN に渡していました。一方、Web 側ではその縮小処理が入っておらず、学習時より広い領域をモデルに渡していました。

この差がかなり効きます。クロップスケールが少しずれるだけで、体感でもはっきり分かるくらい精度が落ちました。

調査のために、Web 側に ablation スイッチを追加し、URL クエリや HUD から cropScale を切り替えながら比較しました。

ce02a28 debug: add web vs Python ROI comparison harness (Step 2-3)
55b8788 debug: make ablation flags runtime-toggleable in InferHud
54456b7 debug: cycle cropScale 0.80→0.75→0.70→0.65→0.60→OFF on toggle
93d1843 fix: set inference cropScale=0.6 as default for web webcam accuracy

その後の実機確認を経て、最終的には 推論時のデフォルトを cropScale=0.7 に固定しました。

ここでの教訓はシンプルです。

学習時の前処理と推論時の前処理は、気合いではなくテストで縛る。

Python と TypeScript のように別言語へ移植すると、こういう小さな差分が静かに入り込みます。そして、静かに精度を落とします。


9. Per-seal threshold:印ごとに結びやすさが違う

12 種類の印は、難しさが均等ではありません。

  • かなり安定して認識できる印
  • 他の印と紛れやすい印
  • そもそも人間がきれいに結ぶのが難しい印

が混ざっています。

一律のしきい値にすると、次のような問題が出ます。

  • 簡単な印:しきい値が低すぎて、チラつきや誤発火が増える
  • 難しい印:しきい値が高すぎて、いつまでも通らない

そこで、印ごとに accept / reject のしきい値を分けました。

export const ACCEPT_REJECT_THRESHOLD_BY_LABEL: Record<string, number> = {
  // very easy to recognise
  "02": 0.90,
  "03": 0.90,
  "05": 0.90,
  "11": 0.90,

  // easy to recognise
  "07": 0.80,
  "10": 0.80,

  // slightly easy
  "09": 0.70,
  "12": 0.70,

  // slightly difficult
  "08": 0.60,
  "01": 0.60,
  "04": 0.60,

  // difficult
  "06": 0.50,
};

この設定はかなり泥臭いですが、ゲーム体験としては重要でした。

「全クラス一律のしきい値で美しく解く」よりも、「印ごとに現実の難しさを認める」方が、最終的な手触りはよくなりました。


10. Trigger ロジック:TemporalSmoother

「しきい値を超えたら即発火」にすると、印を結ぶ途中の中間ポーズで暴発します。

そこで、TemporalSmoother を挟みました。

TemporalSmoother は、次の条件を満たした瞬間だけ trigger=true を返します。

  • roi_ok かつ detector_ok
  • pred_score >= reject_threshold
  • pred_margin >= margin_threshold
  • 直近 N=7 フレーム中、同じラベルが M=5 回以上出ている
  • そのラベルの平均スコアが threshold 以上
  • 直前の stable state からラベルが変化した瞬間である

最後の「ラベルが変化した瞬間だけ」という条件が、連発防止に効いています。

この層があるおかげで、ゲーム側はとてもシンプルに書けます。

if (sm.trigger) {
  challenge.feed(sm.stableLabel);
}

web/src/pipeline/smoother.ts では、Python 側の検証コードと同じセマンティクスになるように TypeScript で再実装しています。


11. ゲーム層:JutsuChallenge / EndlessChallenge

ゲームロジックは、推論パイプラインとは分けています。

JutsuChallenge は、期待する印のシーケンスと現在のインデックスを持っています。

  • feed(label) で期待値と一致したら progress
  • シーケンスを完走したら success
  • 違う印が来たら wrong
  • tick(now) で 3 秒経過したら timeout

EndlessChallenge は、random / sequential のどちらかで次の印を出し続け、間違えたら gameover にします。

UI 側は、これらのイベントを subscribe して演出を出すだけです。

  • progress:◎ を付けて SE
  • success:画面フラッシュ + 術スプライト再生 + SE
  • wrong / timeout:soft な「もう一回!」表示

認識ロジック、ゲームロジック、演出を分けておくと、後から術やモードを増やすのがかなり楽でした。


12. なぜ Gradio をやめて Cloudflare Pages にしたか

最初は Gradio + fastrtc / WebRTC で動かそうとしていました。

Hugging Face Spaces Static案を経て、最終的にCloudflare Pagesにしました。

理由は次のとおりです。

  • Webcam → 推論 → 演出のループは、クライアント完結が一番レイテンシを低くできる
  • Hugging Face Spaces の Gradio は cold start が気になる
  • Python 依存、特に torch / mediapipe / gradio を持ち込むと重い
  • Staticでも、米国のサーバからonnxだけでなくmediapipeの各種を送る必要があるため、日本での検証が難しい。
  • ゲーム的な 10fps 推論と WebRTC のフレーム取り回しが、今回の用途ではあまり相性よく感じなかった

最終的には、公開用リポジトリを GitHub に置き、Cloudflare Pages と連携して静的配信する形にしました。

これなら、サーバー側で Python を起動する必要がありません。モデルもフロントエンドも CDN から配られ、推論はユーザーのブラウザで完結します。


13. 学習時のディレクトリ構成

参考までに、学習側のディレクトリ構成です。

撮影画像や学習成果物の中身は非公開です。model.onnxのみ公開repositoryに置いています、これはライセンスの方を確認してもらえば使用してもらって構いません。

naruto_seal/
├── captures/                # 撮影画像(学習用、非公開)
│   └── set_a/01/*.jpg ...
├── src/sealsrc/             # 学習・推論モジュール
│   ├── make_split_csv.py
│   ├── prepare_hand_rois.py
│   ├── rgb_dataset.py / rgb_augment.py
│   ├── train_rgb.py
│   ├── infer_webcam_rgb.py  # ローカル動作確認用
│   ├── export_onnx.py
│   └── models/hand_landmarker.task
├── artifacts/               # split CSV, ROI manifest, run 出力
│   └── rgb_run_hand_010/
│       ├── model.pt / model.onnx / model.int8.onnx
│       ├── idx_to_class.json
│       └── test_metrics.json
└── web/                     # 静的 Web アプリ
    ├── src/pipeline/        # camera → hand → seal → smoother → challenge
    ├── src/render/          # canvas / effects / hud
    ├── src/state/game.ts    # ゲーム状態機械
    ├── public/models/       # model.onnx, hand_landmarker.task, idx_to_class.json
    └── dist/                # ビルド成果物

14. 学んだこと

今回の個人的な学びは、次の 5 つです。

1. 学習時の前処理と推論時の前処理は、必ずテストで縛る

クロップスケール 0.1 の差でも、精度が静かに落ちます。

「Python では当たるのに Web では当たらない」は、だいたい前処理差分を疑った方がよさそうです。

2. ROI をきれいに切れば、軽量モデルでもかなり戦える

今回の用途では、MobileNetV3-Small で十分でした。

より大きいモデルを試す前に、まず前処理を固める方が効きました。

3. per-class threshold を許容する設計にしておくと助かる

全クラス一律のしきい値は、早い段階で破綻しました。

クラスごとの難しさがあるタスクでは、しきい値を個別に持てる設計にしておくと後で楽です。

4. Gradio は便利だが、ゲーム性のある UI には静的配信の方が合うこともある

Gradio はとても便利です。

ただ、今回のように Webcam、リアルタイム推論、SE、画面フラッシュ、スプライト再生を全部組み合わせたい場合は、普通にフロントエンドとして作る方がやりやすかったです。

5. commit メッセージに正直に書いておくと、後から記事化しやすい

この記事のかなりの部分は、コミットログを見返しながら書きました。
ハマったこと、諦めたこと、方向転換した理由を commit メッセージに残しておくと、未来の自分が助かります。

700. データセット集めはめちゃくちゃ大変

iPhoneのショートカット機能や、webcam用の写真撮影用のスクリプトも組みましたが、それでも数日はバシャバシャ取りっぱなしでした。今日からは就活と修論にコミットしたいです。疲れた……。


15. 今後やりたいこと

今後やりたいことは、主にこの 2 つです。

スマホ転移の検証

スマホで撮影した画像は一部ありますが、まだきちんと test に入れていません。

スマホ専用の set を作り、どれくらい転移できるかを評価したいです。

そもそも、スマホのブラウザで動くのかどうか。スマホだと結構重い感じはするので、軽量化の方法がないか模索したいです。

印の追加

今回は干支 12 印に絞りました。

将来的には、代表的な術の印(影分身の術のアレ)なども足してみたいです。
ディレクトリを増やせば学習が回る設計にはしてあるので、ここは比較的拡張しやすいはずです。


NARUTO ファンの方は、ぜひ実際に印を結んでみてください。

「火遁・豪火球の術」の6 印を通したときのカタルシスは、作っている側でも毎回ちょっと嬉しいです。
慣れてきたらエンドレスモードに挑戦して、ぜひXに自分の記録を投稿してみてください!

デモ:https://ninja-like-hands-seal.pages.dev/

なお、この記事はClaude Codeにリポジトリを読ませてドラフトを作り、自分で手直ししたのちにChatGPTに清書をさせました。ご了承ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?