Привет, мир! на Android

Введение

Этот Привет, Мир! Учебное пособие использует MediaPipe Framework для разработки приложения Android, которое запускает граф MediaPipe на Android.

Что вы построите

Простое приложение камеры для обнаружения границ Собела в режиме реального времени, применяемое к живому видеопотоку на устройстве Android.

Edge_detection_android_gpu_gif

Настраивать

  1. Установите MediaPipe Framework в вашей системе. Подробности см. в руководстве по установке Framework .
  2. Установите Android Development SDK и Android NDK. См. также, как это сделать, в [Руководстве по установке Framework].
  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 .

Начальная минимальная настройка приложения

Сначала мы начнем с простого приложения для Android, которое отображает «Hello World!» на экране. Вы можете пропустить этот шаг, если знакомы с созданием приложений Android с использованием bazel .

Создайте новый каталог, в котором вы будете создавать свое приложение для Android. Например, полный код этого руководства можно найти по адресу mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic . В кодовой лаборатории мы будем называть этот путь $APPLICATION_PATH .

Обратите внимание, что в пути к приложению:

  • Приложение называется helloworld .
  • $PACKAGE_PATH приложения — com.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>

Добавьте простой 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 использует библиотеку Android basic_lib , созданную для создания двоичного APK для установки на ваше устройство Android.

Чтобы создать приложение, используйте следующую команду:

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 Framework, а именно PermissionHelper . Чтобы использовать его, добавьте зависимость "//mediapipe/java/com/google/mediapipe/components:android_components" в правило mediapipe_lib в BUILD .

Чтобы использовать PermissionHelper в MainActivity , добавьте следующую строку в функцию 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 . Чтобы использовать их, нам сначала нужно изменить макет нашего приложения.

Удалите весь блок кода TextView из $APPLICATION_PATH/res/layout/activity_main.xml и добавьте вместо него следующий код:

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

Этот блок кода имеет новый FrameLayout с preview_display_layout и вложенный в него TextView с именем no_camera_access_preview . Если права доступа к камере не предоставлены, наше приложение отобразит 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) добавьте следующие две строки перед запросом разрешений камеры:

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 , чтобы можно было использовать его для отображения кадров камеры с помощью объекта SurfaceTexture с именем previewFrameTexture .

Чтобы использовать previewFrameTexture для получения кадров камеры, мы будем использовать CameraX . Framework предоставляет утилиту под названием 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 , который предоставляет два параметра: FRONT и BACK . Мы можем передать решение из файла BUILD в виде метаданных, чтобы не требовалось никаких изменений кода для создания другой версии приложения с использованием другой камеры.

Предполагая, что мы хотим использовать BACK камеру для обнаружения границ на живой сцене, которую мы просматриваем с камеры, добавьте метаданные в AndroidManifest.xml :

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

и укажите выбор в BUILD в двоичном правиле helloworld для Android с новой записью в manifest_values :

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);

На этом этапе приложение должно быть успешно построено. Однако когда вы запустите приложение на своем устройстве, вы увидите черный экран (хотя разрешения камеры были предоставлены). Это связано с тем, что, хотя мы сохраняем переменную surfaceTexture , предоставленную CameraXPreviewHelper , 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);

Напомним, что мы определили функцию onResume() в MainActivity для подтверждения предоставления разрешений камере и вызова startCamera() . Перед этой проверкой добавьте следующую строку в onResume() для инициализации объекта converter :

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

Этот converter теперь использует GLContext управляемый eglManager .

Нам также необходимо переопределить функцию onPause() в MainActivity , чтобы, если приложение перейдет в состояние паузы, мы могли правильно закрыть 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.

Использование графика MediaPipe в Android

Добавьте соответствующие зависимости

Чтобы использовать граф MediaPipe, нам нужно добавить зависимости к платформе MediaPipe на Android. Сначала мы добавим правило сборки для создания cc_binary с использованием кода JNI платформы MediaPipe, а затем создадим правило 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" к правилу сборки mediapipe_lib в файле BUILD .

Далее нам нужно добавить зависимости, специфичные для графа 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 указывает имя файла двоичного графа, определяемое полем output_name в цели mediapipe_binary_graph . inputVideoStreamName и outputVideoStreamName — это имя входного и выходного видеопотока, указанное на графике соответственно.

Теперь MainActivity необходимо загрузить платформу MediaPipe. Кроме того, фреймворк использует OpenCV, поэтому MainActvity также должен загружать OpenCV . Используйте следующий код в MainActivity (внутри класса, но не внутри какой-либо функции), чтобы загрузить обе зависимости:

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

Используйте график в MainActivity

Сначала нам нужно загрузить ресурс, содержащий .binarypb скомпилированный из файла .pbtxt графика. Для этого мы можем использовать утилиту 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;

и инициализируйте его в onCreate(Bundle) после инициализации eglManager :

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

processor необходимо использовать преобразованные кадры из converter для обработки. Добавьте следующую строку в onResume() после инициализации converter :

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 для VideoSurfaceOutput processor . Когда он уничтожен, мы удаляем его из VideoSurfaceOutput processor .

И все! Теперь вы сможете успешно создать и запустить приложение на устройстве и увидеть, как обнаружение границ Sobel работает в прямом эфире с камеры! Поздравляю!

Edge_detection_android_gpu_gif

Если у вас возникли какие-либо проблемы, ознакомьтесь с полным кодом руководства здесь .