KINTO Tech Blog
Android

MediaPipe Instant Motion Trackingを用いた、AndroidにおけるARエフェクトの実現

Cover Image for MediaPipe Instant Motion Trackingを用いた、AndroidにおけるARエフェクトの実現

はじめに

はじめまして。
KINTO テクノロジーズで KINTO Unlimited Android アプリを開発している JR.Liang です。

本記事では、KINTO Unlimited アプリにて提供する「これなにガイド」スキャン機能の AR エフェクトについて、Android における技術的な検証を紹介します。
特に MediaPipe のソリューションを用いて幅広い Android デバイスで AR エフェクトを実現した実装にフォーカスします。

これなにガイドとは

「これなにガイド」は AR(拡張現実)を活用して、車内スイッチの用途や使い方をテキストと動画で案内する機能です。紹介動画をご覧ください。
https://youtube.com/watch?v=E8zfNzuHr7g&embeds_referring_euri=https%3A%2F%2Fcorp.kinto-jp.com%2F&source_ve_path=MjM4NTE
上記の紹介動画は iOS アプリでの動作を示しています。スイッチ上に表示された黄色の丸 🟡 が、AR 技術で実現した仮想コンテンツです。

機能全体の仕組みは以下の流れです。本記事では 3 番目(描画)に関する内容を扱います。

1. アプリのカメラを起動、カメラ画像を取得
2. 機械学習における物体認識を用いて、車内のスイッチを検出
3. 検出した座標を元に、ボタンとテキストをフレーム上に描画
4. ボタンをタップして、当該スイッチのテキストと動画を表示

Android AR 技術検証の経緯

当初の Android 版「これなにガイド」のスキャン機能では、Canvas を利用して毎フレーム検出される座標に描画する実装でした。そのため検出の時間差により、スマホ(カメラ)を動かすと描画のズレが生じていました。
v1
2D Canvas
幸い、MediaPipe のソリューションである Instant Motion Tracking モジュールで素早くかつ安定した AR エフェクトを実現できることがわかり、Android への導入を検証しました。
v2
3D OpenGL

MediaPipe Instant Motion Tracking

MediaPipe は Google が開発したオープンソースの ML フレームワークで、顔検出・手のトラッキング・姿勢推定などリアルタイム映像処理のソリューションを提供します。

その中の Instant Motion Tracking は、現実世界のシーン上に 3D 仮想コンテンツをリアルタイムで正確に配置できる AR トラッキング機能です。初期化や厳密なキャリブレーションが不要で、静止面や動いている面の上にコンテンツを置くことが可能です。

Android + MediaPipe AR アーキテクチャ

CameraX で取得したフレームを Instant Motion Tracking に渡し、TensorFlow Lite で物体検出した情報を元に AR コンテンツを描画・追従させるパイプラインです。

MediaPipe ライブラリの作成

MediaPipe では Bazel を使用してパッケージをビルドします。Android に適合する AAR として書き出してアプリに組み込みます。
https://chuoling.github.io/mediapipe/getting_started/android_archive_library.html

AAR をビルドする BUILD ファイルを作成し、instant_motion_tracking を基盤とした定義を記述します。

load("//mediapipe/java/com/google/mediapipe:mediapipe_aar.bzl", "mediapipe_aar")

mediapipe_aar(
    name = "mediapipe_ar",
    calculators = ["//mediapipe/graphs/instant_motion_tracking:instant_motion_tracking_deps"]
)

MediaPipe は C++ が中核のため、C++ ランタイムである libc++_shared.so を AAR に同梱する必要があります。
https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/third_party/BUILD#L399-L403

また Instant Motion Tracking では画像処理ライブラリ OpenCV を利用し、AR トラッキングを行います。
https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/WORKSPACE#L649-L655

上記サードパーティのライブラリを含めて、以下のコマンドで AAR をビルドします。

bazel build -c opt --strip=ALWAYS \
    --host_crosstool_top=@bazel_tools//tools/cpp:toolchain \
    --fat_apk_cpu=arm64-v8a \
    --linkopt=-Wl,-z,max-page-size=16384 \
    //path/to/the/aar/build/mediapipe_ar:mediapipe_ar.aar
  • 市場に流通している Android デバイスは主に arm64-v8a アーキテクチャのため、AAR のサイズを抑える目的で fat_apk_cpu=arm64-v8a にします。
  • C++ ライブラリの 16KB page-size に対応するため、max-page-size=16384 を追加します。

また AAR を利用するにはグラフ構造を定義するファイル(binarypb)が必要です。

bazel build -c opt mediapipe/graphs/instant_motion_tracking:instant_motion_tracking.binarypb

Instant Motion Tracking の導入

AAR をアプリに組み込んで、Android 側の実装を解説していきます。
下記は AAR に組み込んだ instant_motion_tracking の全体構造です。

Graph

instant_motion_tracking.pbtxt の構成

グラフ定義ファイル instant_motion_tracking.pbtxt は、Calculator(処理ノード)・入出力ストリーム・サイドパケットの 3 要素で構成されます。

Calculator

各 Calculator がパイプライン上でどの処理を担うかを示します。

Calculator 役割
ImageTransformationCalculator カメラフレームを 320×320(FIT)にリサイズ。物体検出モデルの入力サイズに合わせる
GpuBufferToImageFrameCalculator GPU テクスチャを CPU の ImageFrame に変換。TensorFlow Lite 推論に使用
StickerManagerCalculator Sticker Proto をパースし、初期アンカーの座標・回転・スケール・レンダリング種別に分解
RegionTrackingSubgraph ボックストラッキングでアンカー位置を追従。内部に TrackedAnchorManagerCalculator(アンカー管理)と BoxTrackingSubgraphGpu(GPU トラッキング)を持つ
MatricesManagerCalculator トラッキング結果・回転・スケール・FOV・アスペクト比から OpenGL 用 4×4 モデル行列を生成
GlAnimationOverlayCalculator モデル行列とテクスチャを用いて、元のカメラフレーム上に AR コンテンツを OpenGL で描画し output_video として出力

input_stream / output_stream

input_stream はフレームごとに Android 側から送信するデータ、output_stream はグラフの処理結果です。

ストリーム名 C++ 型 方向 用途
input_video GpuBuffer Input カメラフレーム
sticker_proto_string String(Serialized Proto) Input ステッカーの座標・スケール等(Sticker Proto)
sticker_sentinels vector<int> Input 座標をリセットするステッカー ID の配列
gif_textures vector<AssetTextureFormat> Input AR コンテンツの Bitmap テクスチャ配列
gif_aspect_ratios vector<float> Input 各テクスチャのアスペクト比
output_video GpuBuffer Output AR 描画済みフレーム

input_side_packet

input_side_packet は初期化時に一度だけ渡す定数で、グラフ実行中は変化しません。

パケット名 用途
vertical_fov_radians カメラの垂直 FOV(ラジアン)
aspect_ratio カメラのアスペクト比
width / height カメラ解像度
gif_texture デフォルトテクスチャ(1x1 プレースホルダ)
gif_asset_name AR テクスチャ描画用のポリゴンメッシュ(.obj)ファイル名

Android への導入に当たって、公式サンプルのコードを参考にします。
https://github.com/google-ai-edge/mediapipe/tree/master/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking

1. 初期化

MediaPipe を使用する前に、ネイティブライブラリの読み込みとアセットマネージャーの初期化が必要です。

companion object {
    init {
        System.loadLibrary("mediapipe_jni")
        System.loadLibrary("opencv_java4")
    }
}

// onCreate 相当の処理
AndroidAssetUtil.initializeNativeAssetManager(context)
  • mediapipe_jni: MediaPipe のコア処理を行う JNI ライブラリ
  • opencv_java4: AR トラッキングに使用する OpenCV ライブラリ
  • initializeNativeAssetManager: ネイティブコードからアセット(binarypb 等)にアクセスするために必要

2. カメラを起動する

公式サンプルを参考に、以下の順序でパイプラインを構築します。
データフロー: CameraX → ExternalTextureConverter → FrameProcessor → SurfaceView

2.1 EGL 環境と FrameProcessor の初期化

val eglManager = EglManager(null)
val frameProcessor = FrameProcessor(
    context,
    eglManager.nativeContext,
    "instant_motion_tracking.binarypb",
    "input_video",
    "output_video"
).apply {
    videoSurfaceOutput.setFlipY(true)
    setInputSidePackets(
        mapOf(
            "gif_asset_name" to packetCreator.createString("gif.obj.uuu"),
            "vertical_fov_radians" to packetCreator.createFloat32(fovRadians),
            "aspect_ratio" to packetCreator.createFloat32(resolution.width.toFloat() / resolution.height.toFloat()),
            "width" to packetCreator.createInt32(resolution.width),
            "height" to packetCreator.createInt32(resolution.height),
            "gif_texture" to packetCreator.createRgbaImageFrame(createBitmap(1, 1))
        )
    )
}
  • EglManager: OpenGL ES の EGL コンテキストを作成・管理。MediaPipe のグラフ内 GPU Calculator(GlAnimationOverlayCalculator 等)が OpenGL で描画するために必要
  • FrameProcessor: EGL コンテキストを受け取り、グラフの読み込み・入出力ストリームの管理・フレームごとのグラフ実行を行う
    • instant_motion_tracking.binarypb: .pbtxt を Bazel でコンパイルしたグラフ定義バイナリ
    • input_video: MediaPipe グラフへカメラフレームを入力
    • output_video: グラフで処理(AR 描画など)された映像を出力
    • videoSurfaceOutput.setFlipY(true): OpenGL とカメラの Y 軸方向が逆のため、出力映像を上下反転して正しい向きにする
    • setInputSidePackets: グラフの input_side_packet に対応する定数をまとめて設定。カメラの FOV・アスペクト比・解像度など、グラフ実行中に変化しない値を初期化時に一度だけ渡す
  • gif_asset_name は AR テクスチャを描画するためのポリゴンメッシュ(頂点データ)、ここでは公式サンプルのgif.obj.uuuを利用

2.2 カメラ映像の変換パイプライン構築

val externalTextureConverter = ExternalTextureConverter(eglManager.context, 2).apply {
    setFlipY(true)
    setConsumer(frameProcessor)
    setDestinationSize(resolution.width, resolution.height)
}
val cameraHelper = object : CameraXPreviewHelper() {
    override fun getCameraCharacteristics(context: Context?, lensFacing: Int?) = cameraCharacteristics
}.apply {
    setOnCameraStartedListener(onCameraStartedListener)
    startCamera(
        context,
        lifecycleOwner,
        CameraHelper.CameraFacing.BACK,
        externalTextureConverter.surfaceTexture,
        Size(resolution.height, resolution.width)
    )
}
  • ExternalTextureConverter: カメラの GL_EXTERNAL_OES テクスチャを MediaPipe が処理できる標準テクスチャに変換
    • setFlipY(true): カメラ映像の上下反転を補正
    • setDestinationSize(resolution.width, resolution.height): パイプラインの処理サイズはポートレート座標(例: 960×1280)で指定
  • CameraXPreviewHelper: CameraX でバックカメラを起動し、Converter の SurfaceTexture に出力
    • startCamera(targetSize = Size(resolution.height, resolution.width)): CameraX はセンサー座標(ランドスケープ)を期待するため、width と height を入れ替えて渡す

公式サンプルでは CameraXPreviewHelper をそのまま使用し、内部で CameraManager からカメラ特性を取得します。
https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/mediapipe/java/com/google/mediapipe/components/CameraXPreviewHelper.java#L558-L560
本実装では getCameraCharacteristics をオーバーライドし、事前に取得済みの CameraCharacteristics を直接渡します。これにより FOV やアスペクト比の算出に使うカメラ情報を、アプリ側で一元管理できます。

2.3 出力先SurfaceViewの設定

SurfaceView(context).apply {
    holder.addCallback(object : SurfaceHolder.Callback {
        override fun surfaceCreated(holder: SurfaceHolder) {
            frameProcessor.videoSurfaceOutput.setSurface(holder.surface)
        }

        override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
            val displaySize = cameraHelper.computeDisplaySizeFromViewSize(Size(width, height))
            val (displayWidth, displayHeight) = if (cameraHelper.isCameraRotated) {
                displaySize.height to displaySize.width
            } else {
                displaySize.width to displaySize.height
            }
            externalTextureConverter.setDestinationSize(displayWidth, displayHeight)
        }

        override fun surfaceDestroyed(holder: SurfaceHolder) {
            frameProcessor.videoSurfaceOutput.setSurface(null)
        }
    })
}
  • SurfaceHolder.Callback: SurfaceView のライフサイクルに応じて FrameProcessor の出力先を管理
    • surfaceCreated: FrameProcessor の出力先として Surface を設定
    • surfaceChanged: 画面回転・サイズ変更時に出力解像度を調整
    • surfaceDestroyed: リソース解放

3. 検出座標をグラフに渡す

物体検出(TensorFlow Lite 等)で得られた座標を MediaPipe グラフに渡し、AR コンテンツを配置します。

3.1 グラフから変換済み画像を取得

MediaPipe グラフ内で ImageTransformationCalculatorGpuBufferToImageFrameCalculator によって変換された画像を addPacketCallback で受け取り、物体検出に使用します。

frameProcessor.addPacketCallback("transformed_input_video_cpu") { packet ->
    packet ?: return@addPacketCallback
    // 変換済み画像を物体検出(TensorFlow Lite)に渡す
    val bitmap = PacketGetter.getBitmapFromRgba(packet)
    objectDetector.detect(bitmap) { detections ->
        // 検出結果を処理
    }
}
  • transformed_input_video_cpu: 変換後の画像を出力するストリーム名

3.2 座標の正規化

物体検出結果のピクセル座標を、MediaPipe が期待する正規化座標に変換します。

// ピクセル座標 → 正規化座標 (0.0〜1.0)
val normalizedX = pixelX / imageWidth.toFloat()
val normalizedY = pixelY / imageHeight.toFloat()

3.3 Sticker Proto の構造

Instant Motion Tracking では、AR オブジェクトの位置情報を Protocol Buffers 形式で定義します。

message Sticker {
  int32 id = 1;        // ユニークID
  float x = 2;         // 正規化X座標 (0.0〜1.0)
  float y = 3;         // 正規化Y座標 (0.0〜1.0)
  float rotation = 4;  // 回転角度
  float scale = 5;     // スケール
  int32 render_id = 6; // レンダリングID
}

message StickerRoll {
  repeated Sticker sticker = 1;
}

3.4 フレームごとにパケットを送信

setOnWillAddFrameListener を使用して、各フレーム処理前に検出座標をグラフへ送信します。

frameProcessor.setOnWillAddFrameListener { timestamp ->
    with(frameProcessor.graph) {
        // 検出された物体の座標情報をパケットとして送信
        val stickerRoll = StickerRoll.newBuilder()
            .addAllSticker(detectedObjects.map { detection ->
                Sticker.newBuilder()
                    .setId(detection.id)
                    .setX(detection.normalizedX)  // 0.0〜1.0
                    .setY(detection.normalizedY)  // 0.0〜1.0
                    .setScale(detection.scale)
                    .build()
            })
            .build()

        val stickersPacket = packetCreator.createSerializedProto(stickerRoll)
        addPacketToInputStream("sticker_proto_string", stickersPacket, timestamp)
    }
}
  • FrameProcessor.setOnWillAddFrameListener: 各フレームがグラフに送られる直前に呼ばれるコールバック
  • FrameProcessor.graph.addPacketToInputStream: 入力ストリームにパケットを追加
  • sticker_proto_string: グラフ定義で指定された入力ストリーム名

4. テクスチャ(Bitmap)の描画と送信

位置情報と同時に、AR コンテンツとして描画する Bitmap テクスチャもグラフに渡します。

4.1 Bitmap テクスチャの生成

検出された各スイッチに対して、丸アイコンとラベルテキストを含む Bitmap を生成します。

val bitmap = createBitmap(width.toInt(), height.toInt()).apply {
    with(Canvas(this)) {
        concat(Matrix().apply {
            preScale(-1.0f, 1.0f, width / 2f, height / 2f) // X軸を反転して描画
        })
        drawCircle(circleX, circleY, CIRCLE_RADIUS, circlePaint)
        drawRect(rectLeft, rectTop, rectRight, rectBottom, backgroundPaint)
    }
}

Matrix().preScale(-1.0f, 1.0f) で Bitmap を左右反転しています。以下の IMU 行列に合わせるためです。

float imu_matrix[9] = {
  -1.0f, 0.0f, 0.0f,  // X軸 → 反転(-X)
   0.0f, 0.0f, 1.0f,  // Y軸 → Z軸へ
   0.0f, 1.0f, 0.0f   // Z軸 → Y軸へ
};

この行列は OpenGL モデル行列(4x4)の回転成分として使われ、Y/Z 軸の入れ替えと X 軸反転でテクスチャをカメラ平面に平行に固定します。

本来はデバイスの IMU センサーから回転行列を受け取り、端末の傾きに追従させます。
https://github.com/google-ai-edge/mediapipe/blob/0.10.32/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking/MainActivity.java#L218-L220
本実装では固定値にすることで常にカメラ正面を向く(ビルボード効果)ようにし、(0,0)-1.0 による X 軸反転を Bitmap 側の preScale(-1.0f, 1.0f) で打ち消します。

4.2 テクスチャの送信

// テクスチャ画像(Bitmap配列)
val texturesPacket = packetCreator.createRgbaImageFrameVector(
    renderStickers.map { it.bitmap }.toTypedArray()
)
addPacketToInputStream("gif_textures", texturesPacket, timestamp)
// アスペクト比(テクスチャの縦横比)
val aspectRatiosPacket = packetCreator.createFloat32Vector(
    renderStickers.map { it.aspectRatio }.toFloatArray()
)
addPacketToInputStream("gif_aspect_ratios", aspectRatiosPacket, timestamp)
  • PacketCreator.createRgbaImageFrameVector: 複数の Bitmap を RGBA 形式のパケットに変換
  • gif_textures: テクスチャ画像の入力ストリーム
  • gif_aspect_ratios: 各テクスチャのアスペクト比(正しいスケーリングに必要)

公式サンプルでは createRgbaImageFrame を使用して単一のテクスチャをグラフに渡します。
https://github.com/google-ai-edge/mediapipe/blob/0.10.32/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking/MainActivity.java#L608-L610
本実装では、複数の検出オブジェクトに対応するため createRgbaImageFrameVector複数テクスチャを同時に送信し、gif_aspect_ratioscreateFloat32Vector各テクスチャに対応するアスペクト比の配列を渡すよう拡張します。これにより、検出された各スイッチに異なるラベル(テキスト付きBitmap)を正しい縦横比で表示できます。

ここまでで AR コンテンツをカメラ上に表示できました。

5. 座標の更新

トラッキング中のステッカー座標を更新するには、新しい座標を持つ sticker_proto_string と、リセット対象の ID を含む sticker_sentinels を同一 timestamp で送信します。TrackedAnchorManagerCalculator が該当 ID のトラッキングボックスを破棄し、新しい座標でトラッキングを再開します。

// 更新した座標で Sticker Proto を再構築
val stickersPacket = packetCreator.createSerializedProto(stickerRoll)
addPacketToInputStream("sticker_proto_string", stickersPacket, timestamp)

// リセット対象のステッカー ID を送信
val stickerSentinels = packetCreator.createInt32Vector(updateIds)
addPacketToInputStream("sticker_sentinels", stickerSentinels, timestamp)

公式サンプルでは sticker_sentinel単一のステッカー ID を送信します。
https://github.com/google-ai-edge/mediapipe/blob/0.10.32/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking/MainActivity.java#L342-L344
本実装では sticker_sentinels として createInt32Vector複数のステッカー ID を配列で渡すよう拡張し、物体検出で座標が更新された複数のステッカーを同時にリセットできるようにします。

最後に

以上が MediaPipe Instant Motion Tracking を用いた技術的な実装解説でした。決して容易に導入できる手法ではありませんが、本機能の要件に対して Android に最も適した解決策だと考えています。
以前に ARCore の検証も行いましたが、ARCore は SLAM 技術による事前の 3D マッピングに時間を要し、素早くかつ安定した AR エフェクトの実現には適さなかったため、検証を断念しました。
両フレームワークの違いを以下にまとめます。AR 技術の検討で参考になれば幸いです。

項目 Instant Motion Tracking ARCore
仕組み 2D ボックストラッキング + OpenGL 描画 環境マッピング + 平面検出(SLAM)
デバイス要件 OpenGL ES 対応であれば動作 ARCore 対応デバイスのみ(Google 認定必須)
安定性 検出座標に依存するため補正が必要 空間認識が高精度で安定
導入コスト Bazel ビルド・C++ Calculator のカスタマイズが必要 SDK 導入のみで比較的容易
オープンソース あり(Apache 2.0) なし(プロプライエタリ)
カスタマイズ性 Calculator の追加・変更で柔軟に拡張可能 SDK の API 範囲内に限定
パフォーマンス 軽量(2D トラッキングベースのため CPU/GPU 負荷が低い) 高負荷(環境の 3D 空間マッピングを常時実行)
学習コスト 高い(Bazel・C++・OpenGL・Protocol Buffers の知識が必要) 低い(Android SDK の知見で導入可能)
Facebook

関連記事 | Related Posts