Premiers pas avec CameraX

Dans cet atelier de programmation, nous allons apprendre à créer une application d'appareil photo qui utilise CameraX pour afficher un viseur, prendre des photos et analyser un flux d'images de l'appareil photo.

Pour cela, nous allons introduire le concept de cas d'utilisation dans CameraX, qui peut être utilisé pour diverses opérations de l'appareil photo, de l'affichage d'un viseur à l'analyse des images en temps réel.

Points abordés

  • Ajouter les dépendances CameraX
  • Afficher l'aperçu de la caméra dans une activité (Cas d'utilisation Preview)
  • Prendre une photo et l'enregistrer dans l'espace de stockage (cas d'utilisation ImageCapture)
  • Comment analyser les images de l'appareil photo en temps réel (cas d'utilisation ImageAnalysis)

Matériel nécessaire

  • Un appareil Android (l'émulateur Android Studio convient également). Le niveau d'API minimal accepté est 21.

Logiciels nécessaires

  • Android Studio version 3.3 ou ultérieure

Dans le menu Android Studio, démarrez un nouveau projet et sélectionnez Empty Activity (Activité vide) lorsque vous y êtes invité.

Ensuite, nous pouvons choisir le nom de notre choix. Nous avons choisi "CameraX App" (Application CameraX). Nous devons nous assurer que le langage est défini sur Kotlin, que le niveau d'API minimal est défini sur 21 (niveau minimal requis pour CameraX) et que nous utilisons des artefacts AndroidX.

Pour commencer, ajoutons les dépendances CameraX à notre fichier Gradle d'application, dans la section dependencies  :

// Use the most recent version of CameraX, currently that is alpha04
def camerax_version = "1.0.0-alpha04"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"

Lorsque vous y êtes invité, cliquez sur Sync Now (Synchroniser). Vous êtes maintenant prêt à utiliser CameraX dans l'application.

Nous allons utiliser une SurfaceTexture pour afficher le viseur de l'appareil photo. Dans cet atelier de programmation, nous allons afficher le viseur dans un format carré de taille fixe. Pour obtenir un exemple plus complet montrant un viseur réactif, consultez l'exemple officiel.

Modifions le fichier de mise en page activity_main sous res > layout > activity_main.xml :

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <TextureView
            android:id="@+id/view_finder"
            android:layout_width="640px"
            android:layout_height="640px"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Une partie essentielle de l'ajout de toute fonctionnalité dans notre projet qui utilise l'appareil photo consiste à demander les autorisations CAMERA appropriées. Tout d'abord, nous devons les déclarer dans le fichier manifeste, avant la balise Application :

<uses-permission android:name="android.permission.CAMERA" />

Ensuite, dans MainActivity, nous devons demander des autorisations au moment de l'exécution. Nous allons apporter des modifications au fichier MainActivity sous java > com.example.cameraxapp > MainActivity.kt :

En haut du fichier, en dehors de la définition de la classe MainActivity, ajoutons les constantes et les importations suivantes :

// Your IDE likely can auto-import these classes, but there are several
// different implementations so we list them here to disambiguate
import android.Manifest
import android.util.Size
import android.graphics.Matrix
import java.util.concurrent.TimeUnit

// This is an arbitrary number we are using to keep tab of the permission
// request. Where an app has multiple context for requesting permission,
// this can help differentiate the different contexts
private const val REQUEST_CODE_PERMISSIONS = 10

// This is an array of all the permission specified in the manifest
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)

Dans la classe MainActivity, ajoutez les champs et les méthodes d'assistance suivants, qui sont utilisés pour demander des autorisations et déclencher notre code une fois que nous savons que toutes les autorisations ont été accordées :

class MainActivity : AppCompatActivity(), LifecycleOwner {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    }

    // Add this after onCreate

    private lateinit var viewFinder: TextureView

    private fun startCamera() {
        // TODO: Implement CameraX operations
    }

    private fun updateTransform() {
        // TODO: Implement camera viewfinder transformations
    }

    /**
     * Process result from permission request dialog box, has the request
     * been granted? If yes, start Camera. Otherwise display a toast
     */
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                viewFinder.post { startCamera() }
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.", 
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    /**
     * Check if all permission specified in the manifest have been granted
     */
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
               baseContext, it) == PackageManager.PERMISSION_GRANTED
    }
}

Enfin, nous rassemblons tout dans onCreate pour déclencher la demande d'autorisation le cas échéant :

override fun onCreate(savedInstanceState: Bundle?) {
    ...

    // Add this at the end of onCreate function

    viewFinder = findViewById(R.id.view_finder)

    // Request camera permissions
    if (allPermissionsGranted()) {
        viewFinder.post { startCamera() }
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
    }

    // Every time the provided texture view changes, recompute layout
    viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
        updateTransform()
    }
}

Désormais, au démarrage de l'application, celle-ci vérifie si elle dispose des autorisations d'accès à l'appareil photo appropriées. Si tel est le cas, il appellera directement `startCamera()`. Sinon, il demandera les autorisations et, une fois accordées, appellera `startCamera()`.

Pour la plupart des applications d'appareil photo, il est très important d'afficher un viseur aux utilisateurs, sinon il leur est très difficile de pointer l'appareil photo au bon endroit. Un viseur peut être implémenté à l'aide de la classe `Preview` de CameraX.

Pour utiliser l'aperçu, nous devons d'abord définir une configuration, qui sert ensuite à créer une instance du cas d'utilisation. L'instance résultante est celle que nous devons associer au cycle de vie de CameraX. Nous allons le faire dans la méthode `startCamera()`. Complétez l'implémentation avec ce code :

private fun startCamera() {

    // Create configuration object for the viewfinder use case
    val previewConfig = PreviewConfig.Builder().apply {
        setTargetAspectRatio(Rational(1, 1))
        setTargetResolution(Size(640, 640))
    }.build()

    // Build the viewfinder use case
    val preview = Preview(previewConfig)

    // Every time the viewfinder is updated, recompute layout
    preview.setOnPreviewOutputUpdateListener {

        // To update the SurfaceTexture, we have to remove it and re-add it
        val parent = viewFinder.parent as ViewGroup
        parent.removeView(viewFinder)
        parent.addView(viewFinder, 0)

        viewFinder.surfaceTexture = it.surfaceTexture
        updateTransform()
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview)
}

À ce stade, nous devons implémenter la mystérieuse méthode `updateTransform()`. Dans `updateTransform()`, l'objectif est de compenser les changements d'orientation de l'appareil pour afficher le viseur en position verticale :

private fun updateTransform() {
    val matrix = Matrix()

    // Compute the center of the view finder
    val centerX = viewFinder.width / 2f
    val centerY = viewFinder.height / 2f

    // Correct preview output to account for display rotation
    val rotationDegrees = when(viewFinder.display.rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> return
    }
    matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)

    // Finally, apply transformations to our TextureView
    viewFinder.setTransform(matrix)
}

Pour implémenter une application prête pour la production, consultez l'exemple officiel pour voir ce qui doit encore être géré. Pour que cet atelier de programmation reste court, nous allons prendre quelques raccourcis. Par exemple, nous ne suivons pas certains changements de configuration, comme les rotations de l'appareil à 180 degrés, qui ne déclenchent pas notre écouteur de changement de mise en page. Les viseurs non carrés doivent également compenser le changement de format lorsque l'appareil pivote.

Si nous compilons et exécutons l'application, nous devrions maintenant voir un aperçu en direct. Bravo !

Pour permettre aux utilisateurs de prendre des photos, nous allons fournir un bouton dans la mise en page après la vue de texture dans res > layout > activity_main.xml :

<ImageButton
        android:id="@+id/capture_button"
        android:layout_width="72dp"
        android:layout_height="72dp"
        android:layout_margin="24dp"
        app:srcCompat="@android:drawable/ic_menu_camera"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

Les autres cas d'utilisation fonctionnent de manière très semblable à l'aperçu. Tout d'abord, nous devons définir un objet de configuration qui permet d'instancier l'objet de cas d'utilisation réel. Pour prendre des photos lorsque vous appuyez sur le bouton de capture, nous devons mettre à jour la méthode `startCamera()` et ajouter quelques lignes de code à la fin, avant l'appel à CameraX.bindToLifecycle :

private fun startCamera() {

    ...

    // Add this before CameraX.bindToLifecycle

    // Create configuration object for the image capture use case
    val imageCaptureConfig = ImageCaptureConfig.Builder()
        .apply {
            setTargetAspectRatio(Rational(1, 1))
            // We don't set a resolution for image capture; instead, we
            // select a capture mode which will infer the appropriate
            // resolution based on aspect ration and requested mode
            setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
    }.build()

    // Build the image capture use case and attach button click listener
    val imageCapture = ImageCapture(imageCaptureConfig)
    findViewById<ImageButton>(R.id.capture_button).setOnClickListener {
        val file = File(externalMediaDirs.first(),
            "${System.currentTimeMillis()}.jpg")
        imageCapture.takePicture(file,
            object : ImageCapture.OnImageSavedListener {
            override fun onError(error: ImageCapture.UseCaseError,
                                 message: String, exc: Throwable?) {
                val msg = "Photo capture failed: $message"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                Log.e("CameraXApp", msg)
                exc?.printStackTrace()
            }

            override fun onImageSaved(file: File) {
                val msg = "Photo capture succeeded: ${file.absolutePath}"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                Log.d("CameraXApp", msg)
            }
        })
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview)
}

Ensuite, mettez à jour l'appel à CameraX.bindToLifecycle pour inclure le nouveau cas d'utilisation :

CameraX.bindToLifecycle(this, preview, imageCapture)

Et voilà, nous avons implémenté un bouton de prise de photo fonctionnel.

La classe ImageAnalysis est une fonctionnalité très intéressante de CameraX. Elle nous permet de définir une classe personnalisée implémentant l'interface ImageAnalysis.Analyzer, qui sera appelée avec les images prises par l'appareil photo. Conformément à la vision principale de CameraX, nous n'aurons pas à nous soucier de la gestion de l'état de la session de l'appareil photo ni même de la suppression des images. L'association au cycle de vie souhaité pour notre application est suffisante, comme avec d'autres composants tenant compte du cycle de vie.

Nous allons d'abord implémenter un analyseur d'images personnalisé. Notre analyseur est assez simple : il enregistre simplement la luma (luminosité) moyenne de l'image, mais illustre ce qui doit être fait pour des cas d'utilisation arbitrairement complexes. Il nous suffit de remplacer la fonction "analyze" dans une classe qui implémente l'interface ImageAnalysis.Analyzer. Nous pouvons définir notre implémentation comme une classe interne dans MainActivity :

private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
    private var lastAnalyzedTimestamp = 0L

    /**
     * Helper extension function used to extract a byte array from an
     * image plane buffer
     */
    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()    // Rewind the buffer to zero
        val data = ByteArray(remaining())
        get(data)   // Copy the buffer into a byte array
        return data // Return the byte array
    }

    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val currentTimestamp = System.currentTimeMillis()
        // Calculate the average luma no more often than every second
        if (currentTimestamp - lastAnalyzedTimestamp >=
            TimeUnit.SECONDS.toMillis(1)) {
            // Since format in ImageAnalysis is YUV, image.planes[0]
            // contains the Y (luminance) plane
            val buffer = image.planes[0].buffer
            // Extract image data from callback object
            val data = buffer.toByteArray()
            // Convert the data into an array of pixel values
            val pixels = data.map { it.toInt() and 0xFF }
            // Compute average luminance for the image
            val luma = pixels.average()
            // Log the new luma value
            Log.d("CameraXApp", "Average luminosity: $luma")
            // Update timestamp of last analyzed frame
            lastAnalyzedTimestamp = currentTimestamp
        }
    }
}

Avec notre classe implémentant l'interface ImageAnalysis.Analyzer, il nous suffit d'instancier ImageAnalysis comme dans tous les autres cas d'utilisation et de mettre à jour de nouveau la fonction `startCamera()` avant d'appeler CameraX.bindToLifecycle :

private fun startCamera() {

    ...

    // Add this before CameraX.bindToLifecycle

    // Setup image analysis pipeline that computes average pixel luminance
    val analyzerConfig = ImageAnalysisConfig.Builder().apply {
        // Use a worker thread for image analysis to prevent glitches
        val analyzerThread = HandlerThread(
            "LuminosityAnalysis").apply { start() }
        setCallbackHandler(Handler(analyzerThread.looper))
        // In our analysis, we care more about the latest image than
        // analyzing *every* image
        setImageReaderMode(
            ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
    }.build()

    // Build the image analysis use case and instantiate our analyzer
    val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
        analyzer = LuminosityAnalyzer()
    }

    // Bind use cases to lifecycle
    // If Android Studio complains about "this" being not a LifecycleOwner
    // try rebuilding the project or updating the appcompat dependency to
    // version 1.1.0 or higher.
    CameraX.bindToLifecycle(this, preview, imageCapture)
}

Nous mettons également à jour l'appel à CameraX.bindtoLifecycle pour associer le nouveau cas d'utilisation :

CameraX.bindToLifecycle(
    this, preview, imageCapture, analyzerUseCase)

Si vous exécutez l'application maintenant, un message semblable à celui-ci sera généré dans logcat chaque seconde environ :

D/CameraXApp: Average luminosity: ...

Pour tester l'application, il vous suffit de cliquer sur le bouton Run (Exécuter) dans Android Studio. Votre projet sera alors créé, déployé et lancé sur l'appareil ou l'émulateur sélectionné. Une fois l'application chargée, le viseur doit s'afficher. Il doit rester à la verticale même après la rotation de l'appareil grâce au code de gestion de l'orientation que nous avons ajouté précédemment. Vous devez également pouvoir prendre des photos à l'aide du bouton :

Vous avez terminé l'atelier de programmation. Bravo ! Pour rappel, vous avez réalisé les opérations suivantes dans une nouvelle application Android :

  • Inclusion des dépendances CameraX dans votre projet
  • Affichage d'un viseur d'appareil photo (à l'aide du cas d'utilisation Preview)
  • Implémentation de la capture de photos et de l'enregistrement d'images dans un espace de stockage (à l'aide du cas d'utilisation ImageCapture)
  • Implémentation de l'analyse des images de l'appareil photo en temps réel (à l'aide du cas d'utilisation ImageAnalysis)

Pour en savoir plus sur CameraX et ses fonctionnalités, consultez la documentation ou clonez l'exemple officiel.