Android で Hello World!

はじめに

この Hello World! チュートリアルでは、MediaPipe フレームワークを使用して、Android で MediaPipe グラフを実行する Android アプリを開発します。

作業内容

Android デバイスでライブ動画ストリームに適用されたリアルタイムの Sobel エッジ検出用のシンプルなカメラアプリ。

edge_detection_android_gpu_gif

セットアップ

  1. MediaPipe Framework をシステムにインストールします。詳しくは、フレームワークのインストール ガイドをご覧ください。
  2. Android Development SDK と Android NDK をインストールします。その方法については、[フレームワークのインストール ガイド]もご覧ください。
  3. Android デバイスで開発者向けオプションを有効にします。
  4. システムに Bazel をセットアップして、Android アプリをビルドしてデプロイします。

エッジ検出のグラフ

ここでは、次のグラフ edge_detection_mobile_gpu.pbtxt を使用します。

# MediaPipe graph that performs GPU Sobel edge detection on a live video stream.
# Used in the examples in
# mediapipe/examples/android/src/java/com/mediapipe/apps/basic and
# mediapipe/examples/ios/edgedetectiongpu.

# Images coming into and out of the graph.
input_stream: "input_video"
output_stream: "output_video"

# Converts RGB images into luminance images, still stored in RGB format.
node: {
  calculator: "LuminanceCalculator"
  input_stream: "input_video"
  output_stream: "luma_video"
}

# Applies the Sobel filter to luminance images stored in RGB format.
node: {
  calculator: "SobelEdgesCalculator"
  input_stream: "luma_video"
  output_stream: "output_video"
}

グラフを図にまとめると、以下のようになります。

edge_detection_mobile_gpu

このグラフには、デバイスのカメラから提供されるすべての受信フレームについて、input_video という名前の単一の入力ストリームがあります。

グラフの最初のノードである LuminanceCalculator は、単一のパケット(画像フレーム)を受け取り、OpenGL シェーダーを使用して輝度の変更を適用します。生成された画像フレームは luma_video 出力ストリームに送信されます。

2 番目のノード SobelEdgesCalculator は、luma_video ストリームの受信パケットにエッジ検出を適用し、output_video 出力ストリームに結果を出力します。

Android アプリは、output_video ストリームの出力画像フレームを表示します。

最小限のアプリケーションの初期セットアップ

まず、画面に「Hello World!」と表示するシンプルな Android アプリから始めます。bazel を使用した Android アプリのビルドに精通している場合は、この手順をスキップできます。

Android アプリを作成する新しいディレクトリを作成します。たとえば、このチュートリアルの完全なコードは mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic にあります。Codelab では、このパスを $APPLICATION_PATH と呼びます。

アプリケーションのパスでは、次の点に注意してください。

  • アプリケーションの名前は helloworld です。
  • アプリの $PACKAGE_PATHcom.google.mediapipe.apps.basic です。このチュートリアルのコード スニペットで使用されるため、コード スニペットをコピーして使用する場合は、必ず独自の $PACKAGE_PATH を使用してください。

activity_main.xml ファイルを $APPLICATION_PATH/res/layout に追加します。これにより、アプリの全画面に Hello World! という文字列とともに TextView が表示されます。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello World!"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

</android.support.constraint.ConstraintLayout>

次のように、activity_main.xml レイアウトのコンテンツを読み込むシンプルな MainActivity.java$APPLICATION_PATH に追加します。

package com.google.mediapipe.apps.basic;

import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;

/** Bare-bones main activity. */
public class MainActivity extends AppCompatActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
  }
}

マニフェスト ファイル AndroidManifest.xml$APPLICATION_PATH に追加します。これにより、アプリの起動時に MainActivity を起動します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.google.mediapipe.apps.basic">

  <uses-sdk
      android:minSdkVersion="19"
      android:targetSdkVersion="19" />

  <application
      android:allowBackup="true"
      android:label="${appName}"
      android:supportsRtl="true"
      android:theme="@style/AppTheme">
      <activity
          android:name="${mainActivity}"
          android:exported="true"
          android:screenOrientation="portrait">
          <intent-filter>
              <action android:name="android.intent.action.MAIN" />
              <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
      </activity>
  </application>

</manifest>

このアプリでは Theme.AppCompat テーマを使用しているため、適切なテーマ参照が必要です。colors.xml$APPLICATION_PATH/res/values/ に追加します。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#D81B60</color>
</resources>

$APPLICATION_PATH/res/values/styles.xml を追加します。

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

</resources>

アプリをビルドするには、BUILD ファイルを $APPLICATION_PATH に追加します。次のように、マニフェスト内の ${appName}${mainActivity} は、BUILD で指定された文字列に置き換えられます。

android_library(
    name = "basic_lib",
    srcs = glob(["*.java"]),
    manifest = "AndroidManifest.xml",
    resource_files = glob(["res/**"]),
    deps = [
        "//third_party:android_constraint_layout",
        "//third_party:androidx_appcompat",
    ],
)

android_binary(
    name = "helloworld",
    manifest = "AndroidManifest.xml",
    manifest_values = {
        "applicationId": "com.google.mediapipe.apps.basic",
        "appName": "Hello World",
        "mainActivity": ".MainActivity",
    },
    multidex = "native",
    deps = [
        ":basic_lib",
    ],
)

android_library ルールは、MainActivity、リソース ファイル、AndroidManifest.xml の依存関係を追加します。

android_binary ルールは、生成された basic_lib Android ライブラリを使用して、Android デバイスにインストールするためのバイナリ APK をビルドします。

アプリをビルドするには、次のコマンドを使用します。

bazel build -c opt --config=android_arm64 $APPLICATION_PATH:helloworld

生成された APK ファイルを adb install を使用してインストールします。次に例を示します。

adb install bazel-bin/$APPLICATION_PATH/helloworld.apk

デバイスでアプリを開きます。Hello World! というテキストが画面に表示されます。

bazel_hello_world_android

CameraX 経由でカメラを使用する

カメラの権限

アプリでカメラを使用するには、カメラへのアクセスを提供するようユーザーにリクエストする必要があります。カメラの権限をリクエストするには、AndroidManifest.xml に次の行を追加します。

<!-- For using the camera -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />

同じファイル内で、最小 SDK バージョンを 21 に、ターゲット SDK バージョンを 27 に変更します。

<uses-sdk
    android:minSdkVersion="21"
    android:targetSdkVersion="27" />

これにより、ユーザーにカメラの権限をリクエストするように求められ、カメラへのアクセスに CameraX ライブラリを使用できるようになります。

カメラの権限をリクエストするには、MediaPipe フレームワーク コンポーネントが提供するユーティリティ(PermissionHelper)を使用します。これを使用するには、BUILDmediapipe_lib ルールに依存関係 "//mediapipe/java/com/google/mediapipe/components:android_components" を追加します。

MainActivityPermissionHelper を使用するには、onCreate 関数に次の行を追加します。

PermissionHelper.checkAndRequestCameraPermissions(this);

これにより、このアプリでカメラを使用する権限をリクエストするダイアログが画面に表示されます。

ユーザーのレスポンスを処理する次のコードを追加します。

@Override
public void onRequestPermissionsResult(
    int requestCode, String[] permissions, int[] grantResults) {
  super.onRequestPermissionsResult(requestCode, permissions, grantResults);
  PermissionHelper.onRequestPermissionsResult(requestCode, permissions, grantResults);
}

@Override
protected void onResume() {
  super.onResume();
  if (PermissionHelper.cameraPermissionsGranted(this)) {
    startCamera();
  }
}

public void startCamera() {}

今のところ、startCamera() メソッドは空のままにします。ユーザーがプロンプトに応答すると、MainActivity が再開され、onResume() が呼び出されます。このコードは、カメラを使用する権限が付与されていることを確認してから、カメラを起動します。

アプリケーションを再ビルドしてインストールします。アプリのカメラへのアクセスをリクエストするプロンプトが表示されます。

カメラへのアクセス

カメラに権限があれば、フレームを開始してカメラからフレームを取得できます。

カメラからフレームを表示するには、SurfaceView を使用します。カメラからの各フレームは SurfaceTexture オブジェクトに格納されます。これらを使用するには、まずアプリのレイアウトを変更する必要があります。

$APPLICATION_PATH/res/layout/activity_main.xml から TextView コードブロック全体を削除し、代わりに次のコードを追加します。

<FrameLayout
    android:id="@+id/preview_display_layout"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:layout_weight="1">
    <TextView
        android:id="@+id/no_camera_access_view"
        android:layout_height="fill_parent"
        android:layout_width="fill_parent"
        android:gravity="center"
        android:text="@string/no_camera_access" />
</FrameLayout>

このコードブロックには、preview_display_layout という名前の新しい FrameLayout と、その内部に no_camera_access_preview という名前の TextView がネストされています。カメラのアクセス権限が付与されていない場合、アプリは TextView を、変数 no_camera_access に格納された文字列メッセージとともに表示します。$APPLICATION_PATH/res/values/strings.xml ファイルに次の行を追加します。

<string name="no_camera_access" translatable="false">Please grant camera permissions.</string>

ユーザーがカメラの権限を付与しないと、画面は次のようになります。

missing_camera_permission_android

次に、SurfaceTexture オブジェクトと SurfaceView オブジェクトを MainActivity に追加します。

private SurfaceTexture previewFrameTexture;
private SurfaceView previewDisplayView;

onCreate(Bundle) 関数で、カメラの権限をリクエストする前に次の 2 行を追加します。

previewDisplayView = new SurfaceView(this);
setupPreviewDisplayView();

次に、setupPreviewDisplayView() を定義するコードを追加します。

private void setupPreviewDisplayView() {
  previewDisplayView.setVisibility(View.GONE);
  ViewGroup viewGroup = findViewById(R.id.preview_display_layout);
  viewGroup.addView(previewDisplayView);
}

新しい SurfaceView オブジェクトを定義して preview_display_layout FrameLayout オブジェクトに追加し、previewFrameTexture という名前の SurfaceTexture オブジェクトを使用してカメラフレームを表示できるようにします。

カメラフレームの取得に previewFrameTexture を使用するには、CameraX を使用します。フレームワークには、CameraX を使用するための CameraXPreviewHelper という名前のユーティリティが用意されています。 このクラスは、onCameraStarted(@Nullable SurfaceTexture) を介してカメラを起動するとリスナーを更新します。

このユーティリティを使用するには、BUILD ファイルを変更して "//mediapipe/java/com/google/mediapipe/components:android_camerax_helper" への依存関係を追加します。

CameraXPreviewHelper をインポートして、次の行を MainActivity に追加します。

private CameraXPreviewHelper cameraHelper;

これで、実装を startCamera() に追加できるようになりました。

public void startCamera() {
  cameraHelper = new CameraXPreviewHelper();
  cameraHelper.setOnCameraStartedListener(
    surfaceTexture -> {
      previewFrameTexture = surfaceTexture;
      // Make the display view visible to start showing the preview.
      previewDisplayView.setVisibility(View.VISIBLE);
    });
}

これにより、新しい CameraXPreviewHelper オブジェクトが作成され、そのオブジェクトに匿名リスナーが追加されます。cameraHelper がカメラを開始したことを通知し、フレームを取得する surfaceTexture が利用可能である場合、その surfaceTexturepreviewFrameTexture として保存し、previewDisplayView を可視化して、previewFrameTexture からのフレームの表示を開始できるようにします。

ただし、カメラを起動する前に、使用するカメラを決定する必要があります。CameraXPreviewHelperCameraHelper を継承し、FRONTBACK の 2 つのオプションを提供します。別のカメラを使用して別のバージョンのアプリをビルドするためにコードを変更する必要がないように、BUILD ファイルの決定をメタデータとして渡すことができます。

BACK カメラを使用して、カメラから見たライブシーンでエッジ検出を実行する場合、メタデータを AndroidManifest.xml に追加します。

      ...
      <meta-data android:name="cameraFacingFront" android:value="${cameraFacingFront}"/>
  </application>
</manifest>

manifest_values の新しいエントリを使用して、helloworld Android バイナリルールの BUILD で選択範囲を指定します。

manifest_values = {
    "applicationId": "com.google.mediapipe.apps.basic",
    "appName": "Hello World",
    "mainActivity": ".MainActivity",
    "cameraFacingFront": "False",
},

次に、MainActivity で、manifest_values で指定されたメタデータを取得するために、ApplicationInfo オブジェクトを追加します。

private ApplicationInfo applicationInfo;

onCreate() 関数に以下を追加します。

try {
  applicationInfo =
      getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
} catch (NameNotFoundException e) {
  Log.e(TAG, "Cannot find application info: " + e);
}

startCamera() 関数の最後に次の行を追加します。

CameraHelper.CameraFacing cameraFacing =
    applicationInfo.metaData.getBoolean("cameraFacingFront", false)
        ? CameraHelper.CameraFacing.FRONT
        : CameraHelper.CameraFacing.BACK;
cameraHelper.startCamera(this, cameraFacing, /*unusedSurfaceTexture=*/ null);

この時点で、アプリケーションは正常にビルドされるはずです。ただし、デバイスでアプリを実行すると、(カメラの権限が付与されていても)黒い画面が表示されます。これは、CameraXPreviewHelper によって提供された surfaceTexture 変数を保存しても、previewSurfaceView はまだその出力を使用して画面に表示されないためです。

MediaPipe グラフでフレームを使用するため、このチュートリアルではカメラ出力を直接表示するコードは追加しません。代わりに、処理対象のカメラフレームを MediaPipe グラフに送信し、グラフの出力を画面に表示する方法に進みます。

ExternalTextureConverterのセットアップ

SurfaceTexture は、ストリームから画像フレームを OpenGL ES テクスチャとしてキャプチャします。MediaPipe グラフを使用するには、カメラからキャプチャしたフレームを通常の Open GL テクスチャ オブジェクトに保存する必要があります。フレームワークには、SurfaceTexture オブジェクトに格納されている画像を通常の OpenGL テクスチャ オブジェクトに変換する ExternalTextureConverter クラスが用意されています。

ExternalTextureConverter を使用するには EGLContext も必要です。これは、EglManager オブジェクトによって作成および管理されます。EglManager"//mediapipe/java/com/google/mediapipe/glutil" を使用するには、BUILD ファイルに依存関係を追加します。

MainActivity に次の宣言を追加します。

private EglManager eglManager;
private ExternalTextureConverter converter;

onCreate(Bundle) 関数に、カメラの権限をリクエストする前に eglManager オブジェクトを初期化するステートメントを追加します。

eglManager = new EglManager(null);

カメラの権限が付与されていることを確認し、startCamera() を呼び出すために、MainActivityonResume() 関数を定義したことを思い出してください。このチェックの前に、onResume() に次の行を追加して、converter オブジェクトを初期化します。

converter = new ExternalTextureConverter(eglManager.getContext());

この converter は、eglManager が管理する GLContext を使用するようになりました。

また、アプリが一時停止状態になった場合に converter を適切に閉じるように、MainActivityonPause() 関数をオーバーライドする必要もあります。

@Override
protected void onPause() {
  super.onPause();
  converter.close();
}

previewFrameTexture の出力を converter にパイプするには、次のコードブロックを setupPreviewDisplayView() に追加します。

previewDisplayView
 .getHolder()
 .addCallback(
     new SurfaceHolder.Callback() {
       @Override
       public void surfaceCreated(SurfaceHolder holder) {}

       @Override
       public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
         // (Re-)Compute the ideal size of the camera-preview display (the area that the
         // camera-preview frames get rendered onto, potentially with scaling and rotation)
         // based on the size of the SurfaceView that contains the display.
         Size viewSize = new Size(width, height);
         Size displaySize = cameraHelper.computeDisplaySizeFromViewSize(viewSize);

         // Connect the converter to the camera-preview frames as its input (via
         // previewFrameTexture), and configure the output width and height as the computed
         // display size.
         converter.setSurfaceTextureAndAttachToGLContext(
             previewFrameTexture, displaySize.getWidth(), displaySize.getHeight());
       }

       @Override
       public void surfaceDestroyed(SurfaceHolder holder) {}
     });

このコードブロックでは、カスタムの SurfaceHolder.CallbackpreviewDisplayView に追加し、surfaceChanged(SurfaceHolder holder, int format, int width, int height) 関数を実装して、デバイス画面上のカメラフレームの適切なディスプレイ サイズを計算し、previewFrameTexture オブジェクトを関連付けて、計算された displaySize のフレームを converter に送信します。

これで、MediaPipe グラフでカメラフレームを使用する準備が整いました。

Android での MediaPipe グラフの使用

関連する依存関係を追加する

MediaPipe グラフを使用するには、Android の MediaPipe フレームワークに依存関係を追加する必要があります。まず、MediaPipe フレームワークの JNI コードを使用して cc_binary をビルドするビルドルールを追加します。次に、このバイナリをアプリで使用するための cc_library ルールを作成します。BUILD ファイルに次のコードブロックを追加します。

cc_binary(
    name = "libmediapipe_jni.so",
    linkshared = 1,
    linkstatic = 1,
    deps = [
        "//mediapipe/java/com/google/mediapipe/framework/jni:mediapipe_framework_jni",
    ],
)

cc_library(
    name = "mediapipe_jni_lib",
    srcs = [":libmediapipe_jni.so"],
    alwayslink = 1,
)

BUILD ファイルの mediapipe_lib ビルドルールに依存関係 ":mediapipe_jni_lib" を追加します。

次に、アプリケーションで使用する MediaPipe グラフに固有の依存関係を追加する必要があります。

まず、libmediapipe_jni.so ビルドルール内のすべての計算ツールコードに依存関係を追加します。

"//mediapipe/graphs/edge_detection:mobile_calculators",

MediaPipe グラフは .pbtxt ファイルですが、アプリで使用するには、mediapipe_binary_graph ビルドルールを使用して .binarypb ファイルを生成する必要があります。

helloworld Android バイナリ ビルドルールで、グラフに固有の mediapipe_binary_graph ターゲットをアセットとして追加します。

assets = [
  "//mediapipe/graphs/edge_detection:mobile_gpu_binary_graph",
],
assets_dir = "",

assets ビルドルールでは、グラフで使用される TensorFlowLite モデルなどの他のアセットを追加することもできます。

さらに、後で MainActivity で取得できるように、グラフ固有のプロパティに manifest_values を追加します。

manifest_values = {
    "applicationId": "com.google.mediapipe.apps.basic",
    "appName": "Hello World",
    "mainActivity": ".MainActivity",
    "cameraFacingFront": "False",
    "binaryGraphName": "mobile_gpu.binarypb",
    "inputVideoStreamName": "input_video",
    "outputVideoStreamName": "output_video",
},

binaryGraphName は、mediapipe_binary_graph ターゲットの output_name フィールドによって決定されるバイナリグラフのファイル名を示します。inputVideoStreamNameoutputVideoStreamName は、それぞれグラフで指定された入力動画ストリーム名と出力動画ストリーム名です。

次に、MainActivity が MediaPipe フレームワークを読み込む必要があります。また、フレームワークは OpenCV を使用するため、MainActvityOpenCV を読み込む必要があります。両方の依存関係を読み込むには、MainActivity で次のコードを使用します(クラス内のみ、関数内ではありません)。

static {
  // Load all native libraries needed by the app.
  System.loadLibrary("mediapipe_jni");
  System.loadLibrary("opencv_java3");
}

MainActivity でグラフを使用する

まず、グラフの .pbtxt ファイルからコンパイルされた .binarypb を含むアセットを読み込む必要があります。そのためには、MediaPipe ユーティリティの AndroidAssetUtil を使用します。

eglManager を初期化する前に、onCreate(Bundle) でアセット マネージャーを初期化します。

// Initialize asset manager so that MediaPipe native libraries can access the app assets, e.g.,
// binary graphs.
AndroidAssetUtil.initializeNativeAssetManager(this);

ここで、converter によって準備されたカメラフレームを MediaPipe グラフに送信してグラフを実行し、出力を準備して previewDisplayView を更新して出力を表示する FrameProcessor オブジェクトを設定する必要があります。次のコードを追加して FrameProcessor を宣言します。

private FrameProcessor processor;

eglManager を初期化した後、onCreate(Bundle) で初期化します。

processor =
    new FrameProcessor(
        this,
        eglManager.getNativeContext(),
        applicationInfo.metaData.getString("binaryGraphName"),
        applicationInfo.metaData.getString("inputVideoStreamName"),
        applicationInfo.metaData.getString("outputVideoStreamName"));

processor は、処理のために converter から変換されたフレームを使用する必要があります。converter を初期化した後、次の行を onResume() に追加します。

converter.setConsumer(processor);

processor は出力を previewDisplayView に送信する必要があります。これを行うには、次の関数定義をカスタム SurfaceHolder.Callback に追加します。

@Override
public void surfaceCreated(SurfaceHolder holder) {
  processor.getVideoSurfaceOutput().setSurface(holder.getSurface());
}

@Override
public void surfaceDestroyed(SurfaceHolder holder) {
  processor.getVideoSurfaceOutput().setSurface(null);
}

SurfaceHolder が作成されると、processorVideoSurfaceOutputSurface が設定されました。破棄されると、processorVideoSurfaceOutput から削除されます。

以上で終了です。これで、デバイス上でアプリを正常にビルドして実行できるようになり、ライブカメラフィードで Sobel エッジ検出が実行されていることを確認できます。おめでとうございます!

edge_detection_android_gpu_gif

問題が発生した場合は、こちらのチュートリアルの完全なコードをご覧ください。