Hello World! en Android

Introducción

En este instructivo de Hello World!, se usa el framework de MediaPipe para desarrollar una aplicación para Android que ejecute un gráfico de MediaPipe en Android.

Qué crearás

Una app de cámara simple para la detección de bordes de Sobel en tiempo real aplicada a una transmisión de video en vivo en un dispositivo Android.

edge_detection_android_gpu_gif

Configuración

  1. Instala MediaPipe Framework en tu sistema. Consulta la Guía de instalación del framework para obtener más detalles.
  2. Instala el SDK de desarrollo de Android y el NDK de Android. Consulta cómo hacerlo también en [Guía de instalación del framework].
  3. Habilita las opciones para desarrolladores en tu dispositivo Android.
  4. Configura Bazel en tu sistema para compilar e implementar la app para Android.

Gráfico de detección de bordes

Usaremos el siguiente gráfico, 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"
}

A continuación, se muestra una visualización del gráfico:

edge_detection_mobile_gpu

Este gráfico tiene una sola transmisión de entrada llamada input_video para todos los fotogramas entrantes que proporcionará la cámara del dispositivo.

El primer nodo del gráfico, LuminanceCalculator, toma un solo paquete (marco de imagen) y aplica un cambio de luminancia con un sombreador OpenGL. El marco de imagen resultante se envía a la transmisión de salida luma_video.

El segundo nodo, SobelEdgesCalculator, aplica la detección de perímetro a los paquetes entrantes en la transmisión luma_video y genera como resultado una transmisión de salida output_video.

Nuestra aplicación para Android mostrará los marcos de imagen de salida de la transmisión output_video.

Configuración inicial mínima de la aplicación

Primero, comenzaremos con una aplicación para Android simple que muestre “Hello World!” en la pantalla. Puedes omitir este paso si estás familiarizado con la compilación de aplicaciones para Android con bazel.

Crea un directorio nuevo donde crearás tu aplicación para Android. Por ejemplo, el código completo de este instructivo se puede encontrar en mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic. Nos referiremos a esta ruta de acceso como $APPLICATION_PATH en todo el codelab.

Ten en cuenta que en la ruta a la aplicación sucede lo siguiente:

  • La aplicación se llama helloworld.
  • El $PACKAGE_PATH de la aplicación es com.google.mediapipe.apps.basic. Esto se usa en los fragmentos de código de este instructivo, por lo que debes recordar usar tu propio $PACKAGE_PATH cuando copies o uses los fragmentos de código.

Agrega un archivo activity_main.xml a $APPLICATION_PATH/res/layout. Se mostrará un TextView en la pantalla completa de la aplicación con la cadena 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>

Agrega un MainActivity.java simple a $APPLICATION_PATH, que carga el contenido del diseño activity_main.xml como se muestra a continuación:

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

Agrega un archivo de manifiesto, AndroidManifest.xml, a $APPLICATION_PATH, que inicie MainActivity cuando se inicie la app:

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

En nuestra aplicación, usamos un tema Theme.AppCompat, por lo que necesitamos las referencias correspondientes al tema. Agrega colors.xml a $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>

Agrega styles.xml a $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>

Para compilar la aplicación, agrega un archivo BUILD a $APPLICATION_PATH, y los elementos ${appName} y ${mainActivity} del manifiesto se reemplazarán por cadenas especificadas en BUILD, como se muestra a continuación.

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",
    ],
)

La regla android_library agrega dependencias para MainActivity, los archivos de recursos y AndroidManifest.xml.

La regla android_binary usa la biblioteca basic_lib de Android generada para compilar un APK binario que se debe instalar en tu dispositivo Android.

Para compilar la app, usa el siguiente comando:

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

Instala el archivo APK generado con adb install. Por ejemplo:

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

Abre la aplicación en tu dispositivo. Debería mostrar una pantalla con el texto Hello World!.

bazel_hello_world_android

Cómo usar la cámara mediante CameraX

Permisos de la cámara

Para usar la cámara en nuestra aplicación, debemos solicitarle al usuario que proporcione acceso a la cámara. Para solicitar permisos de cámara, agrega lo siguiente a AndroidManifest.xml:

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

Cambia la versión mínima del SDK a 21 y la versión del SDK de destino a 27 en el mismo archivo:

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

Esto garantiza que se le pida al usuario que solicite permiso de cámara y nos permite usar la biblioteca de CameraX para acceder a la cámara.

Para solicitar permisos de cámara, podemos usar una utilidad que proporcionan los componentes del framework de MediaPipe, es decir, PermissionHelper. Para usarla, agrega una dependencia "//mediapipe/java/com/google/mediapipe/components:android_components" en la regla mediapipe_lib en BUILD.

Para usar PermissionHelper en MainActivity, agrega la siguiente línea a la función onCreate:

PermissionHelper.checkAndRequestCameraPermissions(this);

Esto le solicita al usuario un diálogo en la pantalla que le solicita permisos para usar la cámara en esta aplicación.

Agrega el siguiente código para controlar la respuesta del usuario:

@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() {}

Por ahora, dejaremos el método startCamera() vacío. Cuando el usuario responda al mensaje, se reanudará MainActivity y se llamará a onResume(). El código confirmará que se otorgaron los permisos para usar la cámara y, luego, iniciará la cámara.

Vuelve a compilar e instala la aplicación. Ahora deberías ver un mensaje que solicita acceso a la cámara para la aplicación.

Acceso a la cámara

Con los permisos de cámara disponibles, podemos iniciar y recuperar fotogramas de la cámara.

Para ver los fotogramas de la cámara, usaremos un objeto SurfaceView. Cada fotograma de la cámara se almacenará en un objeto SurfaceTexture. Para usarlas, primero debemos cambiar el diseño de nuestra aplicación.

Quita todo el bloque de código TextView de $APPLICATION_PATH/res/layout/activity_main.xml y, en su lugar, agrega el siguiente código:

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

Este bloque de código tiene un nuevo FrameLayout llamado preview_display_layout y un TextView anidado dentro de él, llamado no_camera_access_preview. Cuando no se otorgan permisos de acceso a la cámara, nuestra aplicación mostrará el TextView con un mensaje de cadena, almacenado en la variable no_camera_access. Agrega la siguiente línea al archivo $APPLICATION_PATH/res/values/strings.xml:

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

Cuando el usuario no otorga permiso de acceso a la cámara, la pantalla se verá de la siguiente manera:

missing_camera_permission_android

Ahora, agregaremos los objetos SurfaceTexture y SurfaceView a MainActivity:

private SurfaceTexture previewFrameTexture;
private SurfaceView previewDisplayView;

En la función onCreate(Bundle), agrega las siguientes dos líneas antes de solicitar permisos de la cámara:

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

Ahora, agrega el código que define setupPreviewDisplayView():

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

Se define un nuevo objeto SurfaceView y se agrega al objeto preview_display_layout FrameLayout para que podamos usarlo para mostrar los fotogramas de la cámara usando un objeto SurfaceTexture llamado previewFrameTexture.

Si quieres usar previewFrameTexture para obtener marcos de cámara, usaremos CameraX. El framework proporciona una utilidad llamada CameraXPreviewHelper para usar CameraX. Esta clase actualiza un objeto de escucha cuando se inicia la cámara mediante onCameraStarted(@Nullable SurfaceTexture).

Si deseas usar esta utilidad, modifica el archivo BUILD para agregar una dependencia en "//mediapipe/java/com/google/mediapipe/components:android_camerax_helper".

Ahora importa CameraXPreviewHelper y agrega la siguiente línea a MainActivity:

private CameraXPreviewHelper cameraHelper;

Ahora, podemos agregar nuestra implementación a 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);
    });
}

Esto crea un objeto CameraXPreviewHelper nuevo y le agrega un objeto de escucha anónimo. Cuando cameraHelper indica que se inició la cámara y hay disponible un surfaceTexture para tomar fotogramas, guardamos ese surfaceTexture como previewFrameTexture y hacemos que previewDisplayView sea visible para que podamos comenzar a ver los fotogramas de previewFrameTexture.

Sin embargo, antes de iniciar la cámara, debemos decidir qué cámara queremos usar. CameraXPreviewHelper se hereda de CameraHelper, que proporciona dos opciones: FRONT y BACK. Podemos pasar la decisión del archivo BUILD como metadatos, de modo que no se requiera ningún cambio de código para compilar otra versión de la app con una cámara diferente.

Si queremos usar la cámara BACK para realizar la detección de bordes en una escena en vivo que vemos desde la cámara, agrega los metadatos a AndroidManifest.xml:

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

y especifica la selección en BUILD, en la regla binaria de Android helloworld con una entrada nueva en manifest_values:

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

Ahora, en MainActivity para recuperar los metadatos especificados en manifest_values, agrega un objeto ApplicationInfo:

private ApplicationInfo applicationInfo;

En la función onCreate(), agrega lo siguiente:

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

Ahora agrega la siguiente línea al final de la función startCamera():

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

En este punto, la aplicación debería compilarse correctamente. Sin embargo, cuando ejecutes la aplicación en tu dispositivo, verás una pantalla negra (aunque se hayan otorgado permisos de cámara). Esto se debe a que, aunque guardamos la variable surfaceTexture proporcionada por CameraXPreviewHelper, previewSurfaceView aún no usa su resultado ni lo muestra en pantalla.

Como queremos usar los fotogramas en un gráfico de MediaPipe, no agregaremos código para ver la salida de la cámara directamente en este instructivo. En cambio, pasaremos a la sección sobre cómo enviar fotogramas de la cámara para su procesamiento a un gráfico de MediaPipe y mostrar el resultado del gráfico en la pantalla.

Configuración de ExternalTextureConverter

Un objeto SurfaceTexture captura los fotogramas de imagen de una transmisión como una textura de OpenGL ES. Para usar un gráfico de MediaPipe, los fotogramas capturados desde la cámara deben almacenarse en un objeto de textura Open GL normal. El framework proporciona una clase, ExternalTextureConverter, para convertir la imagen almacenada en un objeto SurfaceTexture en un objeto de textura OpenGL normal.

Para usar ExternalTextureConverter, también necesitamos un EGLContext, que se crea y administra con un objeto EglManager. Agrega una dependencia al archivo BUILD para usar EglManager, "//mediapipe/java/com/google/mediapipe/glutil".

En MainActivity, agrega las siguientes declaraciones:

private EglManager eglManager;
private ExternalTextureConverter converter;

En la función onCreate(Bundle), agrega una sentencia para inicializar el objeto eglManager antes de solicitar permisos de cámara:

eglManager = new EglManager(null);

Recuerda que definimos la función onResume() en MainActivity para confirmar que se otorgaron los permisos de cámara y llamar a startCamera(). Antes de esta verificación, agrega la siguiente línea en onResume() para inicializar el objeto converter:

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

Este converter ahora usa el GLContext administrado por eglManager.

También debemos anular la función onPause() en MainActivity para que, si la aplicación entra en estado de pausa, se cierre la converter correctamente:

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

Para canalizar el resultado de previewFrameTexture a converter, agrega el siguiente bloque de código a 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) {}
     });

En este bloque de código, agregamos un SurfaceHolder.Callback personalizado a previewDisplayView y, luego, implementamos la función surfaceChanged(SurfaceHolder holder, int format, int width, int height) para calcular un tamaño de visualización apropiado de los marcos de cámara en la pantalla del dispositivo, así como para vincular el objeto previewFrameTexture y enviar los marcos del displaySize calculado a converter.

Ya estamos listos para usar marcos de cámara en un gráfico de MediaPipe.

Cómo usar un gráfico de MediaPipe en Android

Agrega dependencias relevantes

Para usar un gráfico de MediaPipe, debemos agregar dependencias al framework de MediaPipe en Android. Primero, agregaremos una regla de compilación para compilar un cc_binary con el código JNI del framework de MediaPipe y, luego, compilaremos una regla cc_library para usar este objeto binario en nuestra aplicación. Agrega el siguiente bloque de código a tu archivo 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,
)

Agrega la dependencia ":mediapipe_jni_lib" a la regla de compilación mediapipe_lib en el archivo BUILD.

A continuación, debemos agregar dependencias específicas al gráfico de MediaPipe que queremos usar en la aplicación.

Primero, agrega dependencias a todo el código de la calculadora en la regla de compilación libmediapipe_jni.so:

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

Los gráficos de MediaPipe son archivos .pbtxt, pero, para usarlos en la aplicación, debemos usar la regla de compilación mediapipe_binary_graph para generar un archivo .binarypb.

En la regla de compilación del objeto binario de Android helloworld, agrega el objetivo mediapipe_binary_graph específico del gráfico como recurso:

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

En la regla de compilación assets, también puedes agregar otros elementos, como los modelos de TensorFlow Lite que se usan en tu grafo.

Además, agrega manifest_values adicionales para propiedades específicas del gráfico, que luego se recuperarán en 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",
},

Ten en cuenta que binaryGraphName indica el nombre de archivo del gráfico binario, determinado por el campo output_name en el destino mediapipe_binary_graph. inputVideoStreamName y outputVideoStreamName son el nombre de transmisión de video por Internet de entrada y salida que se especifica en el gráfico, respectivamente.

Ahora, MainActivity necesita cargar el framework de MediaPipe. Además, el framework usa OpenCV, por lo que MainActvity también debe cargar OpenCV. Usa el siguiente código en MainActivity (dentro de la clase, pero no dentro de ninguna función) para cargar ambas dependencias:

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

Usa el gráfico de MainActivity

Primero, necesitamos cargar el recurso que contiene el .binarypb compilado del archivo .pbtxt del gráfico. Para ello, podemos usar una utilidad MediaPipe, AndroidAssetUtil.

Inicializa el administrador de recursos en onCreate(Bundle) antes de inicializar eglManager:

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

Ahora, debemos configurar un objeto FrameProcessor que envíe los fotogramas de la cámara preparados por converter al gráfico de MediaPipe y lo ejecute, prepare el resultado y, luego, actualice el previewDisplayView para mostrar el resultado. Agrega el siguiente código para declarar el FrameProcessor:

private FrameProcessor processor;

e inicializarlo en onCreate(Bundle) después de inicializar eglManager:

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

processor debe consumir los marcos convertidos de converter para su procesamiento. Agrega la siguiente línea a onResume() después de inicializar converter:

converter.setConsumer(processor);

processor debe enviar su resultado a previewDisplayView. Para ello, agrega las siguientes definiciones de función a nuestra SurfaceHolder.Callback personalizada:

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

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

Cuando se crea el SurfaceHolder, tuvimos el Surface en el VideoSurfaceOutput de la processor. Cuando se destruye, lo quitamos de VideoSurfaceOutput de processor.

Eso es todo. Ahora deberías poder compilar y ejecutar correctamente la aplicación en el dispositivo y ver la detección de borde de Sobel en ejecución en el feed de una cámara en vivo. ¡Felicitaciones!

edge_detection_android_gpu_gif

Si tuviste algún problema, consulta el código completo del instructivo aquí.