midori303: 顔・音声・ジェスチャーを束ねるハイブリッド感情モニタ
緊張した。初版は顔だけで完結させようとして、怒りの気配を全部取りこぼした。だから音声とジェスチャを絡める必要があった。
対象読者
- リアルタイム解析で複数モダリティを束ねたいエンジニア
- face-api.js(顔認識ライブラリ)や TensorFlow.js(機械学習ライブラリ)を既に触っていて精度を上げたい人
- WebGL(ブラウザで3D描画を行う技術)ダッシュボードを高速表示しつつ、UX(ユーザー体験)を崩したくないデザイナー兼実装者
記事に書いてあること
- midori303 コアがどのように顔・音声・ジェスチャを重み付けしているか
- フォールバックと重複排除で IndexedDB(ブラウザ内蔵データベース)を太らせないための実装
- 8fps(FPS、フレームレート、1秒あたりのフレーム数)を死守するために計測したレイテンシと調整ログ
作ったもの
顔・音声・ジェスチャを一画面で突き合わせるライブモニタです。01.mp4 は記事ヘッダーで自動再生されるので、content.md 側ではデモだけを並べています。
このデモは face 45% / voice 30% / gesture 25% という初期重みを再現しています。live-dashboard.js では fusionWeights を正規化してから合成し、シナリオ切り替えのたびに UI に反映しています。
// new_toppage/articles/midori303/libs/live-dashboard.js(抜粋)
const voiceScore = clampScore(scenarios.live.metrics.voice.score);
const gestureScore = clampScore(scenarios.live.metrics.gesture.score);
fusionSummaryEl.textContent =
`Face ${faceWeight}% / Voice ${voiceWeight}% / Gesture ${gestureWeight}% | Voice ${voiceLabel} | Gesture ${gestureLabel}`;
融合エンジンの骨格
midori303 は webcam-component が MediaStream(メディアストリーム)を握り、prediction-component に渡して感情ラベル・年齢・性別を推定します。その結果を live-dashboard.js が受け取り、VoiceAnalyzer と GestureAnalyzer のスコアと合わせてシナリオを更新します。構造はこうです。
webcam-component が webcam-ready を投げる
prediction-component が face-api.js(顔認識ライブラリ)モデルをロードし、8fps(FPS、フレームレート)で推論
VoiceAnalyzer と GestureAnalyzer が CustomEvent(カスタムイベント)を飛ばす
live-dashboard.js が重みとフォールバックを管理
失敗。最初は face と voice だけにしたら、ジェスチャの静穏ケースで感情が常にニュートラルへ吸い寄せられました。これはマズい。ジェスチャのフラグメントを追加してからようやく怒りの抑制が見えるようになった。
音声トーンの補強実験
VoiceAnalyzer は RMS(二乗平均平方根)が 0.02 を超えた瞬間に「発話中」へ移行し、0.6×音量 + 0.4×ピッチを 120ms 間隔の EMA(指数移動平均)で滑らかにします。ピッチが 180〜260Hz に入ると喜び寄り、320Hz を超えると減衰する式です。
// midori303/libs/voice-analyzer.js(抜粋)
const volumeScore = clamp01((volumeDb + 60) / 45); // -60dB → 0, -15dB → 1
let pitchScore = 0.15;
if (pitchHz) {
if (pitchHz >= 90 && pitchHz <= 320) {
const mid = (pitchHz >= 180 && pitchHz <= 260) ? 1 : 0.75;
const distance = Math.min(Math.abs(pitchHz - 180), Math.abs(pitchHz - 260));
pitchScore = clamp01(mid - distance / 400);
}
}
甘かった。初日はフォールバックを 0.50 にしていたせいで、無音時でも合成スコアが高止まりしてしまった。0.35 まで落としたらようやく沈んだ。意外だ。静かな空間では 0.32 くらいにしておくともっと自然だった。
ジェスチャ解析を噛み合わせる
MoveNet(姿勢推定モデル)(Lightning) を回し、手首・肘・鼻の位置変化から動きの量と範囲を算出します。スコアは過去の値に75%、新しい値に25%の重みをかけて滑らかに更新しており、動きの量が0.35を超えると「ダイナミック」扱いです。
ジェスチャの信頼度が 0.55 を割ると span の EMA が沈んでしまうので、モニタに映る上半身のトリミングをしつこく調整しました。ヨシ。これでライブモードに入ると face とのバランスが崩れない。
フォールバック監視の設計
音声・ジェスチャはブラウザ権限に左右されるので、Fallback の値を明示しておきます。VoiceAnalyzer が ready を投げなければ 0.35、Gesture は 0.40。live-dashboard は CustomEvent を聞きながら UI を切り替えます。
権限を拒否した時に voiceStatus がずっと Live 表示のままだったバグがありました。voiceLive = false; voiceState.ready = false; をエラーイベントで明示的に叩き、fallback ラベルへ戻すように修正。これだ。
シナリオ切替とマーケ指標
シナリオは「ライブ解析」「ギャラリートーク」「クレーム応対」「集中作業ブレイク」の 4 種。各シナリオが features 配列を持っているので、CTA 用のテキストもここに寄せています。
内部リンクとして旧記事の midori225 の顔推定や midori237 の WebGL フレームも参照しておくと、読者にコンテキストを渡しやすくなりました。
IndexedDB と重複抑制
検出結果は IndexedDB(ブラウザ内蔵データベース)の検出履歴ストアに入ります。ただし重複率・色ヒストグラム・サイズの類似度を合成し、スコアが 0.75 を超えたら保存をスキップします。色ヒストグラムは 8×8×8 の 512 ビンで集計し、バタチャリヤ係数で比較しています。
類似スコアの計算を差し込む前は、60秒で 140 件も履歴が増えてブラウザがヒーヒー言った。重複判定を入れた結果、ほぼ同じフレームは 3 件に圧縮され、detection-history のスクロールが軽くなりました。
レイテンシ予算の調整
face-api.js(顔認識ライブラリ)の inputSize は 320。推論 86ms、後処理 18ms、EMA(指数移動平均)とシナリオ同期 12ms で合計 116ms。8fps(FPS、フレームレート)の予算 125ms からすると 9ms しか余裕がありません。
処理が 130ms を超えたら fall back で推論を 6fps に下げるコードパスも検討しましたが、今回は detectorInputSize を 288→320→256 と往復させて、256 では怒りの眉間が欠けたため断念。最終的に 320 のまま GPU を信用することにしました。
履歴ビューのチューニング
detection-history は 12 件ずつカード表示します。画像の hover 時にスケールを 1.5 にしても overflow しないよう CSS を調整し、切り抜き画像は上下左右に 20px のパディングを追加しています。
IndexedDB 衝突で InvalidStateError が出た時は this.db = null; に戻して再初期化するようにしました。これでブラウザをリロードしなくても復帰できます。
使ってみて
実際のダッシュボードを触ってみたい方へ:
ハイブリッド解析を全画面で開く(ブラウザでライブ調整)
ポイントは以下の 3 つです。
- 顔 45% / 音声 30% / ジェスチャ 25% の初期重みを自由に配分できる
- 音声・ジェスチャのフォールバック値が明示されており、権限の有無がすぐ分かる
- シナリオ切り替えでマーケティング視点のコメントを即座に確認できる
旧サイト版のフルデモは midori303/index.html をどうぞ。ライブカメラを許可すると、記事で説明したロジックをそのまま体験できます。同じようなハイブリッド推定を試している方の役に立てば嬉しいです。
今日のまとめ
- VoiceAnalyzer は RMS とピッチを 0.6 : 0.4 で融合し、フォールバックを 0.35 に落としたことで無音時の暴れを抑えた
- MoveNet の motion/span を 0.5 : 0.3 : 0.2 で合成し、span > 0.55 でオープン、motion > 0.35 でダイナミックと判定
- IoU + 色ヒスト + サイズの合成スコアが 0.75 を超えたら検出履歴を保存せず、IndexedDB の肥大化を防いだ
- 推論 116ms / 8.6fps で 8fps の目標ラインをクリアし、face-api.js の inputSize を 320 のまま維持
- 動画なしの状態でも 8 個のデモでハイブリッド構造とフォールバック戦略を体験できるようにした
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。