Vol.3
Chrome で 21 点が 1 点に潰れた — Legacy Hands から Tasks Vision への移行
何が起きたか
anime-ar-stock では、手の 21 点から掌の中心・指先・手の向きを計算し、それを (1) 印の CNN に渡す手の切り抜き範囲、(2) 螺旋丸や千鳥を手に追従させるアンカーとして使っています。ランドマークが 1 点に潰れると、切り抜きは面積ゼロになり、アンカーは画面の一点に張り付きます。Hands 自体は「手を検出した」という結果を返し続けるので、エラーにはなりません。
コミットメッセージには「Legacy Hands 0.4 collapses all 21 landmarks to one point in Chrome 153; HandLandmarker 0.10.32 does not.」と記録しています。
切り分け: 映像の渡し方を疑った
最初に疑ったのは MediaPipe ではなく、こちらの映像の渡し方でした。lib/nativeHandsVideo.ts の冒頭コメントに当時の仮説が残っています。「Chrome の WebGL texImage2D(<video>) は CSS のコンポジタ(object-fit・レイアウトボックス・DPR)を経由してサンプリングするので、ランドマークが videoWidth × videoHeight と合わない」。そこで次の順に試しました。
- 表示用 <video> の width / height 属性と CSS サイズを実解像度に固定し、object-fit: fill・transform: none にする(pinHandsVideoIntrinsicSize)。
- トラックを clone() して、画面外に置いた不可視の <video>(opacity 0.04、z-index -1)に流し、そちらを Hands に渡す。
- Chrome では <video> ではなく、デコード済みフレームを描いたキャンバスを渡す(2D canvas の drawImage はコンポジタではなくデコード済みフレームを読むため)。
- public/debug/ に mediapipe-hands-raw.html と mediapipe-tasks-hand-raw.html という、React アプリのコードを一切通さない素の HTML ページを置き、旧 Hands と HandLandmarker に同じカメラ映像を渡して生の座標を見比べる。
決め手になったのは最後の比較ページです。アプリのコードを通さなくても旧 Hands だけが潰れ、HandLandmarker は正常だったので、「こちらの渡し方を直せば治る」という線を捨てられました。上の 1〜3 の回避策は、根本原因が特定できないまま「効かなかった」ことだけが分かった状態です。
移行方針の比較
- メリット
- 既存コードを変えなくてよい。
- デメリット
- 原因が分からないので、次の Chrome 更新でまた壊れても打つ手がない。
- メリット
- setOptions / onResults / send / close の形を保てば、ランドマークの解析・印認識・VFX は無変更。URL パラメータで旧実装に戻せる。
- デメリット
- 旧 Hands にしかないオプションをアダプタが黙って捨てる(後述)。
- メリット
- 新 API の機能をすべて使える。
- デメリット
- 術ごとのセッションフック(use*Session.ts)だけで 12 本あり、すべてを同時に検証し直す必要がある。
採用理由: 壊れていたのは「21 点を返す部分」だけで、21 点を受け取った後のロジックは正しかった。差し替える範囲をランタイムに限定すれば、検証も「座標が正しく来るか」だけに絞れる。
移行は 2 段階で行いました。2026-09-27 にまず Call Studio(管理画面の撮影用ページ)を Tasks Vision に切り替え、同じ日のうちに公開ページも同じアダプタに揃えています。既定値は HAND_TRACKING_BACKEND = 'tasks' で、?hands=legacy を付ければビルドし直さずに旧実装へ戻せます。
アダプタで吸収した 3 つの差
1async send(input) {2 const image = input.image;3 if (!(image instanceof HTMLVideoElement) || !landmarker) return;4 // ① まだフレームが無い video を渡さない5 if (image.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || image.videoWidth <= 0) return;6 7 // ② detectForVideo は timestamp が単調増加でないと例外になる8 let timestamp = performance.now();9 if (timestamp <= lastTimestamp) timestamp = lastTimestamp + 1;10 lastTimestamp = timestamp;11 12 const result = landmarker.detectForVideo(image, timestamp);13 14 // ③ HandLandmarker には selfieMode が無いので、x の反転を自前でやる15 const flipX = options.selfieMode === true;16 const multiHandLandmarks = copyAndMaybeFlipX(result.landmarks ?? [], flipX);17 18 // 旧 Hands と同じ形で返す。Left/Right はここでは決めない19 onResults?.({ multiHandLandmarks });20}③ を忘れると、セルフィー表示の体験だけ手とエフェクトが左右逆に動きます。また HandLandmarker が返す handedness(左右の推定)はデバッグ表示に出すだけで使いません。左右は従来どおり parseTrackedHandsFromResults の中で、掌の中心(ランドマーク 0, 5, 9, 13, 17 の平均)の x 座標から決めます。手が 1 本なら x < 0.5 を右手、2 本なら x でソートして画面左を右手とします。インカメラの映像は鏡像なので、画面左にユーザーの右手が来るためです。
コードを読み直して気づいた注意点もあります。現在のアダプタが HandLandmarker に渡しているのは numHands だけで、各フックが setOptions で指定している modelComplexity(タッチ端末 0 / それ以外 1)や minDetectionConfidence(0.55 / 0.7)は、Tasks Vision 経由では使われていません。端末ごとに調整したつもりの値が、移行後は効いていない状態です。
実装から得た知見
- ランドマークが「潰れる」不具合はエラーにならない。手は検出され続けるので、座標の分布を見ない限り気づけない。
- アプリのコードを一切通さない比較ページを用意したことで、「自分のコードの問題か、ライブラリの問題か」を 1 回で切り分けられた。
- ML ランタイムは、アプリ側の形(setOptions / onResults / send)で包んでおくと乗り換えが 1 ファイルで済む。左右判定のような意味づけはランタイムの外に置く。
- ランタイムを差し替えたら、旧ランタイムのオプションが新しい方で本当に効いているかを確認する。形を合わせたアダプタほど、捨てたオプションが見えにくい。