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.
Configuración
- Instala MediaPipe Framework en tu sistema. Consulta la Guía de instalación del framework para obtener más detalles.
- 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].
- Habilita las opciones para desarrolladores en tu dispositivo Android.
- 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:
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 escom.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!
.
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:
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!
Si tuviste algún problema, consulta el código completo del instructivo aquí.