Hello World! no Android

Introdução

Neste tutorial, Hello World! usa o MediaPipe Framework para desenvolver um aplicativo Android que executa um gráfico do MediaPipe no Android.

O que você vai criar

Um app de câmera simples para detecção de borda Sobel em tempo real, aplicado a um stream de vídeo ao vivo em um dispositivo Android.

edge_detection_android_gpu_gif

Configuração

  1. Instale o MediaPipe Framework no sistema. Consulte o Guia de instalação do framework para saber mais.
  2. Instale o SDK de desenvolvimento do Android e o Android NDK. Consulte como fazer isso também no [Guia de instalação do framework].
  3. Ative as opções do desenvolvedor no seu dispositivo Android.
  4. Configure o Bazel no seu sistema para criar e implantar o app Android.

Gráfico para detecção de borda

Usaremos o gráfico a seguir, 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"
}

Confira a visualização do gráfico abaixo:

edge_detection_mobile_gpu

Esse gráfico tem um único stream de entrada, chamado input_video, para todos os frames recebidos que serão fornecidos pela câmera do dispositivo.

O primeiro nó no gráfico, LuminanceCalculator, usa um único pacote (frame de imagem) e aplica uma mudança na luminosidade usando um sombreador OpenGL. O frame de imagem resultante é enviado ao stream de saída luma_video.

O segundo nó, SobelEdgesCalculator, aplica a detecção de borda aos pacotes recebidos no stream luma_video e gera resultados no stream de saída output_video.

Nosso aplicativo Android exibirá os frames de imagem de saída do fluxo output_video.

Configuração mínima inicial do aplicativo

Primeiro, começamos com um aplicativo Android simples que exibe "Hello World!" na tela. Você pode pular esta etapa se estiver familiarizado com a criação de aplicativos Android usando bazel.

Crie um novo diretório onde você vai criar o app Android. Por exemplo, o código completo deste tutorial pode ser encontrado em mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic. Chamaremos esse caminho como $APPLICATION_PATH em todo o codelab.

Observe que, no caminho para o aplicativo:

  • O nome do aplicativo é helloworld.
  • O $PACKAGE_PATH do aplicativo é com.google.mediapipe.apps.basic. Isso é usado nos snippets de código neste tutorial. Por isso, lembre-se de usar seu próprio $PACKAGE_PATH ao copiar/usar os snippets de código.

Adicione um arquivo activity_main.xml a $APPLICATION_PATH/res/layout. Um TextView será exibido em tela cheia do aplicativo com a string 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>

Adicione um MainActivity.java simples ao $APPLICATION_PATH que carrega o conteúdo do layout activity_main.xml, conforme mostrado abaixo:

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

Adicione um arquivo de manifesto, AndroidManifest.xml a $APPLICATION_PATH, que inicia MainActivity na inicialização do aplicativo:

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

Estamos usando um tema Theme.AppCompat no nosso app, então precisamos de referências de tema apropriadas. Adicione 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>

Adicione 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 criar o aplicativo, adicione um arquivo BUILD a $APPLICATION_PATH. ${appName} e ${mainActivity} no manifesto serão substituídos por strings especificadas em BUILD, conforme mostrado abaixo.

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

A regra android_library adiciona dependências para MainActivity, arquivos de recursos e AndroidManifest.xml.

A regra android_binary usa a biblioteca Android basic_lib gerada para criar um APK binário para instalação no dispositivo Android.

Para criar o app, use o seguinte comando:

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

Instale o arquivo APK gerado usando adb install. Exemplo:

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

Abra o aplicativo no seu dispositivo. Ele exibirá uma tela com o texto Hello World!.

bazel_hello_world_android

Como usar a câmera via CameraX

Permissões da câmera

Para usar a câmera no nosso aplicativo, precisamos solicitar que o usuário conceda acesso à câmera. Para solicitar permissões de câmera, adicione o seguinte a AndroidManifest.xml:

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

Altere a versão mínima do SDK para 21 e a versão de destino do SDK para 27 no mesmo arquivo:

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

Isso garante que o usuário seja solicitado a solicitar a permissão da câmera e permite usar a biblioteca CameraX para o acesso à câmera.

Para solicitar permissões de câmera, podemos usar um utilitário fornecido pelos componentes do MediaPipe Framework, chamado PermissionHelper. Para usá-lo, adicione uma dependência "//mediapipe/java/com/google/mediapipe/components:android_components" na regra mediapipe_lib em BUILD.

Para usar PermissionHelper em MainActivity, adicione a seguinte linha à função onCreate:

PermissionHelper.checkAndRequestCameraPermissions(this);

Isso solicita que o usuário com uma caixa de diálogo na tela solicite permissões para usar a câmera nesse aplicativo.

Adicione o código a seguir para processar a resposta do usuário:

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

Deixaremos o método startCamera() vazio por enquanto. Quando o usuário responder à solicitação, o MainActivity será retomado e onResume() será chamado. O código confirmará que as permissões de uso da câmera foram concedidas e, em seguida, iniciará a câmera.

Recrie e instale o aplicativo. Aparecerá uma solicitação solicitando acesso à câmera para o aplicativo.

Acesso à câmera

Com as permissões de câmera disponíveis, podemos iniciar e buscar frames da câmera.

Para visualizar os frames da câmera, usaremos um SurfaceView. Cada frame da câmera será armazenado em um objeto SurfaceTexture. Para usá-los, primeiro precisamos mudar o layout do nosso aplicativo.

Remova todo o bloco de código TextView do $APPLICATION_PATH/res/layout/activity_main.xml e adicione o seguinte 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>

Esse bloco de código tem um novo FrameLayout chamado preview_display_layout e um TextView aninhado dentro dele, chamado no_camera_access_preview. Quando as permissões de acesso à câmera não são concedidas, nosso aplicativo mostra a TextView com uma mensagem de string, armazenada na variável no_camera_access. Adicione a linha abaixo ao arquivo $APPLICATION_PATH/res/values/strings.xml:

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

Quando o usuário não concede permissão para a câmera, a tela fica assim:

missing_camera_permission_android

Agora, vamos adicionar os objetos SurfaceTexture e SurfaceView a MainActivity:

private SurfaceTexture previewFrameTexture;
private SurfaceView previewDisplayView;

Na função onCreate(Bundle), adicione as duas linhas a seguir antes de solicitar permissões de câmera:

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

Agora, adicione o código que define setupPreviewDisplayView():

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

Definimos um novo objeto SurfaceView e o adicionamos ao objeto preview_display_layout FrameLayout para que possamos usá-lo para mostrar os frames da câmera com um objeto SurfaceTexture chamado previewFrameTexture.

Para usar a previewFrameTexture e acessar os frames da câmera, usaremos o CameraX. O framework fornece um utilitário chamado CameraXPreviewHelper para usar o CameraX. Essa classe atualiza um listener quando a câmera é iniciada via onCameraStarted(@Nullable SurfaceTexture).

Para usar esse utilitário, modifique o arquivo BUILD para adicionar uma dependência a "//mediapipe/java/com/google/mediapipe/components:android_camerax_helper".

Agora importe CameraXPreviewHelper e adicione a seguinte linha ao MainActivity:

private CameraXPreviewHelper cameraHelper;

Agora, podemos adicionar nossa implementação 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);
    });
}

Isso cria um novo objeto CameraXPreviewHelper e adiciona um listener anônimo ao objeto. Quando cameraHelper sinalizar que a câmera foi iniciada e uma surfaceTexture para capturar frames está disponível, salvamos esse surfaceTexture como previewFrameTexture e tornamos o previewDisplayView visível para que possamos começar a ver frames da previewFrameTexture.

No entanto, antes de iniciar a câmera, precisamos decidir qual câmera queremos usar. CameraXPreviewHelper é herdado de CameraHelper, que fornece duas opções, FRONT e BACK. Podemos transmitir a decisão do arquivo BUILD como metadados para que nenhuma mudança de código seja necessária para criar outra versão do app usando uma câmera diferente.

Supondo que queremos usar a câmera BACK para realizar a detecção da borda em uma cena ao vivo que visualizamos da câmera, adicione os metadados a AndroidManifest.xml:

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

e especifique a seleção em BUILD na regra binária do Android helloworld com uma nova entrada em manifest_values:

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

Agora, em MainActivity para recuperar os metadados especificados em manifest_values, adicione um objeto ApplicationInfo:

private ApplicationInfo applicationInfo;

Na função onCreate(), adicione:

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

Agora, adicione a seguinte linha ao final da função startCamera():

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

Nesse ponto, o aplicativo deve ser criado corretamente. No entanto, ao executar o aplicativo no dispositivo, você verá uma tela preta, mesmo que as permissões da câmera tenham sido concedidas. Isso ocorre porque, mesmo que salvemos a variável surfaceTexture fornecida pelo CameraXPreviewHelper, o previewSurfaceView ainda não usa a saída e a exibe na tela.

Como queremos usar os frames em um gráfico do MediaPipe, não vamos adicionar código para visualizar a saída da câmera diretamente neste tutorial. Em vez disso, vamos aprender a enviar frames da câmera para processamento em um gráfico do MediaPipe e mostrar a saída do gráfico na tela.

Configuração de ExternalTextureConverter

Uma SurfaceTexture captura frames de imagem de um stream como uma textura do OpenGL ES. Para usar um gráfico do MediaPipe, os frames capturados pela câmera precisam ser armazenados em um objeto de textura Open GL normal. O framework fornece uma classe, ExternalTextureConverter, para converter a imagem armazenada em um objeto SurfaceTexture em um objeto de textura OpenGL normal.

Para usar ExternalTextureConverter, também precisamos de um EGLContext, que é criado e gerenciado por um objeto EglManager. Adicione uma dependência ao arquivo BUILD para usar EglManager, "//mediapipe/java/com/google/mediapipe/glutil".

Em MainActivity, adicione as seguintes declarações:

private EglManager eglManager;
private ExternalTextureConverter converter;

Na função onCreate(Bundle), adicione uma instrução para inicializar o objeto eglManager antes de solicitar permissões de câmera:

eglManager = new EglManager(null);

Lembre-se de que definimos a função onResume() em MainActivity para confirmar que as permissões da câmera foram concedidas e chamar startCamera(). Antes dessa verificação, adicione a seguinte linha em onResume() para inicializar o objeto converter:

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

Este converter agora usa o GLContext gerenciado por eglManager.

Também precisamos substituir a função onPause() no MainActivity para que, se o aplicativo entrar em um estado pausado, fechemos o converter corretamente:

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

Para canalizar a saída de previewFrameTexture para converter, adicione o bloco de código abaixo 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) {}
     });

Nesse bloco de código, adicionamos um SurfaceHolder.Callback personalizado a previewDisplayView e implementamos a função surfaceChanged(SurfaceHolder holder, int format, int width, int height) para calcular um tamanho de exibição adequado dos frames da câmera na tela do dispositivo e vincular o objeto previewFrameTexture e enviar frames do displaySize calculado ao converter.

Agora já podemos usar os frames da câmera em um gráfico do MediaPipe.

Como usar um gráfico do MediaPipe no Android

Adicionar dependências relevantes

Para usar um gráfico do MediaPipe, é preciso adicionar dependências ao framework MediaPipe no Android. Primeiro, adicionaremos uma regra de build para criar um cc_binary usando o código JNI do framework MediaPipe e, em seguida, criaremos uma regra cc_library para usar esse binário no nosso aplicativo. Adicione o seguinte bloco de código ao seu arquivo 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,
)

Adicione a dependência ":mediapipe_jni_lib" à regra de build mediapipe_lib no arquivo BUILD.

Em seguida, é preciso adicionar dependências específicas ao gráfico do MediaPipe que queremos usar no aplicativo.

Primeiro, adicione dependências a todo o código da calculadora na regra de build libmediapipe_jni.so:

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

Os gráficos do MediaPipe são arquivos .pbtxt, mas, para usá-los no aplicativo, precisamos usar a regra de build mediapipe_binary_graph para gerar um arquivo .binarypb.

Na regra de build binário do Android helloworld, adicione o destino mediapipe_binary_graph específico ao gráfico como um recurso:

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

Na regra de criação assets, também é possível adicionar outros recursos, como modelos do TensorFlowLite usados no gráfico.

Além disso, adicione mais manifest_values para propriedades específicas do gráfico, que serão recuperadas posteriormente em 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",
},

Observe que binaryGraphName indica o nome do arquivo do gráfico binário, determinado pelo campo output_name no destino mediapipe_binary_graph. inputVideoStreamName e outputVideoStreamName são os nomes do stream de vídeo de entrada e saída especificados no gráfico, respectivamente.

Agora, o MainActivity precisa carregar o framework MediaPipe. Além disso, o framework usa OpenCV, então MainActvity também precisa carregar OpenCV. Use o código abaixo em MainActivity (dentro da classe, mas não dentro de qualquer função) para carregar as duas dependências:

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

Usar o gráfico em MainActivity

Primeiro, precisamos carregar o recurso que contém o .binarypb compilado no arquivo .pbtxt do gráfico. Para isso, podemos usar o utilitário AndroidAssetUtil do MediaPipe.

Inicialize o gerenciador de recursos no 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);

Agora, precisamos configurar um objeto FrameProcessor que envie frames da câmera preparados pelo converter para o gráfico do MediaPipe e execute o gráfico, prepare a saída e atualize o previewDisplayView para mostrar a saída. Adicione o código a seguir para declarar o FrameProcessor:

private FrameProcessor processor;

e inicialize-o em onCreate(Bundle) depois de inicializar eglManager:

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

O processor precisa consumir os frames convertidos do converter para processamento. Adicione a seguinte linha ao onResume() depois de inicializar o converter:

converter.setConsumer(processor);

O processor precisa enviar a saída para previewDisplayView. Para isso, adicione as seguintes definições de função ao SurfaceHolder.Callback personalizado:

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

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

Quando o SurfaceHolder é criado, usamos o Surface no VideoSurfaceOutput do processor. Quando ela é destruída, ela é removida do VideoSurfaceOutput do processor.

Pronto. Agora você pode criar e executar o aplicativo no dispositivo e conferir a detecção do Sobel Edge em execução em um feed de câmera ao vivo. Parabéns!

edge_detection_android_gpu_gif

Se você tiver algum problema, consulte o código completo do tutorial aqui.