Hello World! (Android)

简介

本 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 输出流。

第二个节点 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。这将在应用全屏显示 TextView,其中包含字符串 Hello World!

<?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>

$APPLICATION_PATH 添加一个简单的 MainActivity.java,用于加载 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>

如需构建应用,请向 $APPLICATION_PATH 添加 BUILD 文件,清单中的 ${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 框架组件提供的实用程序,即 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_previewTextView。如果未授予相机访问权限,应用将显示 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>

并使用 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 纹理对象中。该框架提供了一个 ExternalTextureConverter 类,用于将存储在 SurfaceTexture 对象中的图像转换为常规 OpenGL 纹理对象。

如需使用 ExternalTextureConverter,我们还需要 EGLContext,它由 EglManager 对象创建和管理。向 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();
}

如需将 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.Callback 添加到 previewDisplayView 并实现 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,
)

将依赖项 ":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

在初始化 eglManager 之前,先在 onCreate(Bundle) 中初始化资产管理器:

// 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 后,我们就获得了 processorVideoSurfaceOutputSurface。销毁后,我们会将其从 processorVideoSurfaceOutput 中移除。

这样就大功告成了!现在,您应该能够在设备上成功构建并运行应用,并查看在实时摄像头画面上运行的 Sobel 边缘检测!恭喜!

edge_detection_android_gpu_gif

如果您遇到任何问题,请在此处查看教程的完整代码。