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 un flujo de imágenes desde la cámara.

Para lograr esto, presentaremos el concepto de casos de uso en CameraX, que se pueden usar para varias operaciones de 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 Preview)
  • 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 necesitaremos

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

Software que necesitaremos

  • Android Studio 3.3 o una versión posterior

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

A continuación, podemos elegir el nombre que queramos. Ingeniosamente, elegimos "App de CameraX". Debemos asegurarnos de que el lenguaje esté configurado en Kotlin, 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 de 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 muestre un visor responsivo, consulta el ejemplo oficial.

Editemos 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 fundamental de agregar cualquier funcionalidad en nuestro proyecto que use la cámara es solicitar los permisos de CAMERA adecuados. Primero, debemos declararlos en el manifiesto, antes de la etiqueta Application:

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

Luego, dentro de MainActivity, debemos solicitar permisos en el tiempo de ejecución. Realizaremos 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 e 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 una vez que sabemos 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, juntamos 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 verificará si tiene los permisos de cámara adecuados. Si lo hace, llamará a `startCamera()` directamente. De lo contrario, solicitará los permisos y, una vez que se otorguen, llamará a `startCamera()`.

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

Para usar la vista previa, primero debemos definir una configuración que luego se usa para crear una instancia del caso de uso. La instancia resultante es la 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 rotació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)
}

Para implementar una app lista para producción, consulta el ejemplo oficial y descubre qué más debes controlar. Para que este codelab sea breve, tomaremos algunos atajos. Por ejemplo, no hacemos un seguimiento de algunos cambios de configuración, como las rotaciones de 180 grados del dispositivo, que no activan nuestro objeto de escucha de cambios de diseño. Los visores no cuadrados también deben compensar el cambio de relación de aspecto cuando se rota el dispositivo.

Si compilamos y ejecutamos la app, ahora deberíamos ver una vista previa en vivo. ¡Genial!

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

Y así implementamos un botón funcional para tomar fotos.

Una función muy interesante de CameraX es la clase ImageAnalysis. Nos permite definir una clase personalizada que implementa la interfaz ImageAnalysis.Analyzer, a la que se llamará con los fotogramas de cámara entrantes. En consonancia con la visión principal de CameraX, no tendremos que preocuparnos por administrar el estado de la sesión de la cámara ni deshacernos de las imágenes. Basta con hacer la vinculación al ciclo de vida deseado de nuestra app, como sucede con otros componentes optimizados para ciclos de vida.

Primero, implementaremos un analizador de imágenes personalizado. Nuestro analizador es bastante simple: solo registra la luminancia promedio (luminosidad) de la imagen, pero ejemplifica lo que se debe hacer para casos de uso arbitrariamente complejos. Todo lo que debemos hacer es anular la función "analyze" en una clase que implemente la interfaz 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 nuestra clase que implementa la interfaz ImageAnalysis.Analyzer, lo único que debemos hacer es crear una instancia de ImageAnalysis como todos los demás casos de uso y actualizar la función `startCamera()` una vez más, antes de llamar 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)
}

También actualizamos la llamada a CameraX.bindtoLifecycle para vincular el nuevo caso de uso:

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

Cuando se ejecute la app, se generará un mensaje similar a este en logcat a cada segundo, aproximadamente:

D/CameraXApp: Average luminosity: ...

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

Completaste el lab de código correctamente. Si recuerdas, implementaste lo siguiente en una nueva app para Android desde cero:

  • Se incluyeron dependencias de CameraX en tu proyecto.
  • Se mostró un visor de la cámara (con el caso de uso de Preview).
  • Se implementó la captura de fotos y el guardado de 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 lo que puedes hacer con esta biblioteca, consulta la documentación o clona el ejemplo oficial.