Primeiros passos com o CameraX (link em inglês)

Neste codelab, você aprenderá a criar um app de câmera que usa o CameraX para mostrar um visor, tirar fotos e analisar um stream de imagem da câmera.

Para isso, apresentaremos o conceito de casos de uso no CameraX, que podem ser usados em diversas operações de câmera, como exibir um visor e analisar frames em tempo real.

O que vamos aprender

  • Como adicionar as dependências do CameraX.
  • Como exibir a visualização da câmera em uma atividade. (Caso de uso de visualização)
  • Como tirar uma foto e salvá-la no armazenamento. (Caso de uso de ImageCapture)
  • Como analisar frames da câmera em tempo real. (Caso de uso de ImageAnalysis)

Hardware necessário

  • Um dispositivo Android é usado, mas o emulador do Android Studio pode ser usado normalmente. O nível mínimo compatível da API é 21.

Softwares necessários

  • Android Studio 3.3 ou uma versão mais recente.

Usando o menu do Android Studio, inicie um novo projeto e selecione Empty Activity quando solicitado.

A seguir, podemos escolher o nome que quisermos. Nós escolhemos o "CameraX App". É importante garantir que a linguagem esteja definida como Kotlin, que o nível mínimo de API seja 21 (o mínimo exigido para o CameraX) e que usemos artefatos do AndroidX.

Para começar, vamos adicionar as dependências do CameraX ao arquivo Gradle do app, na seção 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}"

Quando solicitado, clique em Sync Now para que possamos usar o CameraX no nosso app.

Usaremos uma SurfaceTexture para exibir o visor da câmera. Neste codelab, exibiremos o visor em um formato quadrado de tamanho fixo. Para ver um exemplo mais abrangente que mostra um visor responsivo, confira a amostra oficial.

Vamos editar o arquivo de layout activity_main em 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>

Uma parte crucial da adição de qualquer funcionalidade no nosso projeto que usa a câmera é a solicitação das permissões adequadas da CAMERA. Primeiro, precisamos declará-las no manifesto, antes da tag do aplicativo:

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

Em seguida, dentro da MainActivity, precisamos solicitar permissões no momento da execução. Faremos as mudanças no arquivo MainActivity em java > com.example.cameraxapp > MainActivity.kt:

Na parte superior do arquivo, fora da definição da classe MainActivity, vamos adicionar as seguintes constantes e importações:

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

Na classe MainActivity, adicione os seguintes campos e métodos auxiliares que são usados para solicitar permissões e acionar nosso código quando soubermos que todas as permissões foram concedidas:

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 fim, reunimos tudo dentro de onCreate para acionar a solicitação de permissão, quando adequado:

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

Agora, quando o aplicativo for iniciado, ele verificará se tem as permissões de câmera adequadas. Em caso afirmativo, ele chamará "startCamera()" diretamente. Caso contrário, ele solicitará as permissões e, depois de concedido, chamará "startCamera()".

Para a maioria dos aplicativos de câmera, é muito importante mostrar um visor aos usuários. Caso contrário, é muito difícil apontar a câmera para o local certo. Um visor pode ser implementado usando a classe "Preview" do CameraX.

Para usar a visualização, primeiro precisamos definir uma configuração que será usada para criar uma instância do caso de uso. A instância resultante é a que precisamos vincular ao ciclo de vida do CameraX. Faremos isso dentro do método "startCamera()". Preencha a implementação com 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)
}

Nesse ponto, precisamos implementar o método misterioso "updateTransform()". Dentro de "updateTransform(), o objetivo é compensar as mudanças na orientação do dispositivo para exibir nosso visor na rotação 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 um app pronto para produção, consulte a amostra oficial (link em inglês) para saber o que mais precisa ser processado. Para simplificar esse codelab, estamos usando alguns atalhos. Por exemplo, não estamos acompanhando algumas mudanças de configuração, como rotações de dispositivos de 180 graus, que não acionam nosso listener de mudança de layout. Os visores não quadrados também precisam compensar a mudança de proporção quando o dispositivo é girado.

Agora, se criarmos e executarmos o app, uma visualização será exibida. Legal!

Para permitir que os usuários capturem imagens, forneceremos um botão como parte do layout após a visualização da textura em 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" />

Outros casos de uso funcionam de maneira muito semelhante em comparação com o pré-lançamento. Primeiro, precisamos definir um objeto de configuração usado para instanciar o objeto de caso de uso real. Para capturar fotos, quando o botão de captura é pressionado, precisamos atualizar o método "startCamera()" e adicionar mais algumas linhas de código no final, antes da chamada para 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)
}

Em seguida, atualize a chamada para CameraX.bindToLifecycle para incluir o novo caso de uso:

CameraX.bindToLifecycle(this, preview, imageCapture)

Agora, implementamos um botão funcional para tirar fotos.

Um recurso muito interessante do CameraX é a classe ImageAnalysis. Ele permite definir uma classe personalizada que implementa a interface ImageAnalysis.Analyzer, que será chamada com frames de câmera recebidos. De acordo com a visão central do CameraX, não precisamos nos preocupar com o gerenciamento do estado da sessão da câmera nem com o descarte de imagens. Vincular o ciclo de vida desejado do app é suficiente como outros componentes que reconhecem o ciclo de vida.

Primeiro, implementaremos um analisador de imagem personalizado. Nosso analisador é bastante simples: ele registra apenas a luminosidade média da imagem, mas exemplifica o que precisa ser feito em casos de uso arbitrariamente complexos. Tudo o que precisamos fazer é substituir a função `analyze` em uma classe que implemente a interface ImageAnalysis.Analyzer. Podemos definir nossa implementação como uma classe interna na 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
        }
    }
}

Com nossa classe implementando a interface ImageAnalysis.Analyzer, basta instanciar a ImageAnalysis como todos os outros casos de uso e atualizar a função "startCamera()" novamente antes de chamar o 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)
}

Também atualizamos a chamada para CameraX.bindtoLifecycle para vincular o novo caso de uso:

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

Executar o app agora produzirá uma mensagem semelhante a esta no logcat aproximadamente a cada segundo:

D/CameraXApp: Average luminosity: ...

Para testar o app, basta clicar no botão Run no Android Studio. Nosso projeto será criado, implantado e iniciado no dispositivo ou emulador selecionado. Depois que o app for carregado, veremos o visor, que permanecerá na posição vertical mesmo após girar o dispositivo, graças ao código de processamento de orientação que adicionamos, além de poder tirar fotos usando o botão:

Você concluiu o codelab Olhando para trás, você implementou o seguinte em um novo app Android do zero:

  • Inclusão de dependências do CameraX no seu projeto.
  • Exibir um visor da câmera (usando o caso de uso de Visualização)
  • Implementação de captura de fotos, salvando imagens no armazenamento (usando o caso de uso ImageCapture)
  • Implementação de análise de frames da câmera em tempo real (usando o caso de uso de ImageAnalysis)

Se quiser saber mais sobre o CameraX e o que você pode fazer com ele, confira a documentação ou clone o exemplo oficial.