透過 Android 使用 Hello World!

簡介

這個 Hello World! 教學課程使用 MediaPipe Framework,開發可在 Android 上執行 MediaPipe 圖表的 Android 應用程式。

建構內容

簡易的相機應用程式,適用於 Android 裝置的即時影像串流,可進行即時 Sobel 邊緣偵測。

edge_detection_android_gpu_gif

設定

  1. 在系統上安裝 MediaPipe 架構,詳情請參閱架構安裝指南
  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 輸出串流。

第二個節點 SobelEdgesCalculator 會對 luma_video 串流中的傳入封包套用邊緣偵測功能,並輸出 output_video 輸出串流。

我們的 Android 應用程式會顯示 output_video 串流的輸出圖片影格。

可直接設定應用程式

首先,我們先從在螢幕上顯示「Hello World!」的簡易 Android 應用程式開始著手。如果您熟悉使用 bazel 建構 Android 應用程式,可以略過這個步驟。

建立新的目錄,以便建立 Android 應用程式。舉例來說,您可以在 mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic 找到本教學課程的完整程式碼。在本程式碼研究室中,我們會將這個路徑稱為 $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>

將簡單的 MainActivity.java 新增至 $APPLICATION_PATH,以載入 activity_main.xml 版面配置的內容,如下所示:

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>

新增 styles.xml$APPLICATION_PATH/res/values/

<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 程式庫建構二進位 APK,以便在 Android 裝置上安裝。

如要建構應用程式,請使用下列指令:

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

使用 adb install 安裝產生的 APK 檔案。例如:

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 Framework 元件提供的公用程式,也就是 PermissionHelper。如要使用,請在 BUILDmediapipe_lib 規則中新增依附元件 "//mediapipe/java/com/google/mediapipe/components:android_components"

如要在 MainActivity 中使用 PermissionHelper,請在 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

現在,我們要將 SurfaceTextureSurfaceView 物件新增至 MainActivity

private SurfaceTexture previewFrameTexture;
private SurfaceView previewDisplayView;

onCreate(Bundle) 函式中,在要求相機權限「之前」新增以下兩行:

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 物件,以便使用名為 previewFrameTextureSurfaceTexture 物件顯示相機影格。

如要使用 previewFrameTexture 取得相機畫面,我們會使用 CameraX。架構提供名為 CameraXPreviewHelper 的公用程式,以便使用 CameraX。這個類別會在透過 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 時,我們會將該 surfaceTexture 儲存為 previewFrameTexture,並讓 previewDisplayView 顯示,這樣我們就能開始看到 previewFrameTexture 的影格。

不過,在啟動相機之前,我們需要決定要使用哪一個相機。CameraXPreviewHelper 繼承自 CameraHelper,其提供 FRONTBACK 這兩個選項。我們可以從 BUILD 檔案傳入決定,因此不必變更程式碼,就能用其他相機建構另一個應用程式版本。

假設我們想使用 BACK 相機針對從相機檢視的即時場景執行邊緣偵測,請將中繼資料新增至 AndroidManifest.xml

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

然後在 helloworld Android 二進位規則的 BUILD 中指定選項 (在 manifest_values 中有新項目):

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

現在,請在 MainActivity 中新增 ApplicationInfo 物件,以擷取 manifest_values 中指定的中繼資料:

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 紋理物件中。架構提供 ExternalTextureConverter 類別,可將 SurfaceTexture 物件中儲存的圖片轉換為一般 OpenGL 紋理物件。

如要使用 ExternalTextureConverter,您還必須擁有由 EglManager 物件建立及管理的 EGLContext。在 BUILD 檔案新增依附元件,以使用 EglManager"//mediapipe/java/com/google/mediapipe/glutil"

MainActivity 中新增下列宣告:

private EglManager eglManager;
private ExternalTextureConverter converter;

onCreate(Bundle) 函式中新增陳述式,用於初始化 eglManager 物件,然後再要求相機權限:

eglManager = new EglManager(null);

提醒您,我們在 MainActivity 中定義了 onResume() 函式,藉此確認已授予相機權限,並呼叫 startCamera()。在檢查之前,請在 onResume() 中新增以下這行程式碼,初始化 converter 物件:

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

這個 converter 現在會使用由 eglManager 管理的 GLContext

我們也必須覆寫 MainActivity 中的 onPause() 函式,以便在應用程式進入暫停狀態時正確關閉 converter

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

如要向 converter 傳送 previewFrameTexture 的輸出內容,請將下列程式碼區塊新增至 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) {}
     });

在這個程式碼區塊中,我們為 previewDisplayView 新增自訂 SurfaceHolder.Callback,並實作 surfaceChanged(SurfaceHolder holder, int format, int width, int height) 函式,以便計算裝置螢幕上相機影格的適當顯示大小,並將計算的 displaySize 畫面連結 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,
)

將依附元件 ":mediapipe_jni_lib" 新增至 BUILD 檔案的 mediapipe_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 模型。

此外,請為圖表的特定屬性新增其他 manifest_values,稍後要在 MainActivity 中擷取:

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,因此 MainActvity 也應載入 OpenCV。在 MainActivity (類別中,但不在任何函式中) 使用下列程式碼,載入這兩個依附元件:

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

使用「MainActivity」中的圖表

首先,我們必須載入素材資源,其中包含從圖表的 .pbtxt 檔案編譯的 .binarypb。我們可以使用 MediaPipe 公用程式 AndroidAssetUtil 來達成此目的。

先在 onCreate(Bundle) 中初始化資產管理工具,然後再初始化 eglManager

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

現在,我們要設定 FrameProcessor 物件,將 converter 準備的相機影格傳送至 MediaPipe 圖表並執行圖形、準備輸出內容,然後更新 previewDisplayView 以顯示輸出內容。請加入以下程式碼來宣告 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 時,系統會將 Surface 設為 processorVideoSurfaceOutput。刪除後,我們就會將其從 processorVideoSurfaceOutput 中移除。

大功告成!現在,您應該可以在裝置上成功建構及執行應用程式,並查看在攝影機即時影像中執行的 Sobel 邊緣偵測功能!恭喜!

edge_detection_android_gpu_gif

如果您遇到任何問題,請在這裡查看教學課程的完整程式碼。