Hello World! sur Android

Introduction

Bonjour le monde ! utilise MediaPipe pour développer une application Android exécute un graphique MediaPipe sur Android.

Objectifs de l'atelier

Application d'appareil photo simple pour la détection en temps réel des bords Sobel à une vidéo en direct diffuser en streaming sur un appareil Android.

edge_detection_android_gpu_gif

Configuration

  1. Installez MediaPipe Framework sur votre système, consultez la section Installation de Framework guide pour plus d'informations.
  2. Installez le SDK Android Development et le NDK Android. Pour savoir comment procéder, consultez [Guide d'installation du framework].
  3. Activez les options pour les développeurs sur votre appareil Android.
  4. Configurez Bazel sur votre système pour créer et déployer l'application Android.

Graphique de détection des bords

Nous utiliserons le graphique suivant, 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"
}

Voici une visualisation du graphique:

edge_detection_mobile_gpu

Ce graphique comporte un seul flux d'entrée nommé input_video pour tous les frames entrants qui sera fournie par l'appareil photo de votre appareil.

Le premier nœud du graphique, LuminanceCalculator, accepte un seul paquet (image cadre) et applique un changement de luminance à l'aide d'un nuanceur OpenGL. Le résultat la trame d'image est envoyée au flux de sortie luma_video.

Le deuxième nœud, SobelEdgesCalculator, applique la détection en périphérie aux paquets dans le flux luma_video et génère une sortie output_video flux.

Notre application Android affiche les images de sortie du Flux output_video.

Configuration initiale de l'application minimale

Commençons par une application Android simple qui affiche le message "Hello World!". à l'écran. Vous pouvez ignorer cette étape si vous savez créer des applications Android applications à l'aide de bazel.

Créez un répertoire dans lequel vous allez créer votre application Android. Pour exemple, le code complet de ce didacticiel est disponible à l'adresse mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic Nous désigner ce chemin par $APPLICATION_PATH tout au long de l'atelier de programmation.

Notez que dans le chemin d'accès à l'application:

  • L'application est nommée helloworld.
  • Le $PACKAGE_PATH de l'application est com.google.mediapipe.apps.basic. Il est utilisé dans les extraits de code de ce tutoriel. N'oubliez donc pas d'utiliser votre propre $PACKAGE_PATH lorsque vous copiez/utilisez les extraits de code.

Ajoutez un fichier activity_main.xml à $APPLICATION_PATH/res/layout. Cela permet d'afficher Un TextView en plein écran de l'application avec la chaîne 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>

Ajoutez un élément MainActivity.java simple à $APPLICATION_PATH pour charger le contenu. de la mise en page activity_main.xml, comme indiqué ci-dessous:

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

Ajoutez un fichier manifeste, AndroidManifest.xml à $APPLICATION_PATH, qui lance MainActivity au démarrage de l'application:

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

Dans notre application, nous utilisons un thème Theme.AppCompat. Nous devons donc les références appropriées au thème. Ajouter 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>

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

Pour compiler l'application, ajoutez un fichier BUILD à $APPLICATION_PATH, puis ${appName} et ${mainActivity} dans le fichier manifeste seront remplacés par des chaînes spécifié dans BUILD, comme indiqué ci-dessous.

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 règle android_library ajoute des dépendances pour MainActivity et les fichiers de ressources et AndroidManifest.xml.

La règle android_binary utilise la bibliothèque Android basic_lib générée pour compiler un APK binaire pour l'installer sur votre appareil Android.

Pour créer l'application, exécutez la commande suivante:

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

Installez le fichier APK généré à l'aide de adb install. Exemple :

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

Ouvrez l'application sur votre appareil. Un écran avec le texte Hello World!

bazel_hello_world_android

Utilisation de l'appareil photo via CameraX

Autorisations de l'appareil photo

Pour utiliser l'appareil photo dans notre application, nous devons demander à l'utilisateur de fournir l'accès à l'appareil photo. Pour demander l'autorisation d'accéder à l'appareil photo, ajoutez le code suivant à AndroidManifest.xml:

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

Remplacez la version minimale du SDK par 21 et la version cible du SDK 27 dans le même fichier:

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

Cela garantit que l'utilisateur est invité à demander l'autorisation d'accéder à l'appareil photo et active d'utiliser la bibliothèque CameraX pour accéder à l'appareil photo.

Pour demander l'autorisation d'accéder à l'appareil photo, nous pouvons utiliser un utilitaire fourni par MediaPipe Framework. à savoir PermissionHelper. Pour l'utiliser, ajoutez une dépendance "//mediapipe/java/com/google/mediapipe/components:android_components" dans la Règle mediapipe_lib dans BUILD.

Pour utiliser PermissionHelper dans MainActivity, ajoutez la ligne suivante au Fonction onCreate:

PermissionHelper.checkAndRequestCameraPermissions(this);

Une boîte de dialogue s'affiche alors à l'écran pour demander l'autorisation d'accéder à utiliser l'appareil photo dans cette application.

Ajoutez le code suivant pour gérer la réponse de l'utilisateur:

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

Nous allons laisser la méthode startCamera() vide pour le moment. Réponse de l'utilisateur à l'invite, MainActivity reprend et onResume() est appelé. Le code confirme que les autorisations d'utilisation de l'appareil photo ont été accordées avant de démarrer la caméra.

Recompilez et installez l'application. Vous devriez maintenant voir une invite demandant l'accès à l'appareil photo pour l'application.

Accès à l'appareil photo

Lorsque les autorisations d'accès à l'appareil photo sont disponibles, nous pouvons démarrer et récupérer des images caméra.

Pour afficher les images de l'appareil photo, nous allons utiliser un SurfaceView. Chaque image de l'appareil photo sont stockés dans un objet SurfaceTexture. Pour les utiliser, nous nous devons d'abord modifier la mise en page de notre application.

Supprimez l'intégralité du bloc de code TextView du $APPLICATION_PATH/res/layout/activity_main.xml et ajoutez le code suivant : à la place:

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

Ce bloc de code comporte un nouveau FrameLayout nommé preview_display_layout et un TextView imbriqué à l'intérieur, nommé no_camera_access_preview. Lorsque l'appareil photo les autorisations d'accès ne sont pas accordées, l'application affiche TextView par un message de chaîne, stocké dans la variable no_camera_access. Ajoutez la ligne suivante au fichier $APPLICATION_PATH/res/values/strings.xml:

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

Lorsque l'utilisateur n'accorde pas l'autorisation d'accéder à l'appareil photo, l'écran ressemble maintenant à ceci:

missing_camera_permission_android

Nous allons maintenant ajouter les objets SurfaceTexture et SurfaceView à MainActivity:

private SurfaceTexture previewFrameTexture;
private SurfaceView previewDisplayView;

Dans la fonction onCreate(Bundle), ajoutez les deux lignes suivantes avant demande l'autorisation d'accéder à l'appareil photo:

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

Ajoutez maintenant le code définissant setupPreviewDisplayView():

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

Nous définissons un nouvel objet SurfaceView et l'ajoutons au preview_display_layout FrameLayout afin de pouvoir l'utiliser pour afficher les images de l'appareil photo à l'aide d'un objet SurfaceTexture nommé previewFrameTexture.

Pour utiliser previewFrameTexture afin d'obtenir des images de l'appareil photo, nous allons utiliser CameraX. Le framework fournit un utilitaire nommé CameraXPreviewHelper permettant d'utiliser CameraX. Cette classe met à jour un écouteur lorsque la caméra est démarrée via onCameraStarted(@Nullable SurfaceTexture)

Pour utiliser cet utilitaire, modifiez le fichier BUILD pour ajouter une dépendance à "//mediapipe/java/com/google/mediapipe/components:android_camerax_helper"

Importez maintenant CameraXPreviewHelper et ajoutez la ligne suivante à MainActivity:

private CameraXPreviewHelper cameraHelper;

Nous pouvons maintenant ajouter notre implémentation à 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);
    });
}

Un objet CameraXPreviewHelper est alors créé, et un objet sur l'objet. Lorsque cameraHelper signale que la caméra a démarré et qu'un surfaceTexture est disponible pour récupérer des images, surfaceTexture en tant que previewFrameTexture, et la previewDisplayView visible afin de pouvoir commencer à voir les images de previewFrameTexture.

Cependant, avant de démarrer l'appareil photo, nous devons choisir la caméra à utiliser utiliser. CameraXPreviewHelper hérite de CameraHelper, qui fournit deux les options FRONT et BACK. Nous pouvons transmettre la décision à partir du fichier BUILD. comme les métadonnées, de sorte qu'aucune modification du code ne soit nécessaire pour créer une autre version avec une autre caméra.

Supposons que nous souhaitons utiliser la caméra BACK pour effectuer une détection des bords sur une scène en direct. que nous regardons depuis l'appareil photo, ajoutez les métadonnées dans AndroidManifest.xml:

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

et spécifiez la sélection dans BUILD dans la règle binaire Android helloworld. par une nouvelle entrée dans manifest_values:

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

Maintenant, dans MainActivity pour récupérer les métadonnées spécifiées dans manifest_values, Ajoutez un objet ApplicationInfo:

private ApplicationInfo applicationInfo;

Dans la fonction onCreate(), ajoutez:

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

Ajoutez maintenant la ligne suivante à la fin de la fonction startCamera():

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

À ce stade, l'application devrait se compiler correctement. Toutefois, lorsque vous exécutez l'application sur votre appareil, un écran noir s'affiche (même si l'appareil photo ont été accordées.) En effet, même si nous sauvegardons la variable surfaceTexture fournie par le CameraXPreviewHelper, la previewSurfaceView n'utilise pas encore sa sortie et ne l'affiche pas encore à l'écran.

Puisque nous voulons utiliser les frames dans un graphique MediaPipe, nous n'ajouterons pas de code à la sortie de la caméra directement dans ce tutoriel. Au lieu de cela, nous allons passer à la façon nous pouvons envoyer les images de l'appareil photo à un graphique MediaPipe pour les traiter du graphique à l'écran.

Configuration de ExternalTextureConverter

Un SurfaceTexture capture des images d'un flux sous forme d'OpenGL ES ou la texture. Pour utiliser un graphique MediaPipe, les images capturées avec l'appareil photo doivent être stocké dans un objet de texture Open GL standard. Le framework fournit une classe, ExternalTextureConverter pour convertir l'image stockée dans un SurfaceTexture à un objet de texture OpenGL standard.

Pour utiliser ExternalTextureConverter, nous avons également besoin d'un EGLContext, qui est créées et gérées par un objet EglManager. Ajouter une dépendance à BUILD pour utiliser EglManager, "//mediapipe/java/com/google/mediapipe/glutil".

Dans MainActivity, ajoutez les déclarations suivantes:

private EglManager eglManager;
private ExternalTextureConverter converter;

Dans la fonction onCreate(Bundle), ajoutez une instruction pour initialiser eglManager avant de demander les autorisations d'accès à l'appareil photo:

eglManager = new EglManager(null);

Rappelez-vous que nous avons défini la fonction onResume() dans MainActivity pour confirmer les autorisations d'accès à l'appareil photo ont été accordées et appeler startCamera(). Avant cela vérifiez, ajoutez la ligne suivante dans onResume() pour initialiser converter objet:

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

Ce converter utilise désormais le GLContext géré par eglManager.

Nous devons également remplacer la fonction onPause() dans MainActivity pour que Si l'application est mise en pause, nous fermons converter correctement:

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

Pour diriger la sortie de previewFrameTexture vers converter, ajoutez le bloc de code suivant vers 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) {}
     });

Dans ce bloc de code, nous ajoutons un SurfaceHolder.Callback personnalisé à previewDisplayView et implémenter la fonction surfaceChanged(SurfaceHolder holder, int format, int width, int height) pour calculer une taille d'affichage appropriée cadres de l'appareil photo sur l'écran de l'appareil et pour lier le previewFrameTexture et envoyer les frames du displaySize calculé au converter.

Nous sommes maintenant prêts à utiliser des images d'appareil photo dans un graphique MediaPipe.

Utiliser un graphique MediaPipe dans Android

Ajouter les dépendances pertinentes

Pour utiliser un graphe MediaPipe, nous devons ajouter des dépendances au framework MediaPipe sur Android. Nous allons d'abord ajouter une règle de compilation pour compiler un cc_binary à l'aide du code JNI du framework MediaPipe, puis créez une règle cc_library pour utiliser ce binaire dans notre application. Ajoutez le bloc de code suivant à votre fichier 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,
)

Ajoutez la dépendance ":mediapipe_jni_lib" à la règle de compilation mediapipe_lib dans le fichier BUILD.

Nous devons ensuite ajouter des dépendances spécifiques au graphe MediaPipe que nous voulons utiliser. dans l'application.

Tout d'abord, ajoutez des dépendances à tout le code de la calculatrice dans libmediapipe_jni.so. règle de compilation:

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

Les graphiques MediaPipe sont des fichiers .pbtxt, mais pour les utiliser dans l'application, nous avons besoin pour utiliser la règle de compilation mediapipe_binary_graph afin de générer un fichier .binarypb.

Dans la règle de compilation binaire Android helloworld, ajoutez mediapipe_binary_graph. cible spécifique au graphique en tant qu'élément:

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

Dans la règle de compilation assets, vous pouvez également ajouter d'autres éléments tels que TensorFlowLite. utilisés dans votre graphique.

De plus, ajoutez des manifest_values supplémentaires pour les établissements spécifiques graphique, qui sera récupéré ultérieurement dans 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",
},

Notez que binaryGraphName indique le nom de fichier du graphe binaire, déterminé par le champ output_name de la cible mediapipe_binary_graph. inputVideoStreamName et outputVideoStreamName sont les entrées et les sorties. nom du flux vidéo spécifié respectivement dans le graphique.

MainActivity doit maintenant charger le framework MediaPipe. Par ailleurs, framework utilise OpenCV. MainActvity doit donc également charger OpenCV. Utilisez les le code suivant dans MainActivity (à l'intérieur de la classe, mais pas dans une fonction) ; pour charger les deux dépendances:

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

Utiliser le graphique en MainActivity

Tout d'abord, nous devons charger l'élément qui contient le .binarypb compilé à partir de le fichier .pbtxt du graphique. Pour ce faire, nous pouvons utiliser un utilitaire MediaPipe, AndroidAssetUtil

Initialisez le gestionnaire d'assets dans onCreate(Bundle) avant de procéder à l'initialisation. eglManager:

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

Nous devons maintenant configurer un objet FrameProcessor qui envoie les images de l'appareil photo. préparé par converter au graphe MediaPipe, exécute le graphe, prépare puis met à jour le previewDisplayView pour l'afficher. Ajouter le code suivant pour déclarer FrameProcessor:

private FrameProcessor processor;

puis initialisez-le dans onCreate(Bundle) après avoir initialisé eglManager:

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

processor doit utiliser les frames convertis à partir de converter pour en cours de traitement. Ajoutez la ligne suivante à onResume() après avoir initialisé converter:

converter.setConsumer(processor);

processor doit envoyer sa sortie à previewDisplayView. Pour ce faire, ajoutez les définitions de fonction suivantes à notre SurfaceHolder.Callback personnalisé:

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

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

Lorsque SurfaceHolder a été créé, nous avions le Surface à la VideoSurfaceOutput sur processor. Lorsqu'elle est détruite, nous la supprimons le VideoSurfaceOutput de processor.

Et voilà ! Vous devriez maintenant être en mesure de créer et d'exécuter application sur l'appareil et la détection des bords Sobel s'exécute sur une caméra en direct flux ! Félicitations !

edge_detection_android_gpu_gif

Si vous avez rencontré des problèmes, veuillez consulter le code complet du tutoriel cliquez ici.