Premiers pas avec CameraX

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

Pour cela, nous allons présenter le concept de cas d'utilisation dans CameraX, qui peuvent être utilisés pour diverses opérations de la caméra (affichage d'un viseur ou analyse d'images en temps réel).

Points abordés

  • Ajouter les dépendances CameraX
  • Comment afficher l'aperçu de la caméra dans une activité (Cas d'utilisation de prévisualisation)
  • Comment prendre une photo et l'enregistrer dans l'espace de stockage. (Cas d'utilisation d'ImageCapture)
  • Comment analyser des images en temps réel de la caméra (Cas d'utilisation d'ImageAnalysis)

Matériel nécessaire

  • Un appareil Android, même si l'émulateur Android Studio ne lui suffit pas. Le niveau d'API minimal accepté est 21.

Logiciels dont nous avons besoin

  • Android Studio version 3.3 ou ultérieure

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

Nous pouvons ensuite choisir le nom de notre choix. Nous avons choisi ingénieusement l'application CameraX. Nous devons nous assurer que la langue est définie sur Kotlin, que le niveau d'API minimal est 21 (valeur minimale requise pour CameraX) et que nous utilisons des artefacts AndroidX.

Pour commencer, ajoutons les dépendances CameraX au fichier Gradle de l'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 maintenant). Nous serons alors prêts à utiliser CameraX dans notre application.

Nous allons utiliser le SurfaceTexture pour afficher le viseur de l'appareil photo. Dans cet atelier de programmation, nous afficherons le viseur dans un format carré de taille fixe. Pour obtenir un exemple plus complet d'utilisation d'un viseur responsif, 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>

Pour ajouter des fonctionnalités à notre projet qui utilise l'appareil photo, vous devez impérativement demander les autorisations appropriées pour l'appareil photo. Nous devons d'abord les déclarer dans le fichier manifeste avant le tag d'application:

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

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

En haut du fichier, en dehors de la définition de la classe MainActivity, nous allons ajouter 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 servent à 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 avons regroupé tous les éléments dans onCreate pour déclencher la demande d'autorisation lorsque nécessaire:

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, lorsque l'application démarre, elle vérifie si elle dispose des autorisations appropriées sur l'appareil photo. Le cas échéant, il appelle directement "CameraCamera()". Sinon, il vous demandera les autorisations et, une fois autorisé, appeler "startCamera()".

Pour la plupart des applications d'appareil photo, il est très important de montrer un viseur aux utilisateurs. Sinon, il est très difficile pour les utilisateurs de diriger la caméra au bon endroit. Un viseur peut être mis en œuvre à l'aide de la classe CameraX "Preview".

Pour utiliser Preview, nous devons d'abord définir une configuration qui sera ensuite utilisée pour créer une instance du cas d'utilisation. L'instance obtenue correspond à ce que nous devons lier au cycle de vie de l'appareil photo X. C'est ce que vous allez faire dans la méthode "startCamera()" ; remplissez l'implémentation avec le code suivant:

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 modifications d'orientation de l'appareil pour afficher notre viseur dans la rotation 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 mettre en œuvre une application prête pour la production, consultez l'exemple officiel pour découvrir les autres étapes à suivre. Afin de maintenir cet atelier de programmation court, nous allons prendre quelques raccourcis. Par exemple, nous n'effectuons pas le suivi de certaines modifications de configuration, comme les rotations à 180 degrés sur les appareils, qui ne déclenchent pas notre écouteur de changements de mise en page. Les viseurs non carrés doivent également compenser les changements de format lors de la rotation de l'appareil.

Si nous comptabilisons et exécutez l'application, un aperçu de la vie devrait s'afficher. Bravo !

Pour permettre aux utilisateurs de capturer des images, nous allons afficher un bouton dans la mise en page après la vue de texture dans re > 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 à la fonctionnalité bêta. Nous devons d'abord définir un objet de configuration qui est utilisé pour instancier l'objet de cas d'utilisation réel. Pour prendre des photos, lorsque vous appuyez sur le bouton de capture, vous devez mettre à jour la méthode "startCamera()" et ajouter quelques lignes de code supplémentaires à 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)

C'est exactement ce que nous avons fait pour mettre en place un bouton fonctionnel permettant de prendre des photos.

La fonctionnalité ImageAnalysis est une fonctionnalité très intéressante de CameraX. Il permet de définir une classe personnalisée qui implémente l'interface ImageAnalysis.Analyzer, qui sera appelée avec les cadres de caméra entrants. Conformément à la vision de base de CameraX, nous n'avons pas à nous soucier de la gestion de l'état de la session de la caméra ni même de la suppression des images. La liaison au cycle de vie souhaité pour notre application est suffisante comme pour d'autres composants du cycle de vie.

Nous allons commencer par implémenter un analyseur d'images personnalisé. Notre analyseur est assez simple : il enregistre simplement la luminosité moyenne (luminosité) de l'image, mais illustre les mesures à prendre pour les cas d'utilisation arbitrairement complexes. Il suffit de remplacer la fonction "analyze" dans une classe qui implémente l'interface ImageAnalysis.Analyzer. Nous pouvons définir notre mise en œuvre en tant que 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, analysez simplement le processus comme tous les autres cas d'utilisation, puis mettez à jour la fonction "startCamera()" avant l'appel de 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 lier le nouveau cas d'utilisation:

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

Si vous exécutez l'application maintenant, un message de ce type s'affichera dans le fichier logcat environ toutes les secondes:

D/CameraXApp: Average luminosity: ...

Pour tester l'application, il suffit de cliquer sur le bouton Exécuter dans Android Studio. Notre projet sera créé, déployé et lancé sur l'appareil ou l'émulateur sélectionné. Une fois l'application chargée, le viseur devrait rester visible, même après la rotation de l'appareil, grâce au code d'orientation que nous avons ajouté plus tôt. Il doit également pouvoir prendre des photos à l'aide du bouton:

Vous avez terminé l'atelier de programmation. Avec le recul, vous avez implémenté de zéro cette application dans une nouvelle application Android:

  • Des dépendances CameraX incluses dans votre projet sont incluses.
  • Affichage d'un viseur d'appareil photo (avec le cas d'utilisation de l'aperçu)
  • Implémentation de la capture photo, enregistrement des images dans l'espace de stockage (à l'aide du cas d'utilisation d'ImageCapture)
  • Implémentation d'analyses d'images de l'appareil photo en temps réel (à l'aide d'un cas d'utilisation d'ImageAnalysis)

Si vous souhaitez en savoir plus sur CameraX et sur les possibilités qui s'offrent à vous, consultez la documentation ou clonez l'exemple officiel.