Cómo comenzar a usar CameraX

En este codelab, aprenderemos a crear una app de cámara que use CameraX para mostrar un visor, tomar fotos y analizar una transmisión de imágenes desde la cámara.

A fin de lograr esto, presentaremos el concepto de casos de uso en CameraX, que se puede usar para diferentes operaciones de la cámara, desde mostrar un visor hasta analizar fotogramas en tiempo real.

Qué aprenderemos

  • Cómo agregar las dependencias de CameraX
  • Cómo mostrar la vista previa de la cámara en una actividad (Caso de uso de vista previa)
  • Cómo tomar una foto y guardarla en el almacenamiento (Caso de uso de ImageCapture)
  • Cómo analizar fotogramas de la cámara en tiempo real (Caso de uso de ImageAnalysis)

Hardware que necesitamos

  • Un dispositivo Android, aunque el emulador de Android Studio es suficiente El nivel de API mínimo admitido es 21.

Software que necesitaremos

  • Android Studio 3.3 o una versión posterior

Con el menú de Android Studio, inicia un proyecto nuevo y selecciona Empty Activity cuando se te solicite.

Luego, podemos elegir el nombre que queramos; selecciona con ingenio la app de CameraX. Debemos asegurarnos de que el lenguaje esté configurado en Kotlin, que el nivel mínimo de API sea 21 (que es el mínimo requerido para CameraX) y que usemos artefactos de AndroidX.

Para comenzar, agreguemos las dependencias de CameraX a nuestro archivo Gradle de la app, dentro de la sección 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}"

Cuando se te solicite, haz clic en Sync Now, y estarás listo para usar CameraX en nuestra app.

Usaremos un SurfaceTexture para mostrar el visor de la cámara. En este codelab, mostraremos el visor en un formato cuadrado de tamaño fijo. Para ver un ejemplo más completo que muestra un visor responsivo, consulta la muestra oficial.

Edita el archivo de diseño activity_main en 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>

Una parte crucial de agregar cualquier funcionalidad a nuestro proyecto que use la cámara es solicitar los permisos de CAMERA apropiados. Primero, debemos declararlos en el manifiesto, antes de la etiqueta de aplicación:

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

Luego, dentro de MainActivity, necesitamos solicitar permisos durante el tiempo de ejecución. Haremos los cambios en el archivo MainActivity en java > com.example.cameraxapp > MainActivity.kt:

En la parte superior del archivo, fuera de la definición de la clase MainActivity, agreguemos las siguientes constantes y las importaciones:

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

Dentro de la clase MainActivity, agrega los siguientes campos y métodos auxiliares que se usan para solicitar permisos y activar nuestro código cuando sepamos que se otorgaron todos los permisos:

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

Por último, colocamos todo dentro de onCreate para activar la solicitud de permiso cuando corresponda:

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

Ahora, cuando se inicie la aplicación, se comprobará si tiene los permisos adecuados de la cámara. Si lo hace, llama directamente a "startCamera()`". De lo contrario, solicitará los permisos y, una vez otorgados, llamará a "startCamera()`".

Para la mayoría de las aplicaciones de cámara, es muy importante mostrar un visor a los usuarios; de lo contrario, es muy difícil para los usuarios apuntar la cámara hacia el lugar correcto. Se puede implementar un visor utilizando la clase "Preview" de CameraX.

Para usar la vista previa, primero debemos definir una configuración que, luego, se use para crear una instancia del caso de uso. La instancia resultante es lo que necesitamos vincular al ciclo de vida de CameraX. Haremos esto dentro del método `startCamera()`; completa la implementación con este código:

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

En este punto, debemos implementar el misterioso método `updateTransform()`. Dentro de `updateTransform()`, el objetivo es compensar los cambios en la orientación del dispositivo para mostrar nuestro visor en posición vertical:

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

Si quieres implementar una app lista para la producción, consulta el ejemplo oficial a fin de determinar qué más se debe administrar. Con el fin de que este codelab sea breve, estamos realizando algunos accesos directos. Por ejemplo, no hacemos un seguimiento de algunos cambios de configuración, como las rotaciones de dispositivos de 180 grados, que no activan nuestro objeto de escucha de cambio de diseño. Los visores que no sean cuadrados también deben compensar la modificación de la relación de aspecto cuando se rota el dispositivo.

Si compilamos y ejecutamos la app, ahora deberíamos ver una vista previa. ¡Muy bien!

Para permitir que los usuarios capturen imágenes, proporcionaremos un botón como parte del diseño después de la vista de textura en 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" />

Otros casos de uso funcionan de una manera muy similar a la vista previa. Primero, debemos definir un objeto de configuración que se use para crear una instancia del objeto del caso de uso real. Para capturar fotos, cuando se presiona el botón de captura, se debe actualizar el método `startCamera()` y agregar unas líneas más de código al final, antes de la llamada a 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)
}

Luego, actualiza la llamada a CameraX.bindToLifecycle para incluir el nuevo caso de uso:

CameraX.bindToLifecycle(this, preview, imageCapture)

Implementamos un botón funcional para tomar fotos.

Una clase muy interesante de CameraX es la clase ImageAnalysis. Nos permite definir una clase personalizada implementando la interfaz de ImageAnalysis.Analyzer, que se llamará con marcos de la cámara entrantes. De acuerdo con la visión central de CameraX, no tendremos que preocuparnos por administrar el estado de la sesión de la cámara ni incluso por desechar las imágenes. La vinculación con el ciclo de vida deseado de la app es suficiente como para otros componentes optimizados para ciclos de vida.

Primero, implementaremos un analizador de imágenes personalizado. Nuestro analizador es bastante simple: solo registra la luma promedio (luminosidad) de la imagen, pero ejemplifica lo que se debe hacer para casos de uso arbitrarios complejos. Lo único que debemos hacer es anular la función "analyze" en una clase que implemente la interfaz de ImageAnalysis.Analyzer. Podemos definir nuestra implementación como una clase interna dentro de 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
        }
    }
}

Con la clase que implementa la interfaz de ImageAnalysis.Analyzer, lo único que necesitamos hacer es crear una instancia de ImageAnalysis, como todos los demás casos de uso, y actualizar la función `startCamera()` nuevamente, antes de la llamada a 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)
}

Además, actualizamos la llamada a CameraX.bindtoLifecycle para vincular el nuevo caso de uso:

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

Cuando se ejecuta la app, se genera un mensaje similar a este en logcat aproximadamente cada segundo:

D/CameraXApp: Average luminosity: ...

Para probar la app, lo único que debemos hacer es hacer clic en el botón Run de Android Studio, y nuestro proyecto se compilará, implementará y lanzará en el dispositivo o emulador seleccionado. Una vez que se cargue la app, deberíamos ver el visor, que permanecerá en posición vertical incluso después de rotar el dispositivo gracias al código de orientación de orientación que agregamos anteriormente, y también debería poder tomar fotos con el botón:

Completaste el codelab y realizaste la prueba correctamente. En retrospectiva, implementaste los siguientes elementos en una nueva app para Android desde cero:

  • Se incluyeron dependencias de CameraX en tu proyecto.
  • Mostraron un visor de la cámara (con el caso de uso de la vista previa).
  • Se implementó la captura de fotos para guardar imágenes en el almacenamiento (con el caso de uso de ImageCapture).
  • Se implementó el análisis de fotogramas de la cámara en tiempo real (con el caso de uso de ImageAnalysis).

Si quieres obtener más información sobre CameraX y las acciones que puedes realizar con él, consulta la documentación o clona la muestra oficial.