Upgrade to Pro — share decks privately, control downloads, hide ads and more …

MediaPipe Face Landmarkerの精度限界にどう立ち向かうか — ランドマー...

MediaPipe Face Landmarkerの精度限界にどう立ち向かうか — ランドマーク幾何学による補正の実践 / Overcoming accuracy limits of MediaPipe Face Landmarker

DroidKaigi 2026で発表したセッション「MediaPipe Face Landmarkerの精度限界にどう立ち向かうか — ランドマーク幾何学による補正の実践」の登壇資料です。

MediaPipe Face Landmarkerで精度が十分に出ないBlend Shapesに対して、ランドマークの幾何学的な情報を使って補正する方法について、実際のアバター配信アプリ「Avvy」での取り組みをもとに紹介しています。

Session:
https://2026.droidkaigi.jp/timetable/1236039/

Avatar for Ryosuke Shimizu (RIO)

Ryosuke Shimizu (RIO)

September 10, 2026

More Decks by Ryosuke Shimizu (RIO)

Other Decks in Technology

Transcript

  1. 表情を推定するところが、AndroidとiOSで違う 表情を推定 カメラ 前面カメラ Android → MediaPipe iOS → KMP

    共通の型へ Unity → アバターへ渡す ARKit MediaPipeはGoogle、ARKitはAppleの、 顔の動きを数値にする技術。MediaPipeは章02で詳しく見る 12
  2. MediaPipeもARKitも、表情を blendshape で返す 名前 顔のどこが、どう動いたか eyeBlinkLeft 左目を閉じた eyeBlinkRight 右目を閉じた jawOpen

    あごを下げて口を開けた mouthPucker 口をすぼめた cheekPuff 頬をふくらませた 強さはどれも 0.0(動かしていない)〜 1.0(いちばん大きく動かした)。 以後、この値を「係数」と呼ぶ。 MediaPipe ソース / Apple 公式ドキュメント 13
  3. iOSは最も低消費電力の形式を選び、顔は1つだけ追う configuration.isLightEstimationEnabled = false configuration.maximumNumberOfTrackedFaces = 1 if let lowestPowerVideoFormat

    = ARFaceTrackingConfiguration .supportedVideoFormats.last { configuration.videoFormat = lowestPowerVideoFormat } 使わない機能は切り、 対応形式の中でいちばん省電力のものを選ぶ 17
  4. 顔を動かす43個の値は、共有メモリに続けて置く // Make sure to have same value in native

    side // Android: .../AvatarTrackingDataInteropBridge.kt // iOS: .../AvatarTrackingDataInteropBridge.swift // AVATAR DATA public const int FACE_TRACKING_DATA_SIZE = 43 * sizeof(float); public const int CAMERA_DATA_SIZE = 3 * sizeof(float); public const int STRIDE = FACE_TRACKING_DATA_SIZE + CAMERA_DATA_SIZE; 表情37・頭の向き3・頭の位置3。 各実装の並びは、コメントを手掛かりに人がそろえている 21
  5. アバターの頬は、2種類の係数を1本の式で動かす var val = Mathf.Clamp01( // 左の頬 (r.CheekSquintLeft * 係数)

    + r.CheekPuff); ... var val = Mathf.Clamp01( // 右の頬 (r.CheekSquintRight * 係数) + r.CheekPuff); cheekSquint : 目の周りと下の頬が上へ動く度合い) cheekPuff : 両頬が外へ動く度合い cheekPuffは左右の式に同じ値が入り、 上がれば両頬が同時に動く 23
  6. 実機計測では、75秒間の送信を毎秒30回で維持した 推論 共有メモリ書き込み 送信 1枚あたり 21〜22ms 1回あたり 約30µs 毎秒30枚・ 取りこぼし0

    中央値 中央値 顔を映した75秒×3本 Galaxy S25 Ultraでの実測。 熱でGPUクロックが絞られても、この30fpsは落ちなかった 24
  7. 周期を決めるのは、端末区分と熱状態の表 DeviceTier.HIGH to mapOf( ThermalState.SAFE to 30, ThermalState.WARNING to 24,

    ThermalState.STRESS to 15, ThermalState.CRITICAL to 10, ) HIGH区分の抜粋。熱を4段階に分け、端末区分とFPSの組み合わせて選ぶ android.os.PowerManagerを使い、端末の熱状態を判定。 OSバージョンで処理分け。 26
  8. 端末の格付けは、OSの申告を先に見る if (activityManager.isLowRamDevice) return DeviceTier.LOW // Android 12+ — OSが申告する性能クラスを先に見る

    val mpc = Build.VERSION.MEDIA_PERFORMANCE_CLASS if (mpc >= 33) return DeviceTier.HIGH if (mpc in 31..32) return DeviceTier.MEDIUM // 申告が無い端末だけ、コア数とRAMで決める if (cores >= 8 && ramGb >= 8.0) return DeviceTier.HIGH Android 12 以降は Build.VERSION.MEDIA_PERFORMANCE_CLASS を見る。 27
  9. 表情の係数まで返すのは、Face Landmarker ARCore Augmented Faces 姿勢と 468点の メッシュ ML Kit

    Face Mesh 468点の メッシュ 表情の分類は返さない (公式の比較表) MediaPipe Face Landmarker 478点と 52個の係数 点に加えて、表情の係数 まで返す 34
  10. 係数の名前が同じなら、共通に扱えるはずだった MediaPipe Android → 同じ名前の 51個 共通のコードで 扱えそうだ ← ARKit

    iOS 51個は、MediaPipeの52個から_neutralを除いた、ARKitと名前まで共通の 部分。この見立てが、どこまで通用するのかをこの章で確かめる 39
  11. 頬がふくらまない報告は、3年前から続いている 報告 報告 2023-03 MediaPipe Issue → 2023-05 cheekPuffが ほぼ0のまま

    google-ai-edge/mediapipe Issue #4436 / #5329 まだOpen → いま ラベルは Google側の回答待ち 40
  12. 他社のツールも、同じ制限として案内している ユーザーからの質問 “I cannot puff my cheeks” ほっぺをふくらませられない Warudo 公式ドキュメント(MediaPipe

    のページ) ツール側の回答 “a known limitation of MediaPipe” MediaPipeで知られている制限。 Googleの修正待ち、と続く 42
  13. 146個の点は、ソースに配列で書かれている static constexpr std::array<int, 146> kLandmarksSubsetIdxs = { 0, 1,

    4, 5, 6, 7, 8, 10, 13, 14, 17, 21, 33, 37, 39, 40, 46, 52, ... }; この146個の、横と縦だけを渡す。奥行きは渡さない google-ai-edge/mediapipe face_blendshapes_graph.cc(Apache-2.0) 49
  14. 係数がだめなら、選べる方法は3つ 道1 係数を直す 倍率やしきい値を 変える。 3章で見たとおり 大小が逆で解けない 道2 道3 点から作る

    学習で作る 478点から 頬の動きを測る。 この章の主役 点や見た目から 係数を推定する。 この章の後半で触れる 58
  15. 3つの条件は、共通側のKotlinに入っている const val MOUTH_WIDTH_MAX = 3.02 const val OVAL_JAW_MIN =

    1.54 const val OVAL_CHIN_MAX = 2.68 val fired = mouthWidth <= MOUTH_WIDTH_MAX && ovalJaw >= OVAL_JAW_MIN && ovalChin <= OVAL_CHIN_MAX 単位は目の間隔を100とした変化。共通SDKの合成経路へつなぐのはこれから 67
  16. 検証用の規則は実装済み。 次は本番アプリにも導入し検証する 規則があり、 取れた収録がある 何も出ない これまで Androidの頬は ずっとゼロ → いま

    取れない収録との差は 候補2つまで絞った 撮り直して 1回だけ採点 → これから 本番アプリへの 統合および検証は 72
  17. 別案として、478点すべてを学習させた 渡したもの 1本ずつ外して採点 478点の座標 そのまま 順番は 正しく並んだ 人が3つ選ぶ代わりに、 選ばずに全部渡す ふくらませた7本が、

    ほかの11本より上 先に決めた合格ライン 越えたのは 7本中2本 誤作動は0本 時刻と順番だけ、顔の姿勢だけ、ラベルを5通り入れ替えた対照は、 どれもこれより低い 73
  18. 学習でも、頬と口の動きを分けられなかった 頬の見た目から学習 同じ収録では 正例6本すべてを分離 ただし、口だけを見る対照も 同じように分離した 点からARKitの反応を学習 製品条件の収録では 話しているだけで 反応し続けた

    ふくらませていない区間も しきい値を超えたまま 頬の小画像には口の領域が4割近く重なる。 学習に入れていない動きや条件では、頬だけを取り出せない 74
  19. ぷく顔は、3つの状態で受け渡す設計案 立ち上がり 保持 ふくらませたら 出る → ふくらませてい → る間、出続ける 解除

    やめたら戻る アバター側 → なめらかに 動かす 連続値を待たず、状態で渡す。なめらかさはアバター側の補間が作る。 75
  20. 共通側は「FaceTrackerを作る」と宣言するだけ // commonMain public expect fun createFaceTracker( platformContext: PlatformContext, config:

    FaceTrackerConfig = FaceTrackerConfig(), ): FaceTracker expectは「ここはOSごとに用意する」で 各プラットフォームのインターフェースを定義。 ビルドする環境に合わせて、 Kotlinのコンパイラがその環境の実装(actual)を選ぶ 80
  21. 環境に合わせて、コンパイラが4つの実装から選ぶ // androidMain public actual fun createFaceTracker(...): FaceTracker = MediaPipeFaceTracker(platformContext,

    config) // iosMain public actual fun createFaceTracker(...): FaceTracker = ARKitFaceTracker(config) // wasmJsMain → MediaPipeWasmFaceTracker(config) // jvmMain → OnnxFaceTracker(config)。依存が無ければ NoOp 同じ名前のactualを、4つの環境ごとに1つずつ置く 81
  22. 4つとも、start・stop・trackingData・stateを同じ形 で備える // commonMain — 4つの実装が返す、同じ約束 public interface FaceTracker :

    Releasable { val trackingData: Flow<FaceTrackingData> val state: StateFlow<TrackingState> suspend fun start() suspend fun stop() } stateはIDLE・STARTING・TRACKING・ STOPPED・ERROR・RELEASEDの6つ 82
  23. 同じなのは使い方と出力の形。中身と能力は別 Android iOS MediaPipe ARKit 較正・平滑化を 利用できる 較正・平滑化を 利用できる Web

    Desktop MediaPipe (Wasm) ONNX Runtime 較正・平滑化は 未対応 UNSUPPORTED 較正・平滑化は 未対応 UNSUPPORTED 83
  24. 52項目は1つのenumに集め、名前は機械変換する public enum class BlendShape { // ARKit由来の52項目。定義はここ1回 EYE_BLINK_LEFT, ...,

    CHEEK_PUFF, ..., TONGUE_OUT; public val arKitName: String // → eyeBlinkLeft get() = arKitNames.getValue(this) // 小文字にして "_" で分け、2語目からは先頭を大文字にする val parts = entry.name.lowercase().split("_") parts.first() + parts.drop(1).joinToString("") { part -> part.replaceFirstChar { it.uppercase() } 名前をここに1回だけ書く。手書きの対応表は無い 84
  25. 詰め替えのコードは、環境ごとに違う // androidMain — MediaPipeのカテゴリ名を、名前で引く if (name == "_neutral") continue

    // 52項目に無い名前は除外 val shape = nameToBlendShape[name] ?: continue // iosMain — eyeBlink_L を eyeBlinkLeft に直してから引く name.endsWith("_L") -> name.dropLast(2) + "Left" name.endsWith("_R") -> name.dropLast(2) + "Right" // jvmMain — 点から計算した値を、 enumのキーへ直接入れる output[BlendShape.JAW_OPEN] = remap(innerMouthHeight, ...) 86
  26. 既定で有効なのは平滑化だけ。ほかは明示的に選ぶ public data class FaceTrackerConfig( val smoothingConfig: SmoothingConfig = SmoothingConfig.Ema(),

    // 平滑化(前の値と混ぜる)は既定ON val enhancerConfig: BlendShapeEnhancerConfig = BlendShapeEnhancerConfig.None, // 候補作りは既定None val calibrationConfig: CalibrationConfig = CalibrationConfig(), // enabled = false ...) Emaは直前の値と混ぜる、いちばん単純な平滑化 89
  27. まとめ なぜ どうしたか 設計 頬の表面の点が 入力に無い 点から規則を 作って実装した 能力の差は 隠さない

    52個を作る入力は 146点のXYだけ 次は本番アプリで 確かめる 値・確かめた能力・ 使う経路を分ける 92